/*
 * JBoss, Home of Professional Open Source.
 * Copyright 2008, Red Hat Middleware LLC, and individual contributors
 * as indicated by the @author tags. See the copyright.txt file in the
 * distribution for a full listing of individual contributors.
 *
 * This is free software; you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation; either version 2.1 of
 * the License, or (at your option) any later version.
 *
 * This software is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */
package org.jboss.ha.hasessionstate.server;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.locks.Lock;
import java.util.zip.Deflater;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.InflaterInputStream;

import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.Name;
import javax.naming.NameNotFoundException;
import javax.naming.NamingException;
import javax.naming.Reference;
import javax.naming.StringRefAddr;

import org.jboss.ha.framework.interfaces.HAPartition;
import org.jboss.ha.hasessionstate.interfaces.HASessionState;
import org.jboss.ha.hasessionstate.interfaces.PackagedSession;
import org.jboss.logging.Logger;
import org.jboss.metadata.ClusterConfigMetaData;
import org.jboss.naming.NonSerializableFactory;

/**
 *   Default implementation of HASessionState
 *
 *   @see org.jboss.ha.hasessionstate.interfaces.HASessionState
 *   @author sacha.labourey@cogito-info.ch
 *   @author <a href="bill@burkecentral.com">Bill Burke</a>
 *   @author Paul Ferraro
 *   @version $Revision: 104443 $
 *
 * <p><b>Revisions:</b><br>
 * <p><b>2002/01/09: billb</b>
 * <ol>
 *   <li>ripped out sub partitioning stuff.  It really belongs as a subclass of HAPartition
 * </ol>
 * 
 */

