//
//  ========================================================================
//  Copyright (c) 1995-2019 Mort Bay Consulting Pty. Ltd.
//  ------------------------------------------------------------------------
//  All rights reserved. This program and the accompanying materials
//  are made available under the terms of the Eclipse Public License v1.0
//  and Apache License v2.0 which accompanies this distribution.
//
//      The Eclipse Public License is available at
//      http://www.eclipse.org/legal/epl-v10.html
//
//      The Apache License v2.0 is available at
//      http://www.opensource.org/licenses/apache2.0.php
//
//  You may elect to redistribute this code under either of these licenses.
//  ========================================================================
//


package org.eclipse.jetty.server.session;

import java.util.Collections;
import java.util.HashSet;
import java.util.Set;

import javax.servlet.http.HttpServletRequest;

import org.eclipse.jetty.util.StringUtil;
import org.eclipse.jetty.util.annotation.ManagedAttribute;
import org.eclipse.jetty.util.annotation.ManagedObject;
import org.eclipse.jetty.util.component.ContainerLifeCycle;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
import org.eclipse.jetty.util.thread.Locker.Lock;

/**
 * AbstractSessionCache
 *
 * A base implementation of the {@link SessionCache} interface for managing a set of
 * Session objects pertaining to a context in memory.
 * 
 * This implementation ensures that multiple requests for the same session id
 * always return the same Session object.
 * 
 * It will delay writing out a session to the SessionDataStore until the
 * last request exits the session. If the SessionDataStore supports passivation
 * then the session passivation and activation listeners are called appropriately as
 * the session is written. 
 * 
 * This implementation also supports evicting idle Session objects. An idle Session
 * is one that is still valid, has not expired, but has not been accessed by a
 * request for a configurable amount of time.  An idle session will be first
 * passivated before it is evicted from the cache.
 * 
 */
@ManagedObject
public abstract class AbstractSessionCache extends ContainerLifeCycle implements SessionCache
{
    final static Logger LOG = Log.getLogger("org.eclipse.jetty.server.session");
    
    /**
     * The authoritative source of session data
     */
    protected SessionDataStore _sessionDataStore;
    
    /**
     * The SessionHandler related to this SessionCache
     */
    protected final SessionHandler _handler;
    
    /**
     * Information about the context to which this SessionCache pertains
     */
    protected SessionContext _context;
    
    
    /**
     * When, if ever, to evict sessions: never; only when the last request for them finishes; after inactivity time (expressed as secs)
     */
    protected int _evictionPolicy = SessionCache.NEVER_EVICT; 
    
    /**
     * If true, as soon as a new session is created, it will be persisted to the SessionDataStore
     */
    protected boolean _saveOnCreate = false;
    
    
    /**
     * If true, a session that will be evicted from the cache because it has been
     * inactive too long will be saved before being evicted.
     */
    protected boolean _saveOnInactiveEviction;
    
    
    /**
     * If true, a Session whose data cannot be read will be
     * deleted from the SessionDataStore.
     */
    protected boolean _removeUnloadableSessions;
    
 

    /**
     * Create a new Session object from pre-existing session data
     * @param data the session data
     * @return a new Session object
     */
    @Override
    public abstract Session newSession (SessionData data);
    
    
    /**
     * Create a new Session for a request.
     * 
     * @param request the request
     * @param data the session data
     * @return the new session
     */
    public abstract Session newSession (HttpServletRequest request, SessionData data);
    
    
    /**
     * Get the session matching the key
     * @param id session id
     * @return the Session object matching the id
     */
    public abstract Session doGet(String id);
    
    
    
    /**
     * Put the session into the map if it wasn't already there
     * 
     * @param id the identity of the session
     * @param session the session object
     * @return null if the session wasn't already in the map, or the existing entry otherwise
     */
    public abstract Session doPutIfAbsent (String id, Session session);
    
    
    /**
     * Replace the mapping from id to oldValue with newValue
     * @param id the id
     * @param oldValue the old value
     * @param newValue the new value
     * @return true if replacement was done
     */
    public abstract boolean doReplace (String id, Session oldValue, Session newValue);
    

    
    /**
     * Remove the session with this identity from the store
     * 
     * @param id the id
     * @return Session that was removed or null
     */
    public abstract Session doDelete (String id);

    
    
    
    /**
     * PlaceHolder
     */
    protected class PlaceHolderSession extends Session
    {

