/*
 * JBoss, Home of Professional Open Source
 * Copyright 2005, JBoss Inc., and individual contributors as indicated
 * by the @authors tag. See the copyright.txt 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.web.tomcat.tc5.session;

import java.io.IOException;
import java.util.*;
import javax.management.ObjectName;

import org.apache.catalina.Context;
import org.jboss.cache.CacheException;
import org.jboss.cache.Fqn;
import org.jboss.cache.TreeCache;
import org.jboss.cache.TreeCacheListener;
import org.jboss.cache.TreeCacheMBean;
import org.jboss.cache.lock.TimeoutException;
import org.jboss.invocation.MarshalledValue;
import org.jboss.logging.Logger;
import org.jboss.mx.util.MBeanProxyExt;
import org.jboss.web.tomcat.tc5.Tomcat5;
import org.jboss.util.NestedRuntimeException;
import org.jgroups.View;

/**
 * A wrapper class to JBossCache. This is currently needed to handle various operations such as
 * <ul>
 * <li>Using MarshalledValue to replace Serializable used inside different web app class loader context.</li>
 * <li>Stripping out any id string after ".". This is to handle the JK failover properly with
 * Tomcat JvmRoute.</li>
 * <li>Cache exception retry.</li>
 * <li>Helper APIS.</li>
 * </ul>
 */
public class JBossCacheService implements TreeCacheListener
{
   private TreeCacheMBean proxy_;
   private ObjectName cacheServiceName_;
   protected static Logger log_ = Logger.getLogger(JBossCacheService.class);
   public static final String SESSION = "JSESSION";
   public static final String ATTRIBUTE = "ATTRIBUTE";
   public static final String KEY = "ATRR_KEY";
   private static final int RETRY = 3;
   // Element within an FQN that is the session id
   private static final int SESSION_ID_FQN_INDEX = 3;
   // Needed for cache invalidation
   private static final String VERSION_KEY = "VERSION";
   // Needed to reconstruct the original session id, if JvmRoute is used.
   private String jvmRoute_;
   // name of webapp's virtual host(JBAS-2194). 
   // Idea is host_name + web_app_path + session id is a unique combo.
   private String hostName_;
   // web app path (JBAS-1367 and JBAS-2194). 
   // Idea is host_name + web_app_path + session id is a unique combo.
   private String webAppPath_;
   // List of new session ids that is being replicated but not yet on the in-memory space. Note that it is not synchronzied
   private List newSessionIDList_;
   // Set of fqn's that we're acting on and therefore should ignore listener events
   protected Set eventSet_;

   // Class loader for this web app.
   private ClassLoader tcl_;
   private JBossCacheManager manager_;
   // Root node of the subtree where we store sessions
   private Fqn webAppRoot_;

   public JBossCacheService() throws ClusteringNotSupportedException
   {
      this(Tomcat5.DEFAULT_CACHE_NAME);
   }
   
   public JBossCacheService(String treeCacheObjectName) throws ClusteringNotSupportedException
   {
      // Find JBossCacheService
      try
      {
         cacheServiceName_ = new ObjectName(treeCacheObjectName);
         // Create Proxy-Object for this service
         // TODO We can also consider using getInstance to get directly the reference handle.
         proxy_ = (TreeCacheMBean) MBeanProxyExt.create(TreeCacheMBean.class, cacheServiceName_);
         if (proxy_ == null)
         {
            throw new NestedRuntimeException("JBossCacheService: locate null TomcatCacheMbean");
         }

         newSessionIDList_ = new ArrayList();
         // TODO consider not using a synchronized set
         eventSet_ = Collections.synchronizedSet(new HashSet());
      }
      catch (Throwable e)
      {
         String str = cacheServiceName_ + " service to Tomcat clustering not found";
         log_.error(str);
         throw new ClusteringNotSupportedException(str);
      }
   }

   public void start(ClassLoader tcl, JBossCacheManager manager)
   {
      tcl_ = tcl;
      manager_ = manager;
      jvmRoute_ = null;
      
      Context webapp = (Context) manager_.getContainer();
      String path = webapp.getName();
      if( path.length() == 0 || path.equals("/")) {
         // If this is root.
         webAppPath_ = "ROOT";
      } else if ( path.startsWith("/") ) {
         webAppPath_ = path.substring(1);
      } else {
         webAppPath_ = path;
      }
      log_.debug("Old and new web app path are: " +path + ", " +webAppPath_);
      
      String host = webapp.getParent().getName();
      if( host == null || host.length() == 0) {
         hostName_ = "localhost";
      }else {
         hostName_ = host;
   }
      log_.debug("Old and new virtual host name are: " + host + ", " + hostName_);

      Object[] objs = new Object[]{SESSION, hostName_, webAppPath_};
      webAppRoot_ = new Fqn( objs );
      
      proxy_.addTreeCacheListener(this);
   }

