/*
 * 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.felix.http.base.internal.handler;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Set;

import jakarta.servlet.ServletContext;
import jakarta.servlet.http.HttpSession;
import jakarta.servlet.http.HttpSessionBindingEvent;
import jakarta.servlet.http.HttpSessionBindingListener;
import jakarta.servlet.http.HttpSessionEvent;

import org.apache.felix.http.base.internal.HttpConfig;
import org.apache.felix.http.base.internal.context.ExtServletContext;

/**
 * The session wrapper keeps track of the internal session, manages their attributes
 * separately and also handles session timeout.
 */
public class HttpSessionWrapper implements HttpSession
{
    /** All special attributes are prefixed with this prefix. */
    private static final String PREFIX = "org.apache.felix.http.session.context.";

    /** For each internal session, the attributes are prefixed with this followed by the context id */
    private static final String ATTR_PREFIX = PREFIX + "attr.";

    /** The created time for the internal session (appended with context id) */
    private static final String ATTR_CREATED = PREFIX + "created.";

    /** The last accessed time for the internal session (appended with context id), as Epoch time (milliseconds). */
    private static final String ATTR_LAST_ACCESSED = PREFIX + "lastaccessed.";

    /** The max inactive time (appended with context id), in seconds. */
    private static final String ATTR_MAX_INACTIVE = PREFIX + "maxinactive.";

    /** The underlying container session. */
    private final HttpSession delegate;

    /** The corresponding servlet context. */
    private final ExtServletContext context;

    /** The id for this session. */
    private final String sessionId;

    /** The key prefix for attributes belonging to this session. */
    private final String keyPrefix;

    /** Flag to handle the validity of this session. */
    private volatile boolean isInvalid = false;

    /** The time this has been created. */
    private final long created;

    /** The time this has been last accessed. */
    private final long lastAccessed;

    /** The max timeout interval. */
    private int maxTimeout;

    /**
     * Is this a new session?
     */
    private final boolean isNew;

    /**
     * Session handling configuration
     */
    private final HttpConfig config;



    public static boolean hasSession(final String contextName, final HttpSession session)
    {
        return session.getAttribute(ATTR_CREATED.concat(contextName)) != null;
    }

    public static Set<String> getExpiredSessionContextNames(final HttpSession session)
    {
        final long now = System.currentTimeMillis();

        final Set<String> names = new HashSet<>();
        final Enumeration<String> attrNames = session.getAttributeNames();
        while (attrNames.hasMoreElements())
        {
            final String name = attrNames.nextElement();
            if (name.startsWith(ATTR_LAST_ACCESSED))
            {
                final String id = name.substring(ATTR_LAST_ACCESSED.length());

                final long lastAccess = (Long) session.getAttribute(name);
                final long maxTimeout = 1000L * ((Integer) session.getAttribute(ATTR_MAX_INACTIVE + id));

                if ((maxTimeout > 0) && (lastAccess + maxTimeout) < now)
                {
                    names.add(id);
                }
            }
        }
        return names;
    }

    /**
     * Get the names of all contexts using a session.
     * @param session The underlying session
     * @return The set of names
     */
    public static Set<String> getSessionContextNames(final HttpSession session)
    {
        final Set<String> names = new HashSet<>();
        final Enumeration<String> attrNames = session.getAttributeNames();
        while (attrNames.hasMoreElements())
        {
            final String name = attrNames.nextElement();
            if (name.startsWith(ATTR_LAST_ACCESSED))
            {
                final String id = name.substring(ATTR_LAST_ACCESSED.length());
                names.add(id);
            }
        }

        return names;
    }

