/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.catalina.ha.tcp;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;

import javax.servlet.ServletException;

import org.apache.catalina.Cluster;
import org.apache.catalina.Context;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.Manager;
import org.apache.catalina.Session;
import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.ha.CatalinaCluster;
import org.apache.catalina.ha.ClusterManager;
import org.apache.catalina.ha.ClusterMessage;
import org.apache.catalina.ha.ClusterSession;
import org.apache.catalina.ha.ClusterValve;
import org.apache.catalina.ha.session.DeltaManager;
import org.apache.catalina.ha.session.DeltaSession;
import org.apache.catalina.valves.ValveBase;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.apache.tomcat.util.res.StringManager;

/**
 * Valve that triggers session replication across the cluster.
 */
public class ReplicationValve extends ValveBase implements ClusterValve {

    private static final Log log = LogFactory.getLog(ReplicationValve.class);

    // ----------------------------------------------------- Instance Variables

    /**
     * The StringManager for this package.
     */
    protected static final StringManager sm = StringManager.getManager(Constants.Package);

    private CatalinaCluster cluster = null;

    /**
     * Filter expression
     */
    protected Pattern filter = null;

    /**
     * crossContext session container
     */
    protected final ThreadLocal<ArrayList<DeltaSession>> crossContextSessions = new ThreadLocal<>();

    /**
     * doProcessingStats (default = off)
     */
    protected boolean doProcessingStats = false;

    /**
     * Total request time in nanoseconds.
     */
    protected LongAdder totalRequestTime = new LongAdder();
    /**
     * Total send time in nanoseconds.
     */
    protected LongAdder totalSendTime = new LongAdder();
    /**
     * Total number of requests.
     */
    protected AtomicLong lastSendTime = new AtomicLong();
    /**
     * Number of requests filtered out.
     */
    protected LongAdder nrOfFilterRequests = new LongAdder();
    /**
     * Number of requests sent for replication.
     */
    protected LongAdder nrOfSendRequests = new LongAdder();
    /**
     * Number of cross-context requests sent for replication.
     */
    protected LongAdder nrOfCrossContextSendRequests = new LongAdder();
    /**
     * Number of requests.
     */
    protected LongAdder nrOfRequests = new LongAdder();

    /**
     * Must set primary change indicator.
     */
    protected boolean primaryIndicator = false;

    /**
     * Name of primary change indicator as request attribute
     */
    protected String primaryIndicatorName = "org.apache.catalina.ha.tcp.isPrimarySession";

    // ------------------------------------------------------------- Properties

    /**
     * Default constructor.
     */
    public ReplicationValve() {
        super(true);
    }

    /**
     * @return the cluster.
     */
    @Override
    public CatalinaCluster getCluster() {
        return cluster;
    }

    /**
     * @param cluster The cluster to set.
     */
    @Override
    public void setCluster(CatalinaCluster cluster) {
        this.cluster = cluster;
    }

    /**
     * Return the replication filter pattern as a string.
     *
     * @return the filter pattern, or {@code null} if not set
     */
    public String getFilter() {
        if (filter == null) {
            return null;
        }
        return filter.toString();
    }

    /**
     * compile filter string to regular expression
     *
     * @see Pattern#compile(String)
     *
     * @param filter The filter to set.
     */
    public void setFilter(String filter) {
        if (log.isTraceEnabled()) {
            log.trace(sm.getString("ReplicationValve.filter.loading", filter));
        }

        if (filter == null || filter.isEmpty()) {
            this.filter = null;
        } else {
            try {
                this.filter = Pattern.compile(filter);
            } catch (PatternSyntaxException pse) {
                log.error(sm.getString("ReplicationValve.filter.failure", filter), pse);
            }
        }
    }

    /**
     * Return whether the primary indicator is enabled.
     *
     * @return {@code true} if the primary indicator is enabled
     */
    public boolean isPrimaryIndicator() {
        return primaryIndicator;
    }

    /**
     * Set whether the primary indicator is enabled.
     *
     * @param primaryIndicator {@code true} to enable the primary indicator
     */
    public void setPrimaryIndicator(boolean primaryIndicator) {
        this.primaryIndicator = primaryIndicator;
    }