   public void stop()
   {
      proxy_.removeTreeCacheListener(this);
   }

   /**
    * Find session ids for the whole Manager instance. Note that this also depends on the web app
    * path setting.
    *
    * @return Empty list if not found.
    */
   public List findSessionIDs()
   {
      List ids = new ArrayList();
      try {
         // Construct the fqn
         Object[] objs = new Object[]{SESSION, hostName_, webAppPath_};
         Fqn path = new Fqn( objs );
         // locate children under each web app path
         Set names = proxy_.getChildrenNames(path);

         if( names == null ) return ids;
         for(Iterator it = names.iterator(); it.hasNext();) {
            Object id = it.next();
            if(id==null) continue;
            ids.add(id);
            if(log_.isTraceEnabled()) {
               log_.trace("Retrieving through web app path with fqn: " +path + " and session id: " +id);
            }
         }
      } catch (CacheException e) {
         log_.error("Exception occurred in cache getChildrenNames", e);
         throw new NestedRuntimeException("JBossCacheService: exception occurred in cache getChildrenNames ... ", e);
      }
      return ids;
   }

   public Object getSession(String id)
   {
      String realId = stripJvmRoute(id);
      Fqn fqn = getSessionFqn(realId);
      return getUnMarshalledValue(_get(fqn, realId));
   }

   public void putSession(String id, Object session)
   {
      String realId = stripJvmRoute(id);
      Fqn fqn = getSessionFqn(realId);
// TODO Need to investigate why this causes deadlock??
//      Map map = new HashMap();
//      map.put(realId, getMarshalledValue(session));
      // Put in (VERSION_KEY, version) after the real put for cache invalidation
//      map.put(VERSION_KEY, new Integer(((ClusteredSession)session).getVersion()));
//      _put(fqn, map);
      // This is not very efficient now since it generates two nodeAdded event.
      _put(fqn, realId, getMarshalledValue(session));
      // Put in (VERSION_KEY, version) after the real put for cache invalidation
      _put(fqn, VERSION_KEY, new Integer(((ClusteredSession)session).getVersion()));
   }

   public Object removeSession(String id)
   {
      String realId = stripJvmRoute(id);
      Fqn fqn = getSessionFqn(realId);
      if (log_.isDebugEnabled())
      {
         log_.debug("Remove session from distributed store. Fqn: " + fqn);
      }
      Object obj = getUnMarshalledValue(_remove(fqn, realId));
      // This needs to go after object removal to support correct cache invalidation.
//      _remove(fqn, VERSION_KEY);
      // Will just remove the whole tree now (include the fqn).
      _remove(fqn);
      return obj;
   }

   public void removeSessionLocal(String id)
   {
      String realId = stripJvmRoute(id);
      Fqn fqn = getSessionFqn(realId);
      if (log_.isDebugEnabled())
      {
         log_.debug("Remove session from my own distributed store only. Fqn: " + fqn);
      }
      _evict(fqn);
   }

   public boolean exists(String id)
   {
      String realId = stripJvmRoute(id);
      Fqn fqn = getSessionFqn(realId);
      return proxy_.exists(fqn);
   }

   public Object getAttribute(String id, String key)
   {
      String realId = stripJvmRoute(id);
      Fqn fqn = getAttributeFqn(realId);
      return getUnMarshalledValue(_get(fqn, key));
   }

   public Object putAttribute(String id, String key, Object value)
   {
      String realId = stripJvmRoute(id);
      Fqn fqn = getAttributeFqn(realId);
      return _put(fqn, key, getMarshalledValue(value));
   }

   public void putAttribute(String id, Map map)
   {
      String realId = stripJvmRoute(id);
      Fqn fqn = getAttributeFqn(realId);
      Set set = map.keySet();
      Iterator it = set.iterator();
      while (it.hasNext())
      {
         String key = (String) it.next();
         _put(fqn, key, getMarshalledValue(map.get(key)));
      }
   }

   public void removeAttributes(String id)
   {
      String realId = stripJvmRoute(id);
      Fqn fqn = getAttributeFqn(realId);
      _remove(fqn);
   }

