/**
 * This file is part of the Meeds project (https://meeds.io/).
 *
 * Copyright (C) 2020 - 2025 Meeds Association contact@meeds.io
 *
 * This program 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 3 of the License, or (at your option) any later version.
 * This program 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 program; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 */
package org.exoplatform.services.cache.invalidation;

import org.exoplatform.services.cache.CacheListener;
import org.exoplatform.services.cache.CacheListenerContext;
import org.exoplatform.services.cache.CachedObjectSelector;
import org.exoplatform.services.cache.ExoCache;
import org.exoplatform.services.cache.ObjectCacheInfo;
import org.exoplatform.services.log.ExoLogger;
import org.exoplatform.services.log.Log;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CopyOnWriteArrayList;

/**
 * This eXo cache type is a decorator allowing ExoCache instances that have
 * big values or non serializable values to be replicated thanks to an invalidation
 * mechanism. To prevent infinite loop described below, we replicate the hash code of
 * the value such that if the hash code is the same, we don't invalidate the value locally
 * <ul>
 * <li>Cluster node #1 puts (key1, value1) into the cache</li>
 * <li>On cluster node #2 key1 is invalidated by the put call in node #1</li>
 * <li>Node #2 re-loads key1 and puts (key1, value1) into the cache</li>
 * <li>On cluster node #1 key1 is invalidated, so we get back to step #1</li>
 * </ul>
 * 
 */