    /**
     * Return the primary indicator name.
     *
     * @return the primary indicator name
     */
    public String getPrimaryIndicatorName() {
        return primaryIndicatorName;
    }

    /**
     * Set the primary indicator name.
     *
     * @param primaryIndicatorName the primary indicator name
     */
    public void setPrimaryIndicatorName(String primaryIndicatorName) {
        this.primaryIndicatorName = primaryIndicatorName;
    }

    /**
     * Calc processing stats
     *
     * @return <code>true</code> if statistics are enabled
     */
    public boolean doStatistics() {
        return doProcessingStats;
    }

    /**
     * Set Calc processing stats
     *
     * @param doProcessingStats New flag value
     *
     * @see #resetStatistics()
     */
    public void setStatistics(boolean doProcessingStats) {
        this.doProcessingStats = doProcessingStats;
    }

    /**
     * Return the last send time.
     *
     * @return the last send time
     */
    public long getLastSendTime() {
        return lastSendTime.longValue();
    }

    /**
     * Return the number of requests.
     *
     * @return the number of requests
     */
    public long getNrOfRequests() {
        return nrOfRequests.longValue();
    }

    /**
     * Return the number of filtered requests.
     *
     * @return the number of filtered requests
     */
    public long getNrOfFilterRequests() {
        return nrOfFilterRequests.longValue();
    }

    /**
     * Return the number of cross-context send requests.
     *
     * @return the number of cross-context send requests
     */
    public long getNrOfCrossContextSendRequests() {
        return nrOfCrossContextSendRequests.longValue();
    }

    /**
     * Return the number of send requests.
     *
     * @return the number of send requests
     */
    public long getNrOfSendRequests() {
        return nrOfSendRequests.longValue();
    }

    /**
     * Return the total request time.
     *
     * @return the total request time
     */
    public long getTotalRequestTime() {
        return totalRequestTime.longValue();
    }

    /**
     * Return the total send time.
     *
     * @return the total send time
     */
    public long getTotalSendTime() {
        return totalSendTime.longValue();
    }

    // --------------------------------------------------------- Public Methods

    /**
     * Register a cross-context session for replication. The session is added to the current thread's cross-context
     * session list if it is not already present.
     *
     * @param session the cross-context session to register
     */
    public void registerReplicationSession(DeltaSession session) {
        List<DeltaSession> sessions = crossContextSessions.get();
        if (sessions != null) {
            if (!sessions.contains(session)) {
                if (log.isTraceEnabled()) {
                    log.trace(sm.getString("ReplicationValve.crossContext.registerSession", session.getIdInternal(),
                            session.getManager().getContext().getName()));
                }
                sessions.add(session);
            }
        }
    }

    @Override
    public void invoke(Request request, Response response) throws IOException, ServletException {
        long totalstart = 0;

        // this happens before the request
        if (doStatistics()) {
            totalstart = System.currentTimeMillis();
        }
        if (primaryIndicator) {
            createPrimaryIndicator(request);
        }
        Context context = request.getContext();
        boolean isCrossContext = context instanceof StandardContext && context.getCrossContext();
        boolean isAsync = request.getAsyncContextInternal() != null;
        try {
            if (isCrossContext) {
                if (log.isTraceEnabled()) {
                    log.trace(sm.getString("ReplicationValve.crossContext.add"));
                }
                crossContextSessions.set(new ArrayList<>());
            }
            getNext().invoke(request, response);
            if (context != null && cluster != null && context.getManager() instanceof ClusterManager) {
                ClusterManager clusterManager = (ClusterManager) context.getManager();

                // valve cluster can access manager - other cluster handle replication
                // at host level - hopefully!
                if (cluster.getManager(clusterManager.getName()) == null) {
                    return;
                }
                if (cluster.hasMembers()) {
                    sendReplicationMessage(request, totalstart, isCrossContext, isAsync, clusterManager);
                } else {
                    resetReplicationRequest(request, isCrossContext);
                }
            }
        } finally {
            // Array must be removed: Current master request send endAccess at recycle.
            // Don't register this request session again!
            if (isCrossContext) {
                if (log.isTraceEnabled()) {
                    log.trace(sm.getString("ReplicationValve.crossContext.remove"));
                }
                crossContextSessions.remove();
            }
        }
    }