   public Object removeAttribute(String id, String key)
   {
      String realId = stripJvmRoute(id);
      Fqn fqn = getAttributeFqn(realId);
      if (log_.isDebugEnabled())
      {
         log_.debug("Remove attribute from distributed store. Fqn: " + fqn + " key: " + key);
      }
      return getUnMarshalledValue(_remove(fqn, key));
   }

   public void removeAttributeLocal(String id)
   {
      String realId = stripJvmRoute(id);
      Fqn fqn = getAttributeFqn(realId);
      if (log_.isDebugEnabled())
      {
         log_.debug("Remove attributes from my own distributed store only. Fqn: " + fqn);
      }
      _evict(fqn);
   }

   /**
    * Obtain the keys associated with this fqn. Note that it is not the fqn children.
    *
    * @return
    */
   public Set getAttributeKeys(String id)
   {
      if (id == null || id.length() == 0)
         throw new IllegalArgumentException("JBossCacheService: id is either null or empty");

      String realId = stripJvmRoute(id);
      Fqn fqn = getAttributeFqn(realId);
      try
      {
         return proxy_.getKeys(fqn);
      }
      catch (CacheException e)
      {
         log_.error("Exception getting attribute keys for session " + realId, e);
      }
      return null;
   }

   /**
    * Return all attributes associated with this session id. Return empty map if not found.
    *
    * @param id
    * @return
    */
   public Map getAttributes(String id)
   {
      if (id == null || id.length() == 0) return new HashMap();
      Set set = getAttributeKeys(id);
      String realId = stripJvmRoute(id);
      Fqn fqn = getAttributeFqn(realId);
      Map map = new HashMap();
      if(set == null) return map;
      for (Iterator it = set.iterator(); it.hasNext();)
      {
         String key = (String) it.next();
         Object value = getAttribute(id, key);
         map.put(key, value);
      }
      return map;
   }

   /**
    * Retrieve the new sessions in the underlying cache. Return size 0 if not found.
    *
    */
   public List getNewSessionsInStore()
   {
      List list = new ArrayList();
      synchronized(newSessionIDList_)
      {
         if(newSessionIDList_.size() != 0)
         {
            list.addAll(newSessionIDList_);
            newSessionIDList_.clear();
         }
      }
      return list;
   }

   /**
    * Wrapper to embed retyr logic.
    *
    * @param fqn
    * @param id
    * @return
    */
   protected Object _get(Fqn fqn, String id)
   {
      Exception ex = null;
      for (int i = 0; i < RETRY; i++)
      {
         try
         {
            return proxy_.get(fqn, id);
         }
         catch (TimeoutException e)
         {
            if (log_.isDebugEnabled()) 
            {
               log_.debug("Caught TimeoutException getting key " + id +
                          " from fqn " + fqn + "; retry " + RETRY, e);
            }
            ex = e;
         }
         catch (Exception e)
         {
            log_.error("Exception occurred getting key " + id +
                          " from fqn " + fqn, e);
            throw new NestedRuntimeException("JBossCacheService: exception occurred in cache get ... ", e);
         }
      }
      throw new NestedRuntimeException("JBossCacheService: exception occurred in cache get after retry ... ", ex);
   }

   /**
    * Wrapper to embed retry logic.
    *
    * @param fqn
    * @param id
    * @param value
    * @return
    */
   protected Object _put(Fqn fqn, String id, Object value)
   {
      Exception ex = null;
      for (int i = 0; i < RETRY; i++)
      {
         try
         {
            return proxy_.put(fqn, id, value);
         }
         catch (TimeoutException e)
         {
            if (log_.isDebugEnabled()) 
            {
               log_.debug("Caught TimeoutException putting key " + id +
                          " into node " + fqn + "; retry " + RETRY, e);
            }
            ex = e;
         }
         catch (Exception e)
         {
            log_.error("Exception occurred putting key " + id +
                          " into node " + fqn, e);
            throw new NestedRuntimeException("JBossCacheService: exception occurred in cache put ... ", e);
         }
      }
      throw new NestedRuntimeException("JBossCacheService: exception occurred in cache put after retry ... ", ex);
   }


   /**
    * Wrapper to embed retry logic.
    *
    * @param fqn
    * @param map
    */
   protected void _put(Fqn fqn, Map map)
   {
      Exception ex = null;
      for (int i = 0; i < RETRY; i++)
      {
         try
         {
            proxy_.put(fqn, map);
            return;
         }
         catch (TimeoutException e)
         {
            if (log_.isDebugEnabled()) 
            {
               log_.debug("Caught TimeoutException putting map " +
                          "into node " + fqn + "; retry " + RETRY, e);
            }
            ex = e;
         }
         catch (Exception e)
         {
            log_.error("Exception occurred putting map " +
                          "into node " + fqn, e);
            throw new NestedRuntimeException("JBossCacheService: exception occurred in cache put ... ", e);
         }
      }
      throw new NestedRuntimeException("JBossCacheService: exception occurred in cache put after retry ... ", ex);
   }