    /**
     * Creates a new {@link HttpSessionWrapper} instance.
     * @param session The internal session
     * @param context The servlet context
     * @param config The configuration
     * @param terminate Whether to terminate the session
     */
    public HttpSessionWrapper(final HttpSession session,
            final ExtServletContext context,
            final HttpConfig config,
            final boolean terminate)
    {
        this.config = config;
        this.delegate = session;
        this.context = context;
        this.sessionId = context.getServletContextName();
        this.keyPrefix = ATTR_PREFIX.concat(this.sessionId).concat(".");

        final String createdAttrName = ATTR_CREATED.concat(this.sessionId);

        final long now = System.currentTimeMillis();
        if ( session.getAttribute(createdAttrName) == null )
        {
            this.created = now;
            this.maxTimeout = session.getMaxInactiveInterval();
            this.isNew = true;

            session.setAttribute(createdAttrName, this.created);
            session.setAttribute(ATTR_MAX_INACTIVE.concat(this.sessionId), this.maxTimeout);

            context.getHttpSessionListener().sessionCreated(new HttpSessionEvent(this));
        }
        else
        {
            this.created = (Long)session.getAttribute(createdAttrName);
            this.maxTimeout = (Integer)session.getAttribute(ATTR_MAX_INACTIVE.concat(this.sessionId));
            this.isNew = false;
        }

        this.lastAccessed = now;
        if ( !terminate )
        {
            session.setAttribute(ATTR_LAST_ACCESSED.concat(this.sessionId), this.lastAccessed);
        }
    }

    /**
     * Helper method to get the real key within the real session.
     */
    private String getKey(final String name)
    {
        return this.keyPrefix.concat(name);
    }

    /**
     * Check whether this session is still valid.
     * @throws IllegalStateException if session is not valid anymore
     */
    private void checkInvalid()
    {
        if ( this.isInvalid )
        {
            throw new IllegalStateException("Session is invalid.");
        }
    }

    @Override
    public Object getAttribute(final String name)
    {
        this.checkInvalid();
        Object result = this.delegate.getAttribute(this.getKey(name));
        if ( result instanceof SessionBindingValueListenerWrapper )
        {
            result = ((SessionBindingValueListenerWrapper)result).getHttpSessionBindingListener();
        }
        return result;
    }

    @Override
    public Enumeration<String> getAttributeNames()
    {
        this.checkInvalid();
        final Enumeration<String> e = this.delegate.getAttributeNames();
        return new Enumeration<String>() {

            String next = peek();

            private String peek()
            {
                while ( e.hasMoreElements() )
                {
                    final String name = e.nextElement();
                    if ( name.startsWith(keyPrefix))
                    {
                        return name.substring(keyPrefix.length());
                    }
                }
                return null;
            }

            @Override
            public boolean hasMoreElements() {
                return next != null;
            }

            @Override
            public String nextElement() {
                if ( next == null )
                {
                    throw new NoSuchElementException();
                }
                final String result = next;
                next = this.peek();
                return result;
            }
        };

    }

    @Override
    public long getCreationTime()
    {
        this.checkInvalid();
        return this.created;
    }

    @Override
    public String getId()
    {
        // no validity check conforming to the javadocs
        if ( this.config.isUniqueSessionId() )
        {
            return this.delegate.getId().concat("-").concat(this.sessionId);
        }
        return this.delegate.getId();
    }

    @Override
    public long getLastAccessedTime()
    {
        this.checkInvalid();
        return this.lastAccessed;
    }

    @Override
    public int getMaxInactiveInterval()
    {
        // no validity check conforming to the javadocs
        return this.maxTimeout;
    }

    @Override
    public ServletContext getServletContext()
    {
        // no validity check conforming to the javadocs
        return this.context;
    }

    public Object getValue(String name)
    {
        this.checkInvalid();
        return this.getAttribute(name);
    }

    public String[] getValueNames()
    {
        this.checkInvalid();
        final List<String> names = new ArrayList<>();
        final Enumeration<String> e = this.getAttributeNames();
        while ( e.hasMoreElements() )
        {
            names.add(e.nextElement());
        }
        return names.toArray(new String[names.size()]);
    }

    @Override
    public void invalidate()
    {
        this.checkInvalid();

        // session listener must be called before the session is invalidated
        context.getHttpSessionListener().sessionDestroyed(new HttpSessionEvent(this));

        this.delegate.removeAttribute(ATTR_CREATED + this.sessionId);
        this.delegate.removeAttribute(ATTR_LAST_ACCESSED + this.sessionId);
        this.delegate.removeAttribute(ATTR_MAX_INACTIVE + this.sessionId);

        // remove all attributes belonging to this session
        final Enumeration<String> names = this.delegate.getAttributeNames();
        while ( names.hasMoreElements() )
        {
            final String name = names.nextElement();

            if ( name.startsWith(this.keyPrefix) ) {
                this.removeAttribute(name.substring(this.keyPrefix.length()));
            }
        }

        if ( this.config.isInvalidateContainerSession() )
        {
            // if the session is empty we can invalidate
            final Enumeration<String> remainingNames = this.delegate.getAttributeNames();
            if ( (!remainingNames.hasMoreElements()) || (isRemainingAttributeAddedByContainer(remainingNames)))
            {
                this.delegate.invalidate();
            }
        }
        this.isInvalid = true;
    }