        /**
         * @param handler SessionHandler to which this session belongs
         * @param data the session data
         */
        public PlaceHolderSession(SessionHandler handler, SessionData data)
        {
            super(handler, data);
        }
    }
    
    
    
    /**
     * @param handler the {@link SessionHandler} to use
     */
    public AbstractSessionCache (SessionHandler handler)
    {
        _handler = handler;
    }
    
    
 
    
    /**
     * @return the SessionManger
     */
    @Override
    public SessionHandler getSessionHandler()
    {
        return _handler;
    }
    

    /** 
     * @see org.eclipse.jetty.server.session.SessionCache#initialize(org.eclipse.jetty.server.session.SessionContext)
     */
    @Override
    public void initialize (SessionContext context)
    {
        if (isStarted())
            throw new IllegalStateException("Context set after session store started");
        _context = context;
    }
    
    /** 
     * @see org.eclipse.jetty.util.component.AbstractLifeCycle#doStart()
     */
    @Override
    protected void doStart() throws Exception
    {
        if (_sessionDataStore == null)
            throw new IllegalStateException ("No session data store configured");
        
        if (_handler == null)
            throw new IllegalStateException ("No session manager");
        
        if (_context == null)
            throw new IllegalStateException ("No ContextId");

        _sessionDataStore.initialize(_context);      
        super.doStart();
    }

    /** 
     * @see org.eclipse.jetty.util.component.AbstractLifeCycle#doStop()
     */
    @Override
    protected void doStop() throws Exception
    {
        _sessionDataStore.stop();
        super.doStop();
    }

    /**
     * @return the SessionDataStore or null if there isn't one
     */
    @Override
    public SessionDataStore getSessionDataStore()
    {
        return _sessionDataStore;
    }

    /** 
     * @see org.eclipse.jetty.server.session.SessionCache#setSessionDataStore(org.eclipse.jetty.server.session.SessionDataStore)
     */
    @Override
    public void setSessionDataStore(SessionDataStore sessionStore)
    {
        updateBean(_sessionDataStore, sessionStore);
        _sessionDataStore = sessionStore;
    }
    





    /** 
     * @see org.eclipse.jetty.server.session.SessionCache#getEvictionPolicy()
     */
    @ManagedAttribute(value="session eviction policy", readonly=true)
    @Override
    public int getEvictionPolicy()
    {
        return _evictionPolicy;
    }


    /** 
     * -1 means we never evict inactive sessions.
     * 0 means we evict a session after the last request for it exits
     * &gt;0 is the number of seconds after which we evict inactive sessions from the cache
     * 
     * @see org.eclipse.jetty.server.session.SessionCache#setEvictionPolicy(int)
     */
    @Override
    public void setEvictionPolicy(int evictionTimeout)
    {
        _evictionPolicy = evictionTimeout;
    }

    @ManagedAttribute(value="immediately save new sessions", readonly=true)
    @Override
    public boolean isSaveOnCreate()
    {
        return _saveOnCreate;
    }


    @Override
    public void setSaveOnCreate(boolean saveOnCreate)
    {
        _saveOnCreate = saveOnCreate;
    }


    /**
     * @return true if sessions that can't be loaded are deleted from the store
     */
    @ManagedAttribute(value="delete unreadable stored sessions", readonly=true)
    @Override
    public boolean isRemoveUnloadableSessions()
    {
        return _removeUnloadableSessions;
    }


    /**
     * If a session's data cannot be loaded from the store without error, remove
     * it from the persistent store.
     * 
     * @param removeUnloadableSessions if <code>true</code> unloadable sessions will be removed from session store
     */
    @Override
    public void setRemoveUnloadableSessions(boolean removeUnloadableSessions)
    {
        _removeUnloadableSessions = removeUnloadableSessions;
    }