   /**
    * Wrapper to embed retyr logic.
    *
    * @param fqn
    * @param id
    * @return
    */
   protected Object _remove(Fqn fqn, String id)
   {
      Exception ex = null;
      for (int i = 0; i < RETRY; i++)
      {
         try
         {
            return proxy_.remove(fqn, id);
         }
         catch (TimeoutException e)
         {
            if (log_.isDebugEnabled()) 
            {
               log_.debug("Caught TimeoutException removing key " + id +
                          " from fqn " + fqn + "; retry " + RETRY, e);
            }
            ex = e;
         }
         catch (Exception e)
         {
            log_.error("Exception occurred removing key " + id +
                       " from fqn " + fqn, e);
            throw new NestedRuntimeException("JBossCacheService: exception occurred in cache remove ... ", e);
         }
      }
      throw new NestedRuntimeException("JBossCacheService: exception occurred in cache remove after retry ... ", ex);
   }

   /**
    * Wrapper to embed retry logic.
    *
    * @param fqn
    */
   protected void _remove(Fqn fqn)
   {
      Exception ex = null;
      for (int i = 0; i < RETRY; i++)
      {
         try
         {
            proxy_.remove(fqn);
            return;
         }
         catch (TimeoutException e)
         {
            if (log_.isDebugEnabled()) 
            {
               log_.debug("Caught TimeoutException removing node " + fqn +
                          "; retry " + RETRY, e);
            }
            ex = e;
         }
         catch (Exception e)
         {
            log_.error("Exception occurred removing node " + fqn, e);
            throw new NestedRuntimeException("JBossCacheService: exception occurred in cache remove ... ", e);
         }
      }
      throw new NestedRuntimeException("JBossCacheService: exception occurred in cache remove after retry ... ", ex);
   }

   /**
    * Wrapper to embed retyr logic.
    *
    * @param fqn
    */
   protected void _evict(Fqn fqn)
   {
      Exception ex = null;
      for (int i = 0; i < RETRY; i++)
      {
         try
         {
            proxy_.evict(fqn);
            return;
         }
         catch (TimeoutException e)
         {
            if (log_.isDebugEnabled()) 
            {
               log_.debug("Caught TimeoutException evicting node " + fqn +
                          "; retry " + RETRY, e);
            }
            ex = e;
         }
         catch (Exception e)
         {
            log_.error("Exception occurred evicting node " + fqn, e);
            throw new NestedRuntimeException("JBossCacheService: exception occurred in cache evict ... ", e);
         }
      }
      throw new NestedRuntimeException("JBossCacheService: exception occurred in cache evict after retry ... ", ex);
   }


   /**
    * Since we store the base id (i.e., without the JvmRoute) internally while the real session id
    * has the postfix in there if mod_jk is used, we will need to strip it to get the real key. Now this
    * is an aspect.
    *
    * @param id
    * @return
    */
   private String stripJvmRoute(String id)
   {
      // TODO Need optimization later since not every request needs to do this.
      int index = id.indexOf(".");
      if (index > 0)
      {
         if(jvmRoute_ == null)
            jvmRoute_ = id.substring(index);

         return id.substring(0, index);
      }
      else
      {
         return id;
      }
   }

   private Fqn getSessionFqn(String id)
   {
      // /SESSION/hostName/webAppPath/id
      Object[] objs = new Object[]{SESSION, hostName_, webAppPath_, id};
      return new Fqn(objs);
   }

   private Fqn getAttributeFqn(String id)
   {
      // /SESSION/hostName/webAppPath/id/ATTR
      Object[] objs = new Object[]{SESSION, hostName_, webAppPath_, id, ATTRIBUTE};
      return new Fqn(objs);
   }

   private Object getMarshalledValue(Object value)
   {
      try
      {
         return new MarshalledValue(value);
      }
      catch (IOException e)
      {
         log_.error("Exception occurred creating MarshalledValue from " + 
                     value, e);
         return null;
      }
   }