public class HASessionStateImpl
   implements HASessionState, HAPartition.HAPartitionStateTransfer
{
   private static final long MAX_DELAY_BEFORE_CLEANING_UNRECLAIMED_STATE = 30L * 60L * 1000L; // 30 minutes... should be set externally or use cache settings
   private static final String HA_SESSION_STATE_STATE_TRANSFER = "HASessionStateTransfer";
   
   private static final Class<?>[] SET_OWNERSHIP_TYPES = new Class[] { String.class, Object.class, String.class, Long.class };
   private static final Class<?>[] REMOVE_SESSION_TYPES = new Class[] { String.class, Object.class };
   private static final Class<?>[] SET_STATE_TYPES = new Class[] { String.class, PackagedSession.class };
   
   protected Map<String, ConcurrentMap<Object, PackagedSession>> appSessionMap = new HashMap<String, ConcurrentMap<Object, PackagedSession>>();
   protected ConcurrentMap<String, Set<HASessionStateListener>> appListenerMap = new ConcurrentHashMap<String, Set<HASessionStateListener>>();
      
   long beanCleaningDelay;
   
   private String _sessionStateName;
   private Logger log;
   private HAPartition partition;
   private String sessionStateIdentifier;
   private String myNodeName;
   
   public HASessionStateImpl(String sessionStateName, HAPartition partition, long beanCleaningDelay)
   {
      if (partition == null)
      {
         throw new IllegalArgumentException("HAPartition must not be null when constructing HASessionImpl");
      }
      
      this.partition = partition;
      
      this._sessionStateName = (sessionStateName != null) ? sessionStateName : ClusterConfigMetaData.DEFAULT_SESSION_STATE_NAME;
      
      this.sessionStateIdentifier = "SessionState-'" + this._sessionStateName + "'";

      this.beanCleaningDelay = (beanCleaningDelay > 0) ? beanCleaningDelay : MAX_DELAY_BEFORE_CLEANING_UNRECLAIMED_STATE;
      
      this.log = Logger.getLogger(HASessionStateImpl.class.getName() + "." + this._sessionStateName);
   }
   
   public void init() throws Exception
   {
      this.partition.registerRPCHandler(this.sessionStateIdentifier, this);
      this.partition.subscribeToStateTransferEvents(HA_SESSION_STATE_STATE_TRANSFER, this);
   }
   
   public void start() throws Exception
   {
      this.myNodeName = this.partition.getNodeName();
      this.log.debug("HASessionState node name : " + this.myNodeName );
      
      // BES 4/7/06 clean up lifecycle; move this to start, as it can't be
      // called until startService due to JNDI dependency
      Context ctx = new InitialContext();
      this.bind(this._sessionStateName, this, HASessionStateImpl.class, ctx);
   }
   
   protected void bind(String jndiName, Object who, Class<?> classType, Context ctx) throws Exception
   {
      // Ah ! This service isn't serializable, so we use a helper class
      //
      org.jboss.util.naming.NonSerializableFactory.bind(jndiName, who);
      Name n = ctx.getNameParser("").parse(jndiName);
      while (n.size() > 1)
      {
         String ctxName = n.get(0);
         try
         {
            ctx = (Context) ctx.lookup(ctxName);
         }
         catch (NameNotFoundException e)
         {
            this.log.debug("creating Subcontext " + ctxName);
            ctx = ctx.createSubcontext(ctxName);
         }
         n = n.getSuffix(1);
      }
      
      // The helper class NonSerializableFactory uses address type nns, we go on to
      // use the helper class to bind the service object in JNDI
      //
      StringRefAddr addr = new StringRefAddr("nns", jndiName);
      Reference ref = new Reference(classType.getName(), addr, NonSerializableFactory.class.getName(), null);
      ctx.bind(n.get(0), ref);
   }
   
   public void stop()
   {
      this.purgeState();
      
      // Unbind so we can rebind if restarted
      try
      {
         Context ctx = new InitialContext();
         ctx.unbind(this._sessionStateName);
         org.jboss.util.naming.NonSerializableFactory.unbind(this._sessionStateName);
      }
      catch (NamingException e)
      {
         // Ignore
      }
   }
   
   public void destroy() throws Exception
   {
      // Remove ref to ourself from HAPartition
      this.partition.unregisterRPCHandler(this.sessionStateIdentifier, this);
      this.partition.unsubscribeFromStateTransferEvents(HA_SESSION_STATE_STATE_TRANSFER, this);
   }
   
   public String getNodeName()
   {
      return this.myNodeName;
   }
   
   // Used for Session state transfer
   //
   public Serializable getCurrentState()
   {
      this.log.debug ("Building and returning state of HASessionState");
      
      synchronized (this.appSessionMap)
      {
         this.purgeState();
         
         try
         {
            return this.deflate(this.appSessionMap);
         }
         catch (IOException e)
         {
            this.log.error("operation failed", e);
            return null;
         }
      }
   }
   
   @SuppressWarnings("unchecked")
   public void setCurrentState(Serializable newState)
   {
      this.log.debug("Receiving state of HASessionState");
      
      try
      {
         Map<String, ConcurrentMap<Object, PackagedSession>> map = (Map) this.inflate((byte[]) newState);
         
         synchronized (this.appSessionMap)
         {
            this.appSessionMap.clear();
            this.appSessionMap.putAll(map);
         }
      }
      catch (IOException e)
      {
         this.log.error("operation failed", e);
      }
   }
   
   public void purgeState()
   {
      synchronized (this.appSessionMap)
      {
         long currentTime = System.currentTimeMillis();
         
         for (Map<Object, PackagedSession> map: this.appSessionMap.values())
         {
            for (PackagedSession session: map.values())
            {
               if ((currentTime - session.unmodifiedExistenceInVM()) > this.beanCleaningDelay)
               {
                  map.remove(session.getKey());
               }
            }
         }
      }
   }
   
   protected byte[] deflate(Object object) throws IOException
   {
      ByteArrayOutputStream baos = new ByteArrayOutputStream();
      Deflater def = new Deflater(java.util.zip.Deflater.BEST_COMPRESSION);
      DeflaterOutputStream dos = new DeflaterOutputStream(baos, def);
      
      ObjectOutputStream out = new ObjectOutputStream(dos);
      out.writeObject(object);
      out.close();
      dos.finish();
      dos.close();
      
      return baos.toByteArray();
   }
   
   protected Object inflate(byte[] compressedContent) throws IOException
   {
      if (compressedContent == null) return null;
      
      try
      {
         ObjectInputStream in = new ObjectInputStream(new InflaterInputStream(new ByteArrayInputStream(compressedContent)));
         
         Object object = in.readObject();
         in.close();
         return object;
      }
      catch (Exception e)
      {
         throw new IOException(e.toString());
      }
   }

   protected ConcurrentMap<Object, PackagedSession> getAppMap(String appName)
   {
      synchronized (this.appSessionMap)
      {
         ConcurrentMap<Object, PackagedSession> map = this.appSessionMap.get(appName);
         
         if (map == null)
         {
            map = new ConcurrentHashMap<Object, PackagedSession>();
            
            this.appSessionMap.put(appName, map);
         }
         
         return map;
      }
   }

   public void createSession(String appName, Object keyId)
   {
      this._createSession(appName, keyId);
   }
   
   public PackagedSession _createSession(String appName, Object keyId)
   {
      PackagedSession session = this.createSession(keyId);
      
      this.getAppMap(appName).put(keyId, session);
      
      return session;
   }
   
   private PackagedSession createSession(Object keyId)
   {
      return new PackagedSessionImpl((Serializable) keyId, null, this.myNodeName);
   }
   
   public void setState(String appName, Object keyId, byte[] state)
      throws java.rmi.RemoteException
   {
      PackagedSession session = this.createSession(keyId);
      PackagedSession existing = this.getAppMap(appName).putIfAbsent(keyId, session);
      
      if (existing != null)
      {
         session = existing;
      }
      
      Lock lock = session.getLock();

      if (!lock.tryLock())
      {
         throw new java.rmi.RemoteException("Concurent calls on session object.");
      }
      
      try
      {
         boolean isStateIdentical = session.setState(state);
         
         if (!isStateIdentical)
         {
            Object[] args = { appName, session };
            
            this.partition.callMethodOnCluster(this.sessionStateIdentifier, "_setState", args, SET_STATE_TYPES, true);
         }
      }
      catch (Exception e)
      {
         this.log.error("operation failed", e);
      }
      finally
      {
         lock.unlock();
      }
   }
   
   public void _setState(String appName, PackagedSession session)
   {
      PackagedSession existing = this.getAppMap(appName).putIfAbsent(session.getKey(), session);
      
      if (existing != null)
      {
         Lock lock = existing.getLock();
         
         try
         {
            lock.lockInterruptibly();
         }
         catch (InterruptedException ie)
         {
            Thread.currentThread().interrupt();
            this.log.info(ie);
            return;
         }
         
         try
         {
            if (existing.getOwner().equals(this.myNodeName))
            {
               // a modification has occured externally while we were the owner
               //
               this.ownedObjectExternallyModified(appName, session.getKey(), existing, session);
            }
            
            existing.update(session);
         }
         finally
         {
            lock.unlock();
         }
      }
   }
   
   public PackagedSession getState(String appName, Object keyId)
   {
      return this.getAppMap(appName).get(keyId);
   }
   
   public PackagedSession getStateWithOwnership(String appName, Object keyId) throws java.rmi.RemoteException
   {
      return this.localTakeOwnership(appName, keyId);
   }
   
   public PackagedSession localTakeOwnership(String appName, Object keyId) throws java.rmi.RemoteException
   {
      PackagedSession session = this.getAppMap(appName).get(keyId);
      
      // if the session is not yet available, we simply return null. The persistence manager
      // will have to take an action accordingly
      //
      if (session == null)
      {
         return null;
      }
      
      Lock lock = session.getLock();
      
      if (!lock.tryLock())
      {
         throw new java.rmi.RemoteException("Concurent calls on session object.");
      }
      
      try
      {
         if (!session.getOwner().equals(this.myNodeName))
         {
            Object[] args = { appName, keyId, this.myNodeName, new Long(session.getVersion()) };
            List<?> answers = null;
            try
            {
               answers = this.partition.callMethodOnCluster(this.sessionStateIdentifier, "_setOwnership", args, SET_OWNERSHIP_TYPES, true);
            }
            catch (Exception e)
            {
               this.log.error("operation failed", e);
            }
            
            if ((answers != null) && answers.contains(Boolean.FALSE))
            {
               throw new java.rmi.RemoteException("Concurent calls on session object.");
            }

            session.setOwner(this.myNodeName);
            return session;
         }

         return session;
      }
      finally
      {
         lock.unlock();
      }
   }
   
   public Boolean _setOwnership(String appName, Object keyId, String newOwner, Long remoteVersion)
   {
      PackagedSession session = this.getAppMap(appName).get(keyId);
      
      Lock lock = session.getLock();
      
      if (!lock.tryLock())
      {
         return Boolean.FALSE;
      }

      try
      {
         if (!session.getOwner().equals(this.myNodeName))
         {
            // this is not our business... we don't care
            // we do not update the owner of ps as another host may refuse the _setOwnership call
            // anyway, the update will be sent to us later if state is modified
            //
            return Boolean.TRUE;
         }
         else if (session.getVersion() > remoteVersion.longValue())
         {
            // we are concerned and our version is more recent than the one of the remote host!
            // it means that we have concurrent calls on the same state that has not yet been updated
            // this means we will need to raise a java.rmi.RemoteException
            //
            return Boolean.FALSE;
         }

         // the remote host has the same version as us (or more recent? possible?)
         // we need to update the ownership. We can do this because we know that no other
         // node can refuse the _setOwnership call
         session.setOwner(newOwner);

         this.ownedObjectExternallyModified(appName, keyId, session, session);
         
         return Boolean.TRUE;
      }
      finally
      {
         lock.unlock();
      }
   }
   
   public void takeOwnership(String appName, Object keyId) throws java.rmi.RemoteException
   {
      this.localTakeOwnership(appName, keyId);
   }
   
   public void removeSession(String appName, Object keyId)
   {
      if (this.getAppMap(appName).remove(keyId) != null)
      {
         Object[] args = { appName, keyId };
         
         try
         {
            this.partition.callMethodOnCluster(this.sessionStateIdentifier, "_removeSession", args, REMOVE_SESSION_TYPES, true);
         }
         catch (Exception e)
         {
            this.log.error("operation failed", e);
         }
      }
   }
   
   public void _removeSession(String appName, Object keyId)
   {
      PackagedSession session = this.getAppMap(appName).remove(keyId);
      
      if ((session != null) && session.getOwner().equals(this.myNodeName))
      {
         this.ownedObjectExternallyModified(appName, keyId, session, session);
      }
   }
   
   public void subscribe(String appName, HASessionStateListener listener)
   {
      Set<HASessionStateListener> set = new CopyOnWriteArraySet<HASessionStateListener>();
      
      Set<HASessionStateListener> existing = this.appListenerMap.putIfAbsent(appName, set);
      
      ((existing != null) ? existing : set).add(listener);
   }
   
   public void unsubscribe(String appName, HASessionStateListener listener)
   {
      Set<HASessionStateListener> listeners = this.appListenerMap.get(appName);
      
      if (listeners != null)
      {
         listeners.remove(listener);
      }
   }
   
   public void ownedObjectExternallyModified(String appName, Object key, PackagedSession oldSession, PackagedSession newSession)
   {
      Set<HASessionStateListener> listeners = this.appListenerMap.get(appName);
      
      if (listeners != null)
      {
         for (HASessionStateListener listener: listeners)
         {
            try
            {
               listener.sessionExternallyModified(newSession);
            }
            catch (Throwable e)
            {
               this.log.debug(e);
            }
         }
      }
   }
   
   public HAPartition getCurrentHAPartition()
   {
      return this.partition;
   }
}