    /** 
     *  Get a session object.
     * 
     * If the session object is not in this session store, try getting
     * the data for it from a SessionDataStore associated with the 
     * session manager.
     * 
     * @see org.eclipse.jetty.server.session.SessionCache#get(java.lang.String)
     */
    @Override
    public Session get(String id) throws Exception
    {
        Session session = null;
        Exception ex = null;
        
        while (true)
        {
            session = doGet(id);
            
            if (_sessionDataStore == null)
                break; //can't load any session data so just return null or the session object
            
            if (session == null)
            {
                if (LOG.isDebugEnabled())
                    LOG.debug("Session {} not found locally, attempting to load", id);
       
                //didn't get a session, try and create one and put in a placeholder for it
                PlaceHolderSession phs = new PlaceHolderSession (_handler, new SessionData(id, null, null,0,0,0,0));
                Lock phsLock = phs.lock();
                Session s = doPutIfAbsent(id, phs);
                if (s == null)
                {
                    //My placeholder won, go ahead and load the full session data
                    try
                    {
                        session = loadSession(id);
                        if (session == null)
                        {
                            //session does not exist, remove the placeholder
                            doDelete(id);
                            phsLock.close();
                            break;
                        }
                        
                        try (Lock lock = session.lock())
                        {
                            //swap it in instead of the placeholder
                            boolean success = doReplace(id, phs, session);
                            if (!success)
                            {
                                //something has gone wrong, it should have been our placeholder
                                doDelete(id);
                                session = null;
                                LOG.warn("Replacement of placeholder for session {} failed", id);
                                phsLock.close();
                                break;
                            }
                            else
                            {
                                //successfully swapped in the session
                                session.setResident(true);
                                phsLock.close();
                                break;
                            }
                        }
                    }
                    catch (Exception e)
                    {
                        ex = e; //remember a problem happened loading the session
                        doDelete(id); //remove the placeholder
                        phsLock.close();
                        session = null;
                        break;
                    }
                }
                else
                {
                    //my placeholder didn't win, check the session returned
                    phsLock.close();
                    try (Lock lock = s.lock())
                    {
                        //is it a placeholder? or is a non-resident session? In both cases, chuck it away and start again
                        if (!s.isResident() || s instanceof PlaceHolderSession)
                        {
                            session = null;
                            continue;
                        }
                        session = s;
                        break;
                    }
                }
                
            }
            else
            {
                //check the session returned
                try (Lock lock = session.lock())
                {                    
                    //is it a placeholder? or is it passivated? In both cases, chuck it away and start again
                    if (!session.isResident()|| session instanceof PlaceHolderSession)
                    {
                        session = null;
                        continue;
                    }

                    //got the session
                    break;
                }
            }
        }

        if (ex != null)
            throw ex;
        return session;
    }

    /**
     * Load the info for the session from the session data store
     * 
     * @param id the id
     * @return a Session object filled with data or null if the session doesn't exist
     * @throws Exception
     */
    private Session loadSession (String id)
    throws Exception
    {
        SessionData data = null;
        Session session = null;

        if (_sessionDataStore == null)
            return null; //can't load it

        try
        {
            data =_sessionDataStore.load(id);

            if (data == null) //session doesn't exist
                return null;
            
            data.setLastNode(_context.getWorkerName());//we are going to manage the node
            session = newSession(data);
            return session;
        }
        catch (UnreadableSessionDataException e)
        {
            //can't load the session, delete it
            if (isRemoveUnloadableSessions())
                _sessionDataStore.delete(id);
            throw e;
        }
    }

    /** 
     * Put the Session object back into the session store. 
     * 
     * This should be called when a request exists the session. Only when the last
     * simultaneous request exists the session will any action be taken.
     * 
     * If there is a SessionDataStore write the  session data through to it.
     * 
     * If the SessionDataStore supports passivation, call the passivate/active listeners.
     * 
     * If the evictionPolicy == SessionCache.EVICT_ON_SESSION_EXIT then after we have saved
     * the session, we evict it from the cache.
     * 
     * @see org.eclipse.jetty.server.session.SessionCache#put(java.lang.String, org.eclipse.jetty.server.session.Session)
     */
    @Override
    public void put(String id, Session session) throws Exception
    {
        if (id == null || session == null)
            throw new IllegalArgumentException ("Put key="+id+" session="+(session==null?"null":session.getId()));
       
        try (Lock lock = session.lock())
        {   
            if (session.getSessionHandler() == null)
                throw new IllegalStateException("Session "+id+" is not managed");
            
            if (!session.isValid())
                return; 

            //don't do anything with the session until the last request for it has finished
            if ((session.getRequests() <= 0))
            {    
                //save the session
                if (!_sessionDataStore.isPassivating())
                {
                    //if our backing datastore isn't the passivating kind, just save the session
                    _sessionDataStore.store(id, session.getSessionData());
                    //if we evict on session exit, boot it from the cache
                    if (getEvictionPolicy() == EVICT_ON_SESSION_EXIT)
                    {
                        if (LOG.isDebugEnabled()) LOG.debug("Eviction on request exit id={}", id);
                        doDelete(session.getId());
                        session.setResident(false);
                    }
                    else
                    {
                        session.setResident(true);
                        doPutIfAbsent(id,session); //ensure it is in our map 
                        if (LOG.isDebugEnabled())LOG.debug("Non passivating SessionDataStore, session in SessionCache only id={}",id);
                    }
                }
                else
                {
                    //backing store supports passivation, call the listeners
                    session.willPassivate();
                    if (LOG.isDebugEnabled()) LOG.debug("Session passivating id={}", id);
                    _sessionDataStore.store(id, session.getSessionData());
               
                    if (getEvictionPolicy() == EVICT_ON_SESSION_EXIT)
                    {
                        //throw out the passivated session object from the map
                        doDelete(id);
                        session.setResident(false);
                        if (LOG.isDebugEnabled()) LOG.debug("Evicted on request exit id={}", id);
                    }
                    else
                    {
                        //reactivate the session
                        session.didActivate();    
                        session.setResident(true);
                        doPutIfAbsent(id,session);//ensure it is in our map
                        if (LOG.isDebugEnabled())LOG.debug("Session reactivated id={}",id);
                    }
                }
            }
            else
            {
                if (LOG.isDebugEnabled()) LOG.debug("Req count={} for id={}",session.getRequests(),id);
                session.setResident(true);
                doPutIfAbsent(id, session); //ensure it is the map, but don't save it to the backing store until the last request exists
            }
        }
    }