    /**
     * reset the active statistics
     */
    public void resetStatistics() {
        totalRequestTime.reset();
        totalSendTime.reset();
        lastSendTime.set(0);
        nrOfFilterRequests.reset();
        nrOfRequests.reset();
        nrOfSendRequests.reset();
        nrOfCrossContextSendRequests.reset();
    }

    @Override
    protected void startInternal() throws LifecycleException {
        if (cluster == null) {
            Cluster containerCluster = getContainer().getCluster();
            if (containerCluster instanceof CatalinaCluster) {
                setCluster((CatalinaCluster) containerCluster);
            } else {
                if (log.isWarnEnabled()) {
                    log.warn(sm.getString("ReplicationValve.nocluster"));
                }
            }
        }
        super.startInternal();
    }


    // --------------------------------------------------------- Protected Methods

    /**
     * Send a replication message for the given request.
     *
     * @param request        the request
     * @param totalstart     the start time
     * @param isCrossContext {@code true} if cross-context
     * @param isAsync        {@code true} if async
     * @param clusterManager the cluster manager
     */
    protected void sendReplicationMessage(Request request, long totalstart, boolean isCrossContext, boolean isAsync,
            ClusterManager clusterManager) {
        // this happens after the request
        long start = 0;
        if (doStatistics()) {
            start = System.currentTimeMillis();
        }
        try {
            sendInvalidSessions(clusterManager);
            try {
                sendSessionReplicationMessage(request, clusterManager);
            } catch (Exception e) {
                log.error(sm.getString("ReplicationValve.send.failure"), e);
            }
            try {
                if (isCrossContext) {
                    sendCrossContextSession();
                }
            } catch (Exception e) {
                log.error(sm.getString("ReplicationValve.send.failure"), e);
            }
        } finally {
            if (doStatistics()) {
                updateStats(totalstart, start, isAsync);
            }
        }
    }

    /**
     * Send all changed cross context sessions to backups
     */
    protected void sendCrossContextSession() {
        List<DeltaSession> sessions = crossContextSessions.get();
        if (sessions != null && !sessions.isEmpty()) {
            for (DeltaSession session : sessions) {
                if (log.isTraceEnabled()) {
                    log.trace(sm.getString("ReplicationValve.crossContext.sendDelta",
                            session.getManager().getContext().getName()));
                }
                sendMessage(session, (ClusterManager) session.getManager());
                if (doStatistics()) {
                    nrOfCrossContextSendRequests.increment();
                }
            }
        }
    }

    /**
     * Fix memory leak for long sessions with many changes, when no backup member exists!
     *
     * @param request        current request after response is generated
     * @param isCrossContext check crosscontext threadlocal
     */
    protected void resetReplicationRequest(Request request, boolean isCrossContext) {
        Session contextSession = request.getSessionInternal(false);
        if (contextSession instanceof DeltaSession) {
            resetDeltaRequest(contextSession);
            ((DeltaSession) contextSession).setPrimarySession(true);
        }
        if (isCrossContext) {
            List<DeltaSession> sessions = crossContextSessions.get();
            if (sessions != null) {
                for (DeltaSession session : sessions) {
                    resetDeltaRequest(session);
                    session.setPrimarySession(true);
                }
            }
        }
    }

    /**
     * Reset DeltaRequest from session
     *
     * @param session HttpSession from current request or cross context session
     */
    protected void resetDeltaRequest(Session session) {
        if (log.isTraceEnabled()) {
            log.trace(sm.getString("ReplicationValve.resetDeltaRequest", session.getManager().getContext().getName()));
        }
        ((DeltaSession) session).resetDeltaRequest();
    }

    /**
     * Send Cluster Replication Request
     *
     * @param request current request
     * @param manager session manager
     */
    protected void sendSessionReplicationMessage(Request request, ClusterManager manager) {
        Session session = request.getSessionInternal(false);
        if (session != null) {
            String uri = request.getDecodedRequestURI();
            // request without session change
            if (!isRequestWithoutSessionChange(uri)) {
                if (log.isDebugEnabled()) {
                    log.debug(sm.getString("ReplicationValve.invoke.uri", uri));
                }
                sendMessage(session, manager);
            } else if (doStatistics()) {
                nrOfFilterRequests.increment();
            }
        }

    }