    private boolean isRemainingAttributeAddedByContainer(Enumeration<String> names){
        final Set<String> attributeAddedByContainerSet = this.config.getContainerAddedAttribueSet() ;

        if(attributeAddedByContainerSet != null && !attributeAddedByContainerSet.isEmpty()) {

            while (names.hasMoreElements()) {

                final String name = names.nextElement();
                if (name == null || !attributeAddedByContainerSet.contains(name)) {
                    return false;
                }
            }
            return true ;
        }
        return false ;
    }

    @Override
    public boolean isNew()
    {
        this.checkInvalid();
        return this.isNew;
    }

    public void putValue(final String name, final Object value)
    {
        this.checkInvalid();
        this.setAttribute(name, value);
    }

    @Override
    public void removeAttribute(final String name)
    {
        this.checkInvalid();
        final Object oldValue = this.getAttribute(name);
        if ( oldValue != null )
        {
            this.delegate.removeAttribute(this.getKey(name));
            if ( oldValue instanceof HttpSessionBindingListener )
            {
                ((HttpSessionBindingListener)oldValue).valueUnbound(new HttpSessionBindingEvent(this, name));
            }
            if ( this.context.getHttpSessionAttributeListener() != null )
            {
                this.context.getHttpSessionAttributeListener().attributeRemoved(new HttpSessionBindingEvent(this, name, oldValue));
            }
        }
    }

    public void removeValue(final String name)
    {
        this.checkInvalid();
        this.removeAttribute(name);
    }

    @Override
    public void setAttribute(final String name, final Object value)
    {
        this.checkInvalid();
        if ( value == null )
        {
            this.removeAttribute(name);
            return;
        }

        final Object oldValue = this.getAttribute(name);
        // wrap http session binding listener to avoid container calling it!
        if ( value instanceof HttpSessionBindingListener )
        {
            this.delegate.setAttribute(this.getKey(name),
                    new SessionBindingValueListenerWrapper((HttpSessionBindingListener)value));
        }
        else
        {
            this.delegate.setAttribute(this.getKey(name), value);
        }
        if ( value instanceof HttpSessionBindingListener )
        {
            ((HttpSessionBindingListener)value).valueBound(new HttpSessionBindingEvent(this, name));
        }

        if ( this.context.getHttpSessionAttributeListener() != null )
        {
            if ( oldValue != null )
            {
                this.context.getHttpSessionAttributeListener().attributeReplaced(new HttpSessionBindingEvent(this, name, oldValue));
            }
            else
            {
                this.context.getHttpSessionAttributeListener().attributeAdded(new HttpSessionBindingEvent(this, name, value));
            }
        }
    }

    @Override
    public void setMaxInactiveInterval(final int interval)
    {
        // no validity check conforming to the javadocs
        if ( this.delegate.getMaxInactiveInterval() < interval )
        {
            this.delegate.setMaxInactiveInterval(interval);
        }
        this.maxTimeout = interval;
        try {
            this.delegate.setAttribute(ATTR_MAX_INACTIVE + this.sessionId, interval);
        } catch ( final IllegalStateException iae) {
            // this might throw if delegate is invalid
        }
    }

    private static final class SessionBindingValueListenerWrapper implements Serializable
    {

        private static final long serialVersionUID = 4009563108883768425L;

        private final HttpSessionBindingListener listener;

        public SessionBindingValueListenerWrapper(final HttpSessionBindingListener listener)
        {
            this.listener = listener;
        }

        public HttpSessionBindingListener getHttpSessionBindingListener()
        {
            return listener;
        }
    }

    @Override
    public int hashCode()
    {
        return this.getId().concat(this.sessionId).hashCode();
    }

    @Override
    public boolean equals(final Object obj)
    {
        if (this == obj)
        {
            return true;
        }
        if (obj == null || this.getClass() != obj.getClass() )
        {
            return false;
        }
        final HttpSessionWrapper other = (HttpSessionWrapper) obj;
        return other.getId().concat(other.sessionId).equals(this.getId().concat(this.sessionId));
    }
}