    /** 
     * Check to see if a session corresponding to the id exists.
     * 
     * This method will first check with the object store. If it
     * doesn't exist in the object store (might be passivated etc),
     * it will check with the data store.
     * @throws Exception the Exception
     * 
     * @see org.eclipse.jetty.server.session.SessionCache#exists(java.lang.String)
     */
    @Override
    public boolean exists(String id) throws Exception
    {
        //try the object store first
        Session s = doGet(id);
        if (s != null)
        {
            try (Lock lock = s.lock())
            {
                //wait for the lock and check the validity of the session
                return s.isValid();
            }
        }
        
        //not there, so find out if session data exists for it
        return _sessionDataStore.exists (id);
    }
    
    /** 
     * Check to see if this cache contains an entry for the session
     * corresponding to the session id.
     * 
     * @see org.eclipse.jetty.server.session.SessionCache#contains(java.lang.String)
     */
    @Override
    public boolean contains (String id) throws Exception
    {
        //just ask our object cache, not the store
        return (doGet(id) != null);
    }


    /** 
     * Remove a session object from this store and from any backing store.
     * 
     * 
     * @see org.eclipse.jetty.server.session.SessionCache#delete(java.lang.String)
     */
    @Override
    public Session delete(String id) throws Exception
    {
        //get the session, if its not in memory, this will load it
        Session session = get(id); 
 
        //Always delete it from the backing data store
        if (_sessionDataStore != null)
        {
            boolean dsdel = _sessionDataStore.delete(id);
            if (LOG.isDebugEnabled()) LOG.debug("Session {} deleted in session data store {}",id, dsdel);                   
        }
        
        //delete it from the session object store
        if (session != null)
        {
            session.setResident(false);
        }
        
        return doDelete(id);
    }


    /** 
     * @see org.eclipse.jetty.server.session.SessionCache#checkExpiration(Set)
     */
    @Override
    public Set<String> checkExpiration(Set<String> candidates)
    {
       if (!isStarted())
           return Collections.emptySet();
       
       if (LOG.isDebugEnabled())
           LOG.debug("{} checking expiration on {}", this, candidates);
       Set<String> allCandidates = _sessionDataStore.getExpired(candidates);
       Set<String> sessionsInUse = new HashSet<>();
       if (allCandidates != null)
       {
           for (String c:allCandidates)
           {
               Session s = doGet(c);
               if (s != null && s.getRequests() > 0) //if the session is in my cache, check its not in use first
                   sessionsInUse.add(c);
           }
           try
           {
               allCandidates.removeAll(sessionsInUse);
           }
           catch (UnsupportedOperationException e)
           {
               Set<String> tmp = new HashSet<>(allCandidates);
               tmp.removeAll(sessionsInUse);
               allCandidates = tmp;
           }
       }
       return allCandidates;
    }

    
    