    /**
     * Send message delta message from request session
     *
     * @param session current session
     * @param manager session manager
     */
    protected void sendMessage(Session session, ClusterManager manager) {
        String id = session.getIdInternal();
        if (id != null) {
            send(manager, id);
        }
    }

    /**
     * send manager requestCompleted message to cluster
     *
     * @param manager   SessionManager
     * @param sessionId sessionid from the manager
     *
     * @see DeltaManager#requestCompleted(String)
     * @see SimpleTcpCluster#send(ClusterMessage)
     */
    protected void send(ClusterManager manager, String sessionId) {
        ClusterMessage msg = manager.requestCompleted(sessionId);
        if (msg != null && cluster != null) {
            cluster.send(msg);
            if (doStatistics()) {
                nrOfSendRequests.increment();
            }
        }
    }

    /**
     * check for session invalidations
     *
     * @param manager Associated manager
     */
    protected void sendInvalidSessions(ClusterManager manager) {
        String[] invalidIds = manager.getInvalidatedSessions();
        for (String invalidId : invalidIds) {
            try {
                send(manager, invalidId);
            } catch (Exception e) {
                log.error(sm.getString("ReplicationValve.send.invalid.failure", invalidId), e);
            }
        }
    }

    /**
     * is request without possible session change
     *
     * @param uri The request uri
     *
     * @return True if no session change
     */
    protected boolean isRequestWithoutSessionChange(String uri) {
        Pattern f = filter;
        return f != null && f.matcher(uri).matches();
    }

    /**
     * Protocol cluster replications stats
     *
     * @param requestTime Request time
     * @param clusterTime Cluster time
     * @param isAsync     if the request was in async mode
     */
    protected void updateStats(long requestTime, long clusterTime, boolean isAsync) {
        long currentTime = System.currentTimeMillis();
        lastSendTime.set(currentTime);
        totalSendTime.add(currentTime - clusterTime);
        totalRequestTime.add(currentTime - requestTime);
        if (!isAsync) {
            nrOfRequests.increment();
            if (log.isDebugEnabled()) {
                if ((nrOfRequests.longValue() % 100) == 0) {
                    log.debug(sm.getString("ReplicationValve.stats",
                            Long.valueOf(totalRequestTime.longValue() / nrOfRequests.longValue()),
                            Long.valueOf(totalSendTime.longValue() / nrOfRequests.longValue()),
                            Long.valueOf(nrOfRequests.longValue()), Long.valueOf(nrOfSendRequests.longValue()),
                            Long.valueOf(nrOfCrossContextSendRequests.longValue()),
                            Long.valueOf(nrOfFilterRequests.longValue()), Long.valueOf(totalRequestTime.longValue()),
                            Long.valueOf(totalSendTime.longValue())));
                }
            }
        }
    }


    /**
     * Mark Request that processed at primary node with attribute primaryIndicatorName
     *
     * @param request The Servlet request
     *
     * @throws IOException IO error finding session
     */
    @SuppressWarnings("null") // ctx can't be null
    protected void createPrimaryIndicator(Request request) throws IOException {
        String id = request.getRequestedSessionId();
        if ((id != null) && (!id.isEmpty())) {
            Context ctx = request.getContext();
            Session session = null;
            if (ctx != null) {
                Manager manager = ctx.getManager();
                if (manager != null) {
                    session = manager.findSession(id);
                }
            }
            if (session instanceof ClusterSession) {
                ClusterSession cses = (ClusterSession) session;
                if (log.isDebugEnabled()) {
                    log.debug(sm.getString("ReplicationValve.session.indicator", ctx.getPath(), id, primaryIndicatorName,
                            Boolean.valueOf(cses.isPrimarySession())));
                }
                request.setAttribute(primaryIndicatorName, cses.isPrimarySession() ? Boolean.TRUE : Boolean.FALSE);
            } else {
                if (log.isDebugEnabled()) {
                    if (session != null) {
                        log.debug(sm.getString("ReplicationValve.session.found", ctx.getPath(), id));
                    } else {
                        log.debug(sm.getString("ReplicationValve.session.invalid",
                                ctx == null ? "null Context" : ctx.getPath(), id));
                    }
                }
            }
        }
    }

}