   private Object getUnMarshalledValue(Object mv)
   {
      if (mv == null) return null;
      // Swap in/out the tcl for this web app. Needed only for un marshalling.
      ClassLoader prevTCL = Thread.currentThread().getContextClassLoader();
      Thread.currentThread().setContextClassLoader(tcl_);
      try
      {
         return ((MarshalledValue) mv).get();
      }
      catch (IOException e)
      {
         log_.error("IOException occurred unmarshalling value ", e);
         return null;
      }
      catch (ClassNotFoundException e)
      {
         log_.error("ClassNotFoundException occurred unmarshalling value ", e);
         return null;
      }
      finally
      {
         Thread.currentThread().setContextClassLoader(prevTCL);
      }
   }

   // --------------- TreeCacheListener methods ------------------------------------

   public void nodeCreated(Fqn fqn)
   {
      // No-op
   }

   public void nodeRemoved(Fqn fqn)
   {
      nodeDirty(fqn);
   }

   /**
    * Called when a node is loaded into memory via the CacheLoader. This is not the same
    * as {@link #nodeCreated(Fqn)}.
    */
   public void nodeLoaded(Fqn fqn)
   {
      // No-op
   }

   public void nodeModified(Fqn fqn)
   {
      nodeDirty(fqn);
   }

   protected void nodeDirty(Fqn fqn)
   {
      if(!needToHandle(fqn))
         return;
      
      // Query if we have version value. If we do, we compare the version. Invalidate if necessary.
      Integer version = (Integer)_get(fqn, VERSION_KEY);
      if(version != null)
      {
         String realId = getIdFromFqn(fqn);

         /*
         String id;
         // Need to reconstruct the session id because it could contain jvm route.
         if(jvmRoute_ == null)
         {
            id = realId;
         } else
         {
            id = realId + "." +jvmRoute_;
         }
         */

         ClusteredSession session = manager_.findLocalSession(realId);
         // if session is null, that means the data is fresh. No need to do anything then.
         if( session != null )
         {
            if( session.isNewData(version.intValue()))
            {
               // Need to invalidate
               session.setIsOutdated(true);
               if(log_.isDebugEnabled())
               {
                  log_.debug("nodeDirty(): session in-memory data is invalidated with id: " +realId
                  + " and verion id: " +version.intValue());
               }
            }
         } else
         {
            // fresh data. We will need to populate it to the local in-memory copy.
//            if(log_.isDebugEnabled())
//            {
//               log_.debug("nodeDirty(): session id recovered -- " +realId);
//            }

            synchronized(newSessionIDList_)
            {
               if(!newSessionIDList_.contains(realId))
                  newSessionIDList_.add(realId);
            }
         }
      }
   }

   /**
    * Begin ignoring cache notifications for Fqn's involving the given session.
    * Marks the given session as being about to locally generate events on the cache 
    * for which we should ignore listener notifications. This is needed so we don't 
    * mark the session outdated again. This feature is actually best provided by JBossCache; 
    * that is, we should not generate any event if I am the guy that is doing the update. 
    * But it doesn't now.
    * Note that currently we can't handle multiple fqn events successively. There may have a race
    * condition. But this scenario is unlikely here since we usually update one session object one
    * at a time.
    * 
    * @param sessionID the "real" id of the session (stripped of any jvmRoute)
    */
   public void ignoreEvents(String sessionID)
   {
      eventSet_.add(sessionID);
   }
   
   /**
    * Resume handle notifications issued for the given session.
    * 
    * @param sessionID the "real" id of the session (stripped of any jvmRoute)
    */
   public void handleEvents(String sessionID)
   {
      eventSet_.remove(sessionID);
   }

   /**
    * If this event is emitted by myself or is not for the web app
    * I'm managing, then we can skip.
    * @param fqn
    * @return
    */
   protected boolean needToHandle(Fqn fqn)
   {
      // Do the isChildOf test first to avoid unneeded
      // contention on the synchronized eventSet_
      return (fqn.isChildOf(webAppRoot_)
              && eventSet_.contains(getIdFromFqn(fqn)) == false);
   }
   
   protected String getIdFromFqn(Fqn fqn)
   {
      //return (String)fqn.get(fqn.size() -1);
      return (String)fqn.get(SESSION_ID_FQN_INDEX);
   }

   public void nodeVisited(Fqn fqn)
   {
      // no-op
   }

   public void cacheStarted(TreeCache cache)
   {
      // TODO will need to synchronize this with local sessions
   }

   public void cacheStopped(TreeCache cache)
   {
      // TODO will need to synchronize this with local sessions
   }

   public void viewChange(View new_view)
   {
      // We don't care for this event.
   }

   public void nodeEvicted(Fqn fqn)
   {
      // We don't care for this event.
   }

}