    /**
     * Check a session for being inactive and
     * thus being able to be evicted, if eviction
     * is enabled.
     * 
     * 
     * @param session session to check
     */
    @Override
    public void checkInactiveSession (Session session)
    {
        if (session == null)
            return;

      if (LOG.isDebugEnabled()) LOG.debug("Checking for idle {}", session.getId());
        try (Lock s = session.lock())
        {
            if (getEvictionPolicy() > 0 && session.isIdleLongerThan(getEvictionPolicy()) && session.isValid() && session.isResident() && session.getRequests() <= 0)
            {       
                //Be careful with saveOnInactiveEviction - you may be able to re-animate a session that was
                //being managed on another node and has expired.
                try
                {
                    if (LOG.isDebugEnabled())
                        LOG.debug("Evicting idle session {}", session.getId());

                    //save before evicting
                    if (isSaveOnInactiveEviction() && _sessionDataStore != null)
                    {
                        if (_sessionDataStore.isPassivating())
                            session.willPassivate();

                        _sessionDataStore.store(session.getId(), session.getSessionData());
                    }
               
                    doDelete(session.getId()); //detach from this cache
                    session.setResident(false);
                }
                catch (Exception e)
                {
                    LOG.warn("Passivation of idle session {} failed", session.getId(), e);
                    //session.updateInactivityTimer();
                }
            }
        }
    }
    
 

    

    @Override
    public Session renewSessionId (String oldId, String newId, String oldExtendedId, String newExtendedId)
    throws Exception
    {
        if (StringUtil.isBlank(oldId))
            throw new IllegalArgumentException ("Old session id is null");
        if (StringUtil.isBlank(newId))
            throw new IllegalArgumentException ("New session id is null");

        Session session = get(oldId);
        renewSessionId(session, newId, newExtendedId);

        return session;
    }
    
    
    /**
     * Swap the id on a session.
     * 
     * @param session the session for which to do the swap
     * @param newId the new id
     * @param newExtendedId the full id plus node id
     * 
     * @throws Exception if there was a failure saving the change
     */
    protected void renewSessionId (Session session, String newId, String newExtendedId)
    throws Exception
    {
        if (session == null)
            return;
        
        try (Lock lock = session.lock())
        {
            String oldId = session.getId();
            session.checkValidForWrite(); //can't change id on invalid session
            session.getSessionData().setId(newId);
            session.getSessionData().setLastSaved(0); //pretend that the session has never been saved before to get a full save
            session.getSessionData().setDirty(true);  //ensure we will try to write the session out    
            session.setExtendedId(newExtendedId); //remember the new extended id
            session.setIdChanged(true); //session id changed
            
            doPutIfAbsent(newId, session); //put the new id into our map
            doDelete (oldId); //take old out of map
            
            if (_sessionDataStore != null)
            {
                _sessionDataStore.delete(oldId);  //delete the session data with the old id
                _sessionDataStore.store(newId, session.getSessionData()); //save the session data with the new id
            }
            if (LOG.isDebugEnabled())
                LOG.debug ("Session id {} swapped for new id {}", oldId, newId);
        }
    }
    
    
    /** 
     * @see org.eclipse.jetty.server.session.SessionCache#setSaveOnInactiveEviction(boolean)
     */
    @Override
    public void setSaveOnInactiveEviction (boolean saveOnEvict)
    {
        _saveOnInactiveEviction = saveOnEvict;
    }
    
    
    /**
     * Whether we should save a session that has been inactive before
     * we boot it from the cache.
     * 
     * @return true if an inactive session will be saved before being evicted
     */
    @ManagedAttribute(value="save sessions before evicting from cache", readonly=true)
    @Override
    public boolean isSaveOnInactiveEviction ()
    {
        return _saveOnInactiveEviction;
    }


    /** 
     * @see org.eclipse.jetty.server.session.SessionCache#newSession(javax.servlet.http.HttpServletRequest, java.lang.String, long, long)
     */
    @Override
    public Session newSession(HttpServletRequest request, String id, long time, long maxInactiveMs)
    {
        if (LOG.isDebugEnabled()) LOG.debug("Creating new session id="+id);
        Session session = newSession(request, _sessionDataStore.newSessionData(id, time, time, time, maxInactiveMs));
        session.getSessionData().setLastNode(_context.getWorkerName());
        try
        {
            if (isSaveOnCreate() && _sessionDataStore != null)
                _sessionDataStore.store(id, session.getSessionData());
        }
        catch (Exception e)
        {
            LOG.warn("Save of new session {} failed", id, e);
        }
        return session;
    }


    @Override
    public String toString()
    {
        return String.format("%s@%x[evict=%d,removeUnloadable=%b,saveOnCreate=%b,saveOnInactiveEvict=%b]",
                             this.getClass().getName(),this.hashCode(),_evictionPolicy,_removeUnloadableSessions,_saveOnCreate,_saveOnInactiveEviction);
    }
}