public class InvalidationExoCache<K extends Serializable, V> implements ExoCache<K, V>,
   CacheListener<K, HashCode<V>>
{
   /**
    * Logger.
    */
   private static final Log LOG = ExoLogger.getLogger("exo.kernel.component.cache.InvalidationExoCache");

   /**
    * The eXo cache instance that we would like to replicate using the invalidation
    * mechanism
    */
   private final ExoCache<K, HashCode<V>> delegate;
   
   /**
    * The listeners of the cache
    */
   private final CopyOnWriteArrayList<CacheListener<? super K, ? super V>> listeners;
   
   /**
    * The local cache that contains the real values
    */
   private final ConcurrentMap<K, V> localCache;
   
   /**
    * @param delegate the underneath eXo cache instance, we assume that the eXo cache
    * implementation behind is fully functional.
    */
   public InvalidationExoCache(ExoCache<K, V> delegate)
   {
      this(delegate, delegate.getMaxSize() > 0 && delegate.getMaxSize() < 512 ? delegate.getMaxSize() : 512);
   }
   
   /**
    * @param delegate the underneath eXo cache instance, we assume that the eXo cache
    * implementation behind is fully functional.
    * @param concurrencyLevel the estimated number of concurrently
    * updating threads. The implementation performs internal sizing
    * to try to accommodate this many threads.  
    */
   @SuppressWarnings("unchecked")
   public InvalidationExoCache(ExoCache<K, V> delegate, int concurrencyLevel)
   {
      this.delegate = (ExoCache<K, HashCode<V>>)delegate;
      // We listen to the cache in order to get a callbacks in case of internal puts for example
      this.delegate.addCacheListener(this);
      this.listeners = new CopyOnWriteArrayList<CacheListener<? super K, ? super V>>();
      this.localCache = new ConcurrentHashMap<K, V>(concurrencyLevel, 0.75f, concurrencyLevel);      
   }
   
   /**
    * @see org.exoplatform.services.cache.ExoCache#getName()
    */
   public String getName()
   {
      return delegate.getName();
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#setName(java.lang.String)
    */
   public void setName(String name)
   {
      delegate.setName(name);
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#getLabel()
    */
   public String getLabel()
   {
      return delegate.getLabel();
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#setLabel(java.lang.String)
    */
   public void setLabel(String s)
   {
      delegate.setLabel(s);
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#get(java.io.Serializable)
    */
   public V get(Serializable name)
   {
      HashCode<V> result = delegate.get(name);
      return result == null ? null : localCache.get(name);
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#remove(java.io.Serializable)
    */
   public V remove(Serializable key) throws NullPointerException
   {
      V value = localCache.get(key);
      delegate.remove(key);
      return value;
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#remove(java.io.Serializable)
    */
   public void removeLocal(Serializable key) throws NullPointerException
   {
      localCache.get(key);
      delegate.removeLocal(key);
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#put(java.io.Serializable, java.lang.Object)
    */
   public void put(K key, V value) throws NullPointerException
   {
      HashCode hashCode = new HashCode<V>(value);
      LOG.debug("Cache: {} PUT KEY: {}, hash: {} ", getName(), key, hashCode.hashCode());
      delegate.put(key, hashCode);
   }

   @Override
   public void putLocal(K key, V value) throws NullPointerException
   {
      HashCode hashCode = new HashCode<V>(value);
      LOG.debug("Cache: {} PUT LOCAL KEY: {}, hash: {} ", getName(), key, hashCode.hashCode());
      delegate.putLocal(key, hashCode);
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#putMap(java.util.Map)
    */
   public void putMap(Map<? extends K, ? extends V> objs) throws IllegalArgumentException
   {
      if (objs == null)
      {
         throw new IllegalArgumentException("No null map accepted");
      }
      Map<K, HashCode<V>> map = new LinkedHashMap<K, HashCode<V>>();
      for (Entry<? extends K, ? extends V> entry : objs.entrySet())
      {
         if (entry.getKey() == null)
         {
            throw new IllegalArgumentException("No null cache key accepted");
         }
         else if (entry.getValue() == null)
         {
            throw new IllegalArgumentException("No null cache value accepted");
         }
         map.put(entry.getKey(), new HashCode<V>(entry.getValue()));
      }
      delegate.putMap(map);
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#clearCache()
    */
   public void clearCache()
   {
      delegate.clearCache();
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#select(org.exoplatform.services.cache.CachedObjectSelector)
    */
   public void select(CachedObjectSelector<? super K, ? super V> selector) throws Exception
   {
      if (selector == null)
      {
         throw new IllegalArgumentException("No null selector");
      }
      for (Entry<K, V> entry : localCache.entrySet())
      {
         final K key = entry.getKey();
         final V value = entry.getValue();
         ObjectCacheInfo<V> info = new ObjectCacheInfo<V>()
         {
            public V get()
            {
               return value;
            }

            public long getExpireTime()
            {
               // Cannot know: The expire time is managed by eXo Cache itself
               return -1;
            }
         };
         if (selector.select(key, info))
         {
            selector.onSelect(this, key, info);
         }
      }
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#getCacheSize()
    */
   public int getCacheSize()
   {
      return localCache.size();
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#getMaxSize()
    */
   public int getMaxSize()
   {
      return delegate.getMaxSize();
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#setMaxSize(int)
    */
   public void setMaxSize(int max)
   {
      delegate.setMaxSize(max);
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#getLiveTime()
    */
   public long getLiveTime()
   {
      return delegate.getLiveTime();
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#setLiveTime(long)
    */
   public void setLiveTime(long period)
   {
      delegate.setLiveTime(period);
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#getCacheHit()
    */
   public int getCacheHit()
   {
      return delegate.getCacheHit();
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#getCacheMiss()
    */
   public int getCacheMiss()
   {
      return delegate.getCacheMiss();
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#getCachedObjects()
    */
   public List<? extends V> getCachedObjects()
   {
      return new ArrayList<V>(localCache.values());
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#removeCachedObjects()
    */
   public List<? extends V> removeCachedObjects()
   {
      final List<? extends V> list = getCachedObjects();
      clearCache();
      return list;
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#addCacheListener(org.exoplatform.services.cache.CacheListener)
    */
   public void addCacheListener(CacheListener<? super K, ? super V> listener) throws IllegalArgumentException
   {
      if (listener == null)
      {
         throw new IllegalArgumentException("The listener cannot be null");
      }
      listeners.add(listener);
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#isLogEnabled()
    */
   public boolean isLogEnabled()
   {
      return delegate.isLogEnabled();
   }

   /**
    * @see org.exoplatform.services.cache.ExoCache#setLogEnabled(boolean)
    */
   public void setLogEnabled(boolean b)
   {
      delegate.setLogEnabled(b);
   }

   /**
    * {@inheritDoc}
    */
   public void onExpire(CacheListenerContext context, K key, HashCode<V> obj) throws Exception
   {
      V value = localCache.remove(key);
      if (listeners.isEmpty())
      {
         return;
      }
      for (CacheListener<? super K, ? super V> listener : listeners)
      {
         try
         {
            listener.onExpire(context, key, value);
         }
         catch (Exception e)
         {
            if (LOG.isWarnEnabled())
               LOG.warn("Cannot execute the CacheListener properly", e);
         }
      }
   }

   /**
    * {@inheritDoc}
    */
   public void onRemove(CacheListenerContext context, K key, HashCode<V> obj) throws Exception
   {
      V value = localCache.remove(key);
      if (listeners.isEmpty())
      {
         return;
      }
      for (CacheListener<? super K, ? super V> listener : listeners)
      {
         try
         {
            listener.onRemove(context, key, value);
         }
         catch (Exception e)
         {
            if (LOG.isWarnEnabled())
               LOG.warn("Cannot execute the CacheListener properly", e);
         }
      }
   }

   /**
    * {@inheritDoc}
    */
   public void onPut(CacheListenerContext context, K key, HashCode<V> obj) throws Exception
   {
      V value = obj == null ? null : obj.getValue();
      if (value != null)
      {
         // we assume that it is a local put since the value is inside the HashCode object
         localCache.put(key, value);
      }
      else
      {
         // we assume that it is a remote put since the value is not inside the HashCode object
         V currentValue = localCache.get(key);
         if (currentValue != null && obj != null && currentValue.hashCode() == obj.hashCode())
         {
            LOG.debug("Cache: {} ON PUT equal KEY: {}, hash: {} ", getName(), key, obj.hashCode());

            // We assume that it is the same value so we don't change the value in the cache
            value = currentValue;
         }
         else
         {
            LOG.debug("Cache: {} ON PUT not equal KEY: {}, hash: {} ", getName(), key, obj.hashCode());
            // A new value has been added to the cache so we invalidate the local one
            value = null;
            localCache.remove(key);
         }
      }
      if (listeners.isEmpty())
      {
         return;
      }
      for (CacheListener<? super K, ? super V> listener : listeners)
         try
         {
            listener.onPut(context, key, value);
         }
         catch (Exception e)
         {
            if (LOG.isWarnEnabled())
               LOG.warn("Cannot execute the CacheListener properly", e);
         }
   }

   /**
    * {@inheritDoc}
    */
   public void onGet(CacheListenerContext context, K key, HashCode<V> obj) throws Exception
   {
      if (listeners.isEmpty())
      {
         return;
      }
      V value = obj == null ? null : localCache.get(key);
      for (CacheListener<? super K, ? super V> listener : listeners)
         try
         {
            listener.onGet(context, key, value);
         }
         catch (Exception e)
         {
            if (LOG.isWarnEnabled())
               LOG.warn("Cannot execute the CacheListener properly", e);
         }
   }

   /**
    * @see org.exoplatform.services.cache.CacheListener#onClearCache(org.exoplatform.services.cache.CacheListenerContext)
    */
   public void onClearCache(CacheListenerContext context) throws Exception
   {
      localCache.clear();
      if (listeners.isEmpty())
      {
         return;
      }
      for (CacheListener<? super K, ? super V> listener : listeners)
      {
         try
         {
            listener.onClearCache(context);
         }
         catch (Exception e)
         {
            if (LOG.isWarnEnabled())
               LOG.warn("Cannot execute the CacheListener properly", e);
         }
      }
   }
}
