/*
 * 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 static org.apache.geode.util.internal.UncheckedUtils.uncheckedCast;

import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.security.AccessController;
import java.security.Principal;
import java.security.PrivilegedAction;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import jakarta.servlet.http.HttpSession;
import org.apache.catalina.Manager;
import org.apache.catalina.ha.session.SerializablePrincipal;
import org.apache.catalina.realm.GenericPrincipal;
import org.apache.catalina.security.SecurityUtil;
import org.apache.catalina.session.StandardSession;
import org.apache.juli.logging.Log;

import org.apache.geode.DataSerializable;
import org.apache.geode.DataSerializer;
import org.apache.geode.Delta;
import org.apache.geode.InvalidDeltaException;
import org.apache.geode.cache.Region;
import org.apache.geode.internal.size.Sizeable;
import org.apache.geode.internal.util.BlobHelper;
import org.apache.geode.modules.gatewaydelta.GatewayDelta;
import org.apache.geode.modules.gatewaydelta.GatewayDeltaEvent;
import org.apache.geode.modules.session.catalina.internal.DeltaSessionAttributeEvent;
import org.apache.geode.modules.session.catalina.internal.DeltaSessionAttributeEventBatch;
import org.apache.geode.modules.session.catalina.internal.DeltaSessionDestroyAttributeEvent;
import org.apache.geode.modules.session.catalina.internal.DeltaSessionUpdateAttributeEvent;

@SuppressWarnings("serial")
public class DeltaSession extends StandardSession
    implements DataSerializable, Delta, GatewayDelta, Sizeable, DeltaSessionInterface {

  private transient Region<String, DeltaSessionInterface> operatingRegion;

  private String sessionRegionName;

  private String contextName;

  private boolean hasDelta;

  private boolean applyRemotely;

  private boolean enableGatewayDeltaReplication;

  private final transient Object changeLock = new Object();

  private final ArrayList<DeltaSessionAttributeEvent> eventQueue = new ArrayList<>();

  private transient GatewayDeltaEvent currentGatewayDeltaEvent;

  private transient boolean expired = false;

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

  private byte[] serializedPrincipal;

  private static final Field cachedField;

  static {
    try {
      cachedField = StandardSession.class.getDeclaredField("attributes");
      cachedField.setAccessible(true);
    } catch (NoSuchFieldException e) {
      throw new IllegalStateException(e);
    }
  }

  /**
   * Construct a new <code>Session</code> associated with no <code>Manager</code>. The
   * <code>Manager</code> will be assigned later using {@link #setOwner(Object)}.
   */
  public DeltaSession() {
    super(null);
  }

  /**
   * Construct a new Session associated with the specified Manager.
   *
   * @param manager The manager with which this Session is associated
   */
  public DeltaSession(Manager manager) {
    super(manager);
    setOwner(manager);
  }

  /**
   * Return the <code>HttpSession</code> for which this object is the facade.
   */
  @Override
  public HttpSession getSession() {
    if (facade == null) {
      if (isPackageProtectionEnabled()) {
        final DeltaSession fsession = this;
        facade = getNewFacade(fsession);
      } else {
        facade = new DeltaSessionFacade(this);
      }
    }
    return (facade);
  }

  @Override
  public Principal getPrincipal() {
    final DeltaSessionManager<?> deltaSessionManager = getDeltaSessionManager();

    if (principal == null && serializedPrincipal != null) {
      final Log logger = deltaSessionManager.getLogger();

      final SerializablePrincipal sp;
      try {
        sp = (SerializablePrincipal) BlobHelper.deserializeBlob(serializedPrincipal);
      } catch (Exception e) {
        logger.warn(this
            + ": Serialized principal contains a byte[] that cannot be deserialized due to the following exception",
            e);
        return null;
      }

      principal = sp.getPrincipal(deltaSessionManager.getTheContext().getRealm());

      if (logger.isDebugEnabled()) {
        logger.debug(this + ": Deserialized principal: " + principal);
      }
    }

    return principal;
  }

  @Override
  public void setPrincipal(Principal principal) {
    super.setPrincipal(principal);

    // Put the session into the region to serialize the principal
    if (getManager() != null) {
      // TODO convert this to a delta
      getManager().add(this);
      final Log logger = getDeltaSessionManager().getLogger();
      if (logger.isDebugEnabled()) {
        logger.debug(this + ": Cached principal: " + principal);
      }
    }
  }

  private byte[] getSerializedPrincipal() {
    if (serializedPrincipal == null) {
      if (principal != null && principal instanceof GenericPrincipal) {
        GenericPrincipal gp = (GenericPrincipal) principal;
        SerializablePrincipal sp = SerializablePrincipal.createPrincipal(gp);
        serializedPrincipal = serialize(sp);
        if (manager != null) {
          final Log logger = getDeltaSessionManager().getLogger();
          if (logger.isDebugEnabled()) {
            logger.debug(this + ": Serialized principal: " + sp);
          }
        }
      }
    }
    return serializedPrincipal;
  }

  private Region<String, DeltaSessionInterface> getOperatingRegion() {
    // This region shouldn't be null when it is needed.
    // It should have been set by the setOwner method.
    return operatingRegion;
  }

  boolean isCommitEnabled() {
    return getDeltaSessionManager().isCommitValveEnabled();
  }

  @Override
  public GatewayDeltaEvent getCurrentGatewayDeltaEvent() {
    return currentGatewayDeltaEvent;
  }

  @Override
  public void setCurrentGatewayDeltaEvent(GatewayDeltaEvent currentGatewayDeltaEvent) {
    this.currentGatewayDeltaEvent = currentGatewayDeltaEvent;
  }

  @Override
  public void setOwner(Object manager) {
    if (manager instanceof DeltaSessionManager) {
      DeltaSessionManager<?> sessionManager = (DeltaSessionManager<?>) manager;
      this.manager = sessionManager;
      initializeRegion(sessionManager);
      hasDelta = false;
      applyRemotely = false;
      enableGatewayDeltaReplication = sessionManager.getEnableGatewayDeltaReplication();
      setOwnerDeprecated(sessionManager);

      // Initialize transient variables
      if (listeners == null) {
        listeners = new ArrayList<>();
      }

      if (notes == null) {
        notes = new Hashtable<>();
      }

      contextName = sessionManager.getContextName();
    } else {
      throw new IllegalArgumentException(this + ": The Manager must be an AbstractManager");
    }
  }

  @SuppressWarnings("deprecation")
  private void setOwnerDeprecated(DeltaSessionManager<?> sessionManager) {
    preferDeserializedForm = sessionManager.getPreferDeserializedForm();
  }

  private void checkBackingCacheAvailable() {
    if (!((SessionManager) getManager()).isBackingCacheAvailable()) {
      throw new IllegalStateException("No backing cache server is available.");
    }
  }

  @Override
  public void setAttribute(String name, Object value, boolean notify) {

    checkBackingCacheAvailable();

    synchronized (changeLock) {
      // Serialize the value
      final byte[] serializedValue = value == null ? null : serialize(value);

      // Store the attribute locally
      if (preferDeserializedForm) {
        if (notify) {
          deserializeAttributeInternal(name);
        }
        super.setAttribute(name, value, true);
      } else {
        super.setAttribute(name, serializedValue, true);
      }

      // super.setAttribute above performed a removeAttribute for a value which was null once
      // deserialized.
      if (value == null) {
        return;
      }

      // Create the update attribute message
      DeltaSessionAttributeEvent event =
          new DeltaSessionUpdateAttributeEvent(name, serializedValue);
      queueAttributeEvent(event, true);

      // Distribute the update
      if (!isCommitEnabled()) {
        putInRegion(getOperatingRegion(), true, null);
      }
    }
  }

  @Override
  public void removeAttribute(String name, boolean notify) {
    checkBackingCacheAvailable();
    if (expired) {
      return;
    }
    synchronized (changeLock) {
      if (notify && preferDeserializedForm) {
        deserializeAttributeInternal(name);
      }

      // Remove the attribute locally
      super.removeAttribute(name, notify);

      // Create the destroy attribute message
      DeltaSessionAttributeEvent event = new DeltaSessionDestroyAttributeEvent(name);
      queueAttributeEvent(event, true);

      // Distribute the update
      if (!isCommitEnabled()) {
        putInRegion(getOperatingRegion(), true, null);
      }
    }
  }

  @Override
  protected void removeAttributeInternal(String name, boolean notify) {
    if (notify && preferDeserializedForm) {
      deserializeAttributeInternal(name);
    }

    super.removeAttributeInternal(name, notify);
  }

  protected Object getAttributeInternal(final String name) {
    if (null == name) {
      return null;
    }
    return getAttributes().get(name);
  }

  protected void setAttributeInternal(String name, Object value) {
    if (null == name) {
      return;
    }
    getAttributes().put(name, value);
  }

  @Override
  public Object getAttribute(String name) {
    if (name == null) {
      return null;
    }
    checkBackingCacheAvailable();
    Object value = deserializeAttribute(name, super.getAttribute(name), preferDeserializedForm);

    // Touch the session region if necessary. This is an asynchronous operation
    // that prevents the session region from prematurely expiring a session that
    // is only getting attributes.
    getDeltaSessionManager().addSessionToTouch(getId());

    return value;
  }

  protected void deserializeAttributeInternal(final String name) {
    deserializeAttribute(name, getAttributeInternal(name), true);
  }

  private Object deserializeAttribute(final String name, final Object value, final boolean store) {
    // If the attribute is a byte[] (meaning it came from the server),
    // deserialize it and add it to attributes map before returning it.
    if (value instanceof byte[]) {
      try {
        final Object deserialized = BlobHelper.deserializeBlob((byte[]) value);
        if (deserialized == null) {
          removeAttributeInternal(name, false);
          return null;
        }
        if (store) {
          setAttributeInternal(name, deserialized);
        }
        return deserialized;
      } catch (final Exception e) {
        getDeltaSessionManager().getLogger().warn(
            this + ": Attribute named " + name
                + " contains a byte[] that cannot be deserialized due to the following exception",
            e);
      }
    }

    return value;
  }

  private DeltaSessionManager<?> getDeltaSessionManager() {
    return (DeltaSessionManager<?>) getManager();
  }

  Object getAttributeWithoutDeserialize(String name) {
    return super.getAttribute(name);
  }

  @Override
  public void invalidate() {
    super.invalidate();
    getDeltaSessionManager().getStatistics().incSessionsInvalidated();
  }

  @Override
  public void processExpired() {
    DeltaSessionManager<?> manager = getDeltaSessionManager();
    if (manager != null && manager.getLogger() != null && manager.getLogger().isDebugEnabled()) {
      getDeltaSessionManager().getLogger().debug(this + ": Expired");
    }

    // Set expired (so region.destroy is not called again)
    setExpired(true);

    // Do expire processing
    super.expire(true);

    // Update statistics
    if (manager != null) {
      manager.getStatistics().incSessionsExpired();
    }
  }

  @Override
  public void expire(boolean notify) {
    if (notify) {
      getOperatingRegion().destroy(getId(), this);
    } else {
      super.expire(false);
    }
  }

  @Override
  public void setMaxInactiveInterval(int interval) {
    super.setMaxInactiveInterval(interval);

    if (!isCommitEnabled() && id != null) {
      putInRegion(getOperatingRegion(), true, null);
    }
  }

  @Override
  public void localUpdateAttribute(String name, Object value) {
    if (manager == null) {
      // Name cannot be null
      if (name == null) {
        throw new IllegalArgumentException(sm.getString("standardSession.setAttribute.namenull"));
      }

      // Null value is the same as removeAttribute()
      if (value == null) {
        removeAttribute(name);
        return;
      }

      // Validate our current state
      if (!isValidInternal()) {
        throw new IllegalStateException(
            sm.getString("standardSession.setAttribute.ise", getIdInternal()));
      }

      // Replace or add this attribute
      getAttributes().put(name, value);
    } else {
      // don't do notification since this is a replication
      super.setAttribute(name, value, false);
    }
  }

  @Override
  public void localDestroyAttribute(String name) {
    super.removeAttribute(name, false); // don't do notification since this is a replication
  }

  @Override
  public void applyAttributeEvents(Region<String, DeltaSessionInterface> region,
      List<DeltaSessionAttributeEvent> events) {
    for (DeltaSessionAttributeEvent event : events) {
      event.apply(this);
      queueAttributeEvent(event, false);
    }

    putInRegion(region, false, true);
  }

  private void initializeRegion(DeltaSessionManager<?> sessionManager) {
    // Get the session region name
    sessionRegionName = sessionManager.getRegionName();

    // Get the operating region.
    // If a P2P manager is used, then this will be a local region fronting the
    // session region if local cache is enabled; otherwise, it will be the
    // session region itself.
    // If a CS manager is used, it will be the session proxy region.
    operatingRegion = uncheckedCast(sessionManager.getSessionCache().getOperatingRegion());
    if (sessionManager.getLogger().isDebugEnabled()) {
      sessionManager.getLogger().debug(this + ": Set operating region: " + operatingRegion);
    }
  }

  void queueAttributeEvent(DeltaSessionAttributeEvent event,
      boolean checkAddToCurrentGatewayDelta) {
    // Add to current gateway delta if necessary
    if (checkAddToCurrentGatewayDelta) {
      // If the manager has enabled gateway delta replication and is a P2P
      // manager, the GatewayDeltaForwardCacheListener will be invoked in this
      // VM. Add the event to the currentDelta.
      if (enableGatewayDeltaReplication && getDeltaSessionManager().isPeerToPeer()) {
        // If commit is not enabled, add the event to the current batch; else,
        // the current batch will be initialized to the events in the queue will
        // be added at commit time.
        if (!isCommitEnabled()) {
          List<DeltaSessionAttributeEvent> events = new ArrayList<>();
          events.add(event);
          currentGatewayDeltaEvent =
              new DeltaSessionAttributeEventBatch(sessionRegionName, id, events);
        }
      }
    }
    addEventToEventQueue(event);
  }

  private void putInRegion(Region<String, DeltaSessionInterface> region, boolean applyRemotely,
      Object callbackArgument) {
    hasDelta = true;
    this.applyRemotely = applyRemotely;
    region.put(id, this, callbackArgument);
    eventQueue.clear();
  }

  @Override
  public void commit() {
    if (!isValidInternal()) {
      throw new IllegalStateException("commit: Session " + getId() + " already invalidated");
    }
    // (STRING_MANAGER.getString("deltaSession.commit.ise", getId()));

    synchronized (changeLock) {
      // Jens - there used to be a check to only perform this if the queue is
      // empty, but we want this to always run so that the lastAccessedTime
      // will be updated even when no attributes have been changed.
      if (enableGatewayDeltaReplication && getDeltaSessionManager().isPeerToPeer()) {
        setCurrentGatewayDeltaEvent(
            new DeltaSessionAttributeEventBatch(sessionRegionName, id, eventQueue));
      }
      hasDelta = true;
      applyRemotely = true;
      putInRegion(getOperatingRegion(), true, null);
      eventQueue.clear();
    }
  }

  @Override
  public void abort() {
    synchronized (changeLock) {
      eventQueue.clear();
    }
  }

  private void setExpired(boolean expired) {
    this.expired = expired;
  }

  @Override
  public boolean getExpired() {
    return expired;
  }

  @Override
  public String getContextName() {
    return contextName;
  }

  @Override
  public boolean hasDelta() {
    return hasDelta;
  }

  @Override
  public void toDelta(DataOutput out) throws IOException {
    // Write whether to apply the changes to another DS if necessary
    out.writeBoolean(applyRemotely);

    // Write the events
    DataSerializer.writeArrayList(eventQueue, out);

    out.writeLong(lastAccessedTime);
    out.writeInt(maxInactiveInterval);
  }

  @Override
  public void fromDelta(DataInput in) throws IOException, InvalidDeltaException {
    // Read whether to apply the changes to another DS if necessary
    applyRemotely = in.readBoolean();

    // Read the events
    List<DeltaSessionAttributeEvent> events;
    try {
      events = DataSerializer.readArrayList(in);
    } catch (ClassNotFoundException e) {
      throw new InvalidDeltaException(e);
    }

    // This allows for backwards compatibility with 2.1 clients
    if (((InputStream) in).available() > 0) {
      lastAccessedTime = in.readLong();
      maxInactiveInterval = in.readInt();
    }

    // Iterate and apply the events
    for (DeltaSessionAttributeEvent event : events) {
      event.apply(this);
    }

    // Add the events to the gateway delta region if necessary
    if (enableGatewayDeltaReplication && applyRemotely) {
      setCurrentGatewayDeltaEvent(
          new DeltaSessionAttributeEventBatch(sessionRegionName, id, events));
    }

    // Access it to set the last accessed time. End access it to set not new.
    access();
    endAccess();
  }

  @Override
  public void toData(DataOutput out) throws IOException {
    // Write the StandardSession state
    DataSerializer.writeString(id, out);
    out.writeLong(creationTime);
    out.writeLong(lastAccessedTime);
    out.writeLong(thisAccessedTime);
    out.writeInt(maxInactiveInterval);
    out.writeBoolean(isNew);
    out.writeBoolean(isValid);
    DataSerializer.writeObject(getSerializedAttributes(), out);
    DataSerializer.writeByteArray(getSerializedPrincipal(), out);

    // Write the DeltaSession state
    out.writeBoolean(enableGatewayDeltaReplication);
    DataSerializer.writeString(sessionRegionName, out);

    DataSerializer.writeString(contextName, out);
  }

  @Override
  public void fromData(DataInput in) throws IOException, ClassNotFoundException {
    // Read the StandardSession state
    id = DataSerializer.readString(in);
    creationTime = in.readLong();
    lastAccessedTime = in.readLong();
    thisAccessedTime = in.readLong();
    maxInactiveInterval = in.readInt();
    isNew = in.readBoolean();
    isValid = in.readBoolean();
    readInAttributes(in);
    serializedPrincipal = DataSerializer.readByteArray(in);

    // Read the DeltaSession state
    enableGatewayDeltaReplication = in.readBoolean();
    sessionRegionName = DataSerializer.readString(in);

    // This allows for backwards compatibility with 2.1 clients
    if (((InputStream) in).available() > 0) {
      contextName = DataSerializer.readString(in);
    }

    // Initialize the transients if necessary
    if (listeners == null) {
      listeners = new ArrayList<>();
    }

    if (notes == null) {
      notes = new Hashtable<>();
    }
  }

  private void readInAttributes(DataInput in) throws IOException, ClassNotFoundException {
    ConcurrentHashMap<Object, Object> map = DataSerializer.readObject(in);
    try {
      Field field = getAttributesFieldObject();
      field.set(this, map);
    } catch (IllegalAccessException e) {
      logError(e);
      throw new IllegalStateException(e);
    }
  }

  private Field getAttributesFieldObject() {
    return cachedField;
  }

  private void logError(Exception e) {
    final DeltaSessionManager<?> deltaSessionManager = getDeltaSessionManager();
    if (deltaSessionManager != null) {
      deltaSessionManager.getLogger().error(e);
    }
  }

  @Override
  public int getSizeInBytes() {
    int size = 0;
    Enumeration<String> attributeNames = uncheckedCast(getAttributeNames());
    while (attributeNames.hasMoreElements()) {
      // Don't use getAttribute() because we don't want to deserialize the value.
      Object value = getAttributeWithoutDeserialize(attributeNames.nextElement());
      if (value instanceof byte[]) {
        size += ((byte[]) value).length;
      }
    }

    return size;
  }

  private Map<String, byte[]> getSerializedAttributes() {
    // Iterate the values and serialize them if necessary before sending them to the server. This
    // makes the application classes unnecessary on the server.
    Map<String, byte[]> serializedAttributes = new ConcurrentHashMap<>();
    for (Map.Entry<String, Object> entry : getAttributes().entrySet()) {
      Object value = entry.getValue();
      byte[] serializedValue = value instanceof byte[] ? (byte[]) value : serialize(value);
      serializedAttributes.put(entry.getKey(), serializedValue);
    }
    return serializedAttributes;
  }

  protected Map<String, Object> getAttributes() {
    try {
      Field field = getAttributesFieldObject();
      return uncheckedCast(field.get(this));
    } catch (IllegalAccessException e) {
      logError(e);
    }
    throw new IllegalStateException("Unable to access attributes field");
  }

  byte[] serialize(Object obj) {
    byte[] serializedValue = null;
    try {
      serializedValue = serializeViaBlobHelper(obj);
    } catch (IOException e) {
      String builder = this + ": Object " + obj
          + " cannot be serialized due to the following exception";
      getDeltaSessionManager().getLogger().warn(
          builder, e);
    }
    return serializedValue;
  }

  byte[] serializeViaBlobHelper(Object obj) throws IOException {
    return BlobHelper.serializeToBlob(obj);
  }


  @Override
  public String toString() {
    return "DeltaSession[" + "id=" + getId()
        + "; context=" + contextName + "; sessionRegionName="
        + sessionRegionName + "; operatingRegionName="
        + (getOperatingRegion() == null ? "unset" : getOperatingRegion().getFullPath())
        + "]";
  }

  // Helper methods to enable better unit testing
  DeltaSessionFacade getNewFacade(DeltaSessionInterface fSession) {
    return AccessController.doPrivileged(
        (PrivilegedAction<DeltaSessionFacade>) () -> new DeltaSessionFacade(fSession));
  }

  boolean isPackageProtectionEnabled() {
    return SecurityUtil.isPackageProtectionEnabled();
  }

  void addEventToEventQueue(DeltaSessionAttributeEvent event) {
    eventQueue.add(event);
  }
}
