/*
 * 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.geode.modules.session.catalina;

import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.io.IOException;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;

import org.apache.catalina.Context;
import org.apache.catalina.Lifecycle;
import org.apache.catalina.Pipeline;
import org.apache.catalina.Session;
import org.apache.catalina.Valve;
import org.apache.catalina.session.ManagerBase;
import org.apache.catalina.session.StandardSession;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;

import org.apache.geode.cache.Cache;
import org.apache.geode.cache.CacheFactory;
import org.apache.geode.internal.cache.GemFireCacheImpl;
import org.apache.geode.modules.session.catalina.internal.DeltaSessionStatistics;
import org.apache.geode.modules.util.ContextMapper;
import org.apache.geode.modules.util.RegionConfiguration;
import org.apache.geode.modules.util.RegionHelper;

public abstract class DeltaSessionManager<CommitSessionValveT extends AbstractCommitSessionValve<?>>
    extends ManagerBase
    implements Lifecycle, PropertyChangeListener, SessionManager, DeltaSessionManagerConfiguration {

  static final String catalinaBaseSystemProperty = "catalina.base";
  static final String javaTempDirSystemProperty = "java.io.tmpdir";
  static final String fileSeparatorSystemProperty = "file.separator";
  /**
   * The number of rejected sessions.
   */
  private final AtomicInteger rejectedSessions;

  /**
   * The maximum number of active Sessions allowed, or -1 for no limit.
   */
  private int maxActiveSessions = -1;

  /**
   * Has this <code>Manager</code> been started?
   */
  protected AtomicBoolean started = new AtomicBoolean(false);

  /**
   * The name of this <code>Manager</code>
   */
  protected String name;

  private Valve jvmRouteBinderValve;

  private CommitSessionValveT commitSessionValve;

  private SessionCache sessionCache;

  private static final String DEFAULT_REGION_NAME = RegionHelper.NAME + "_sessions";

  private static final boolean DEFAULT_ENABLE_GATEWAY_REPLICATION = false;

  private static final boolean DEFAULT_ENABLE_DEBUG_LISTENER = false;

  private static final boolean DEFAULT_ENABLE_COMMIT_VALVE = true;

  private static final boolean DEFAULT_ENABLE_COMMIT_VALVE_FAILFAST = false;

  /**
   * @deprecated No replacement. Always prefer deserialized form.
   */
  @Deprecated
  private static final boolean DEFAULT_PREFER_DESERIALIZED_FORM = true;

  /*
   * This *MUST* only be assigned during start/startInternal otherwise it will be associated with
   * the incorrect context class loader.
   */
  private Log LOGGER;

  protected String regionName = DEFAULT_REGION_NAME;

  private String regionAttributesId; // the default is different for client-server and
  // peer-to-peer

  private Boolean enableLocalCache; // the default is different for client-server and peer-to-peer

  private boolean enableCommitValve = DEFAULT_ENABLE_COMMIT_VALVE;

  private boolean enableCommitValveFailfast = DEFAULT_ENABLE_COMMIT_VALVE_FAILFAST;

  private boolean enableGatewayReplication = DEFAULT_ENABLE_GATEWAY_REPLICATION;

  private boolean enableDebugListener = DEFAULT_ENABLE_DEBUG_LISTENER;

  /**
   * @deprecated No replacement. Always prefer deserialized form.
   */
  @Deprecated
  private boolean preferDeserializedForm = DEFAULT_PREFER_DESERIALIZED_FORM;

  private Timer timer;

  private final Set<String> sessionsToTouch;

  private static final long TIMER_TASK_PERIOD =
      Long.getLong("gemfiremodules.sessionTimerTaskPeriod", 10000);

  private static final long TIMER_TASK_DELAY =
      Long.getLong("gemfiremodules.sessionTimerTaskDelay", 10000);

  public DeltaSessionManager() {
    rejectedSessions = new AtomicInteger(0);
    // Create the set to store sessions to be touched after get attribute requests
    sessionsToTouch = Collections.newSetFromMap(new ConcurrentHashMap<>());
  }

  @Override
  public String getRegionName() {
    return regionName;
  }

  @Override
  public void setRegionName(String regionName) {
    this.regionName = regionName;
  }

  @Override
  public String getRegionAttributesId() {
    // This property will be null if it hasn't been set in the context.xml file.
    // Since its default is dependent on the session cache, get the default from
    // the session cache.
    if (regionAttributesId == null) {
      regionAttributesId = getSessionCache().getDefaultRegionAttributesId();
    }
    return regionAttributesId;
  }

  @Override
  public void setRegionAttributesId(String regionType) {
    regionAttributesId = regionType;
  }

  @Override
  public boolean getEnableLocalCache() {
    // This property will be null if it hasn't been set in the context.xml file.
    // Since its default is dependent on the session cache, get the default from
    // the session cache.
    if (enableLocalCache == null) {
      enableLocalCache = getSessionCache().getDefaultEnableLocalCache();
    }
    return enableLocalCache;
  }

  @Override
  public void setEnableLocalCache(boolean enableLocalCache) {
    this.enableLocalCache = enableLocalCache;
  }

  @Override
  public int getMaxActiveSessions() {
    return maxActiveSessions;
  }

  @Override
  public void setMaxActiveSessions(int maxActiveSessions) {
    int oldMaxActiveSessions = this.maxActiveSessions;
    this.maxActiveSessions = maxActiveSessions;
    support.firePropertyChange("maxActiveSessions", new Integer(oldMaxActiveSessions),
        new Integer(this.maxActiveSessions));
  }

  @Override
  public boolean getEnableGatewayDeltaReplication() {
    // return this.enableGatewayDeltaReplication;
    return false; // disabled
  }

  @Override
  public void setEnableGatewayDeltaReplication(boolean enableGatewayDeltaReplication) {
    // this.enableGatewayDeltaReplication = enableGatewayDeltaReplication;
    // Disabled. Keeping the method for backward compatibility.
  }

  @Override
  public boolean getEnableGatewayReplication() {
    return enableGatewayReplication;
  }

  @Override
  public void setEnableGatewayReplication(boolean enableGatewayReplication) {
    this.enableGatewayReplication = enableGatewayReplication;
  }

  @Override
  public boolean getEnableDebugListener() {
    return enableDebugListener;
  }

  @Override
  public void setEnableDebugListener(boolean enableDebugListener) {
    this.enableDebugListener = enableDebugListener;
  }

  @Override
  public boolean isCommitValveEnabled() {
    return enableCommitValve;
  }

  @Override
  public void setEnableCommitValve(boolean enable) {
    enableCommitValve = enable;
  }

  @Override
  public boolean isCommitValveFailfastEnabled() {
    return enableCommitValveFailfast;
  }

  @Override
  public void setEnableCommitValveFailfast(boolean enable) {
    enableCommitValveFailfast = enable;
  }

  @Override
  public boolean isBackingCacheAvailable() {
    return sessionCache.isBackingCacheAvailable();
  }

  /**
   * @deprecated No replacement. Always prefer deserialized form.
   */
  @Deprecated
  @Override
  public void setPreferDeserializedForm(boolean enable) {
    LOGGER
        .warn("Use of deprecated preferDeserializedForm property to be removed in future release.");
    if (!enable) {
      LOGGER.warn(
          "Use of HttpSessionAttributeListener may result in serialized form in HttpSessionBindingEvent.");
    }
    preferDeserializedForm = enable;
  }

  /**
   * @deprecated No replacement. Always prefer deserialized form.
   */
  @Deprecated
  @Override
  public boolean getPreferDeserializedForm() {
    return preferDeserializedForm;
  }

  @Override
  public String getStatisticsName() {
    return getContextName().replace("/", "");
  }

  @Override
  public Log getLogger() {
    if (LOGGER == null) {
      LOGGER = LogFactory.getLog(DeltaSessionManager.class);
    }
    return LOGGER;
  }

  public SessionCache getSessionCache() {
    return sessionCache;
  }

  public DeltaSessionStatistics getStatistics() {
    return getSessionCache().getStatistics();
  }

  boolean isPeerToPeer() {
    return getSessionCache().isPeerToPeer();
  }

  public boolean isClientServer() {
    return getSessionCache().isClientServer();
  }

  @Override
  public Session findSession(String id) {
    if (id == null) {
      return null;
    }
    if (getLogger().isDebugEnabled()) {
      getLogger().debug(
          this + ": Finding session " + id + " in " + getSessionCache().getOperatingRegionName());
    }
    DeltaSessionInterface session = (DeltaSessionInterface) getSessionCache().getSession(id);
    /*
     * Check that the context name for this session is the same as this manager's. This comes into
     * play when multiple versions of a webapp are deployed and active at the same time; the context
     * name will contain an embedded version number; something like /test###2.
     */
    if (session != null && !session.getContextName().isEmpty()
        && !getContextName().equals(session.getContextName())) {
      getLogger()
          .info(this + ": Session " + id + " rejected as container name and context do not match: "
              + getContextName() + " != " + session.getContextName());
      session = null;
    }

    if (session == null) {
      if (getLogger().isDebugEnabled()) {
        getLogger().debug(this + ": Did not find session " + id + " in "
            + getSessionCache().getOperatingRegionName());
      }
    } else {
      if (getLogger().isDebugEnabled()) {
        getLogger().debug(this + ": Found session " + id + " in "
            + getSessionCache().getOperatingRegionName() + ": " + session);
      }
      // The session was previously stored. Set new to false.
      session.setNew(false);

      // Check the manager.
      // If the manager is null, the session was replicated and this is a
      // failover situation. Reset the manager and activate the session.
      if (session.getManager() == null) {
        session.setOwner(this);
        session.activate();
      }
    }

    return session;
  }

  protected void initializeSessionCache() {
    // Retrieve the cache
    GemFireCacheImpl cache = (GemFireCacheImpl) getAnyCacheInstance();
    if (cache == null) {
      throw new IllegalStateException(
          "No cache exists. Please configure either a PeerToPeerCacheLifecycleListener or ClientServerCacheLifecycleListener in the server.xml file.");
    }

    // Create the appropriate session cache
    sessionCache = cache.isClient() ? new ClientServerSessionCache(this, cache)
        : new PeerToPeerSessionCache(this, cache);

    // Initialize the session cache
    initSessionCache();
  }

  void initSessionCache() {
    sessionCache.initialize();
  }

  Cache getAnyCacheInstance() {
    return CacheFactory.getAnyInstance();
  }

  @Override
  protected StandardSession getNewSession() {
    return new DeltaSession(this);
  }

  @Override
  public void remove(Session session) {
    // Remove the session from the region if necessary.
    // It will have already been removed if it expired implicitly.
    DeltaSessionInterface ds = (DeltaSessionInterface) session;
    if (ds.getExpired()) {
      if (getLogger().isDebugEnabled()) {
        getLogger().debug(this + ": Expired session " + session.getId() + " from "
            + getSessionCache().getOperatingRegionName());
      }
    } else {
      if (getLogger().isDebugEnabled()) {
        getLogger().debug(this + ": Destroying session " + session.getId() + " from "
            + getSessionCache().getOperatingRegionName());
      }
      getSessionCache().destroySession(session.getId());
      if (getLogger().isDebugEnabled()) {
        getLogger().debug(this + ": Destroyed session " + session.getId() + " from "
            + getSessionCache().getOperatingRegionName());
      }
    }
  }

  @Override
  public void add(Session session) {
    // super.add(session);
    if (getLogger().isDebugEnabled()) {
      getLogger().debug(this + ": Storing session " + session.getId() + " into "
          + getSessionCache().getOperatingRegionName());
    }
    getSessionCache().putSession(session);
    if (getLogger().isDebugEnabled()) {
      getLogger().debug(this + ": Stored session " + session.getId() + " into "
          + getSessionCache().getOperatingRegionName());
    }
    getSessionCache().getStatistics().incSessionsCreated();
  }

  @Override
  public int getRejectedSessions() {
    return rejectedSessions.get();
  }

  public void setRejectedSessions(int rejectedSessions) {
    this.rejectedSessions.set(rejectedSessions);
  }

  /**
   * Returns the number of active sessions
   *
   * @return number of sessions active
   */
  @Override
  public int getActiveSessions() {
    return getSessionCache().size();
  }

  /**
   * For debugging: return a list of all session ids currently active
   */
  @Override
  public String listSessionIds() {
    StringBuilder builder = new StringBuilder();
    Iterator<String> sessionIds = getSessionCache().keySet().iterator();
    while (sessionIds.hasNext()) {
      builder.append(sessionIds.next());
      if (sessionIds.hasNext()) {
        builder.append(" ");
      }
    }
    return builder.toString();
  }

  /*
   * If local caching is enabled, add the session to the set of sessions to be touched. A timer task
   * will be periodically invoked to get the session in the session region to update its last
   * accessed time. This prevents the session from expiring in the case where the application is
   * only getting attributes from the session and never putting attributes into the session. If
   * local caching is disabled. the session's last accessed time would already have been updated
   * properly in the sessions region.
   *
   * Note: Due to issues in GemFire expiry, sessions are always asynchronously touched using a
   * function regardless whether or not local caching is enabled. This prevents premature
   * expiration.
   */
  void addSessionToTouch(String sessionId) {
    sessionsToTouch.add(sessionId);
  }

  protected Set<String> getSessionsToTouch() {
    return sessionsToTouch;
  }

  void removeTouchedSession(String sessionId) {
    sessionsToTouch.remove(sessionId);
  }

  protected void scheduleTimerTasks() {
    // Create the timer
    timer = new Timer("Timer for " + this, true);

    // Schedule the task to handle sessions to be touched
    scheduleTouchSessionsTask();

    // Schedule the task to maintain the maxActive sessions
    scheduleDetermineMaxActiveSessionsTask();
  }

  private void scheduleTouchSessionsTask() {
    TimerTask task = new TimerTask() {
      @Override
      public void run() {
        // Get the sessionIds to touch and clear the set inside synchronization
        Set<String> sessionIds;
        sessionIds = new HashSet<>(getSessionsToTouch());
        getSessionsToTouch().clear();

        // Touch the sessions we currently have
        if (!sessionIds.isEmpty()) {
          getSessionCache().touchSessions(sessionIds);
          if (getLogger().isDebugEnabled()) {
            getLogger().debug(DeltaSessionManager.this + ": Touched sessions: " + sessionIds);
          }
        }
      }
    };
    timer.schedule(task, TIMER_TASK_DELAY, TIMER_TASK_PERIOD);
  }

  protected void cancelTimer() {
    if (timer != null) {
      timer.cancel();
    }
  }

  private void scheduleDetermineMaxActiveSessionsTask() {
    TimerTask task = new TimerTask() {
      @Override
      public void run() {
        int currentActiveSessions = 0;
        try {
          currentActiveSessions = getSessionCache().size();
        } catch (Exception ignore) {
        }
        if (currentActiveSessions > getMaxActive()) {
          setMaxActive(currentActiveSessions);
          if (getLogger().isDebugEnabled()) {
            getLogger().debug(
                DeltaSessionManager.this + ": Set max active sessions: " + currentActiveSessions);
          }
        }
      }
    };

    timer.schedule(task, TIMER_TASK_DELAY, TIMER_TASK_PERIOD);
  }

  @Override
  public void load() throws ClassNotFoundException, IOException {
    ContextMapper.addContext(getContextName(), this);
  }

  @Override
  public void unload() throws IOException {
    clearLocalCache();
    ContextMapper.removeContext(getContextName());
  }

  protected void registerJvmRouteBinderValve() {
    if (getLogger().isDebugEnabled()) {
      getLogger().debug(this + ": Registering JVM route binder valve");
    }
    jvmRouteBinderValve = new JvmRouteBinderValve();
    getPipeline().addValve(jvmRouteBinderValve);
  }

  protected abstract Pipeline getPipeline();

  protected void unregisterJvmRouteBinderValve() {
    if (getLogger().isDebugEnabled()) {
      getLogger().debug(this + ": Unregistering JVM route binder valve");
    }
    if (jvmRouteBinderValve != null) {
      getPipeline().removeValve(jvmRouteBinderValve);
    }
  }

  protected void registerCommitSessionValve() {
    if (getLogger().isDebugEnabled()) {
      getLogger().debug(this + ": Registering CommitSessionValve");
    }
    commitSessionValve = createCommitSessionValve();
    getPipeline().addValve(commitSessionValve);
  }

  protected abstract CommitSessionValveT createCommitSessionValve();

  protected void unregisterCommitSessionValve() {
    if (getLogger().isDebugEnabled()) {
      getLogger().debug(this + ": Unregistering CommitSessionValve");
    }
    if (commitSessionValve != null) {
      getPipeline().removeValve(commitSessionValve);
    }
  }

  // ------------------------------ Lifecycle Methods

  /**
   * Process property change events from our associated Context.
   * <p>
   * Part of this method implementation was taken from StandardManager. The sessionTimeout can be
   * changed in the web.xml which is processed after the context.xml. The context (and the default
   * session timeout) would already have been set in this Manager. This is the way to get the new
   * session timeout value specified in the web.xml.
   * <p>
   * The precedence order for setting the session timeout value is:
   * <ol>
   * <li>the max inactive interval is set based on the Manager defined in the context.xml
   * <li>the max inactive interval is then overwritten by the value of the Context's session timeout
   * when setContainer is called
   * <li>the max inactive interval is then overwritten by the value of the session-timeout specified
   * in the web.xml (if any)
   * </ol>
   *
   * @param event The property change event that has occurred
   */
  @Override
  public void propertyChange(PropertyChangeEvent event) {

    // Validate the source of this event
    if (!(event.getSource() instanceof Context)) {
      return;
    }

    // Process a relevant property change
    if (event.getPropertyName().equals("sessionTimeout")) {
      try {
        int interval = (Integer) event.getNewValue();
        if (interval < RegionConfiguration.DEFAULT_MAX_INACTIVE_INTERVAL) {
          getLogger().warn("The configured session timeout of " + interval
              + " minutes is invalid. Using the original value of " + event.getOldValue()
              + " minutes.");
          interval = (Integer) event.getOldValue();
        }
        // StandardContext.setSessionTimeout passes -1 if the configured timeout
        // is 0; otherwise it passes the value set in web.xml. If the interval
        // parameter equals the default, set the max inactive interval to the
        // default (no expiration); otherwise set it in seconds.
        setMaxInactiveInterval(interval == RegionConfiguration.DEFAULT_MAX_INACTIVE_INTERVAL
            ? RegionConfiguration.DEFAULT_MAX_INACTIVE_INTERVAL : interval * 60);
      } catch (NumberFormatException e) {
        getLogger()
            .error(sm.getString("standardManager.sessionTimeout", event.getNewValue().toString()));
      }
    }
  }

  /**
   * Clear the local cache to avoid ClassCastException if container is being reloaded.
   */
  private void clearLocalCache() {
    final Log logger = getLogger();
    final boolean debugEnabled = logger.isDebugEnabled();

    if (getSessionCache().isClientServer()) {
      if (debugEnabled) {
        logger.debug("Locally clearing sessions.");
      }
      getSessionCache().getOperatingRegion().localClear();
    }

    if (debugEnabled) {
      logger.debug("Unloading complete");
    }
  }

  @Override
  public String toString() {
    return getClass().getSimpleName() + "[" + "container="
        + getTheContext() + "; regionName=" + regionName
        + "; regionAttributesId=" + regionAttributesId + "]";
  }

  String getContextName() {
    return getTheContext().getName();
  }

  public abstract Context getTheContext();

  public abstract int getMaxInactiveInterval();

  public abstract void setMaxInactiveInterval(int interval);
}
