/*
 * Copyright (C) 2001-2004 Red Hat Inc. All Rights Reserved.
 *
 * The contents of this file are subject to the CCM Public
 * License (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.redhat.com/licenses/ccmpl.html.
 *
 * Software distributed under the License is distributed on an
 * "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, either express
 * or implied. See the License for the specific language
 * governing rights and limitations under the License.
 *
 */
package com.arsdigita.caching;

import com.arsdigita.util.SystemProperties;
import com.arsdigita.util.StringUtils;
import com.arsdigita.util.parameter.IntegerParameter;
import com.arsdigita.util.parameter.Parameter;
import com.arsdigita.kernel.SiteNode;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.io.PrintWriter;
import java.io.StringWriter;

import org.apache.log4j.Logger;

/**
 * <p> Implements simple caching, where storage is limited in both size and age.
 * In case of overflow, the least recently used item is evicted.  There can be
 * any number of <code>CacheTable</code> instances throughout the system,
 * however each must have a unique tag.  This tag is later used by {@link
 * CacheServlet} to synchronize cache between multiple web servers. </p>
 *
 *
 * @author Artak Avetyan
 * @author Matthew Booth
 * @author Sebastian Skracic
 *
 * @version $Revision: #22 $ $DateTime: 2004/05/10 07:13:45 $
 */
public class CacheTable {

    private static final Logger s_log = Logger.getLogger(CacheTable.class);

    private static final int MIN_CACHE_SIZE      =           20;
    private static final int MAX_CACHE_SIZE      =      1000000;
    private static final int MIN_CACHE_AGE       =            5;
    private static final int MAX_CACHE_AGE       =  60*60*24*30;
    private static final int DEFAULT_CACHE_SIZE  =         1000;
    private static final int DEFAULT_CACHE_AGE   =          300;

    private static Map s_caches = new Hashtable();

    private String m_cacheID;
    private DynamicList m_list;

    /** For debugging only. */
    public static final Browser BROWSER = new BrowserImpl(s_caches);
    
    /**
     * <p>Creates cache storage tagged with the passed in identificator.  This
     * tag must be unique in the sense that no other cache table loaded by this
     * <code>CacheTable</code>'s {@link java.lang.ClassLoader class loader} may
     * have the same id.</p>
     *
     * <p>One of the purposes of the id is to serve as a unique identifier for
     * specifying the configurable parameters of this cache table in system
     * configuration files. </p>
     *
     * <p>The configurable parameters are <code>waf.caching.[id].max_size</code>
     * and <code>waf.caching.[id].max_age</code>. </p>
     *
     * @param id Unique identifier for the new storage area
     * @pre id != null
     * @throws NullPointerException if <code>id</code> is <code>null</code>
     */
    public CacheTable(final String id) {
        this(id, DEFAULT_CACHE_SIZE, DEFAULT_CACHE_AGE);
    }

    /**
     * <p>Creates cache storage tagged with the passed in identificator.  This
     * tag must be unique in the sense that no other cache table loaded by this
     * <code>CacheTable</code>'s {@link java.lang.ClassLoader class loader} may
     * have the same id.</p>
     *
     * <p>One of the purposes of the id is to serve as a unique identifier for
     * specifying the configurable parameters of this cache table in system
     * configuration files. </p>
     *
     * <p>The configurable parameters are <code>waf.caching.[id].max_size</code>
     * and <code>waf.caching.[id].max_age</code>. </p>
     *
     * @param id Unique identifier for the new storage area
     * @param size Initial default size
     * @param age Initial default age
     * @pre id != null
     * @throws NullPointerException if <code>id</code> is <code>null</code>
     */
    public CacheTable(final String id, int defSize, int defAge) {
        if ( id == null ) { throw new NullPointerException("id"); }

        m_cacheID = id;

        final Parameter sizeParam = new IntegerParameter
            ("waf.util.caching." + id + ".size",
             Parameter.REQUIRED,
             new Integer(defSize));

        int size = ((Integer) SystemProperties.get(sizeParam)).intValue();
        if (size < MIN_CACHE_SIZE  ||  size > MAX_CACHE_SIZE) {
            s_log.warn("Cache size " + size + " was outside allowed range " + 
                       MIN_CACHE_SIZE + "-" + MAX_CACHE_SIZE);
            size = DEFAULT_CACHE_SIZE;
        }

        final Parameter ageParam = new IntegerParameter
            ("waf.util.caching." + id + ".age",
             Parameter.REQUIRED,
             new Integer(defAge));

        int age = ((Integer) SystemProperties.get(ageParam)).intValue();

        m_list = new DynamicList(m_cacheID, size, saneMaxAge(age));

        register(m_cacheID, this);
    }

    private static void register(String id, CacheTable cache) {
        if ( s_caches.containsKey(id) ) {
            throw new IllegalArgumentException
                ("There already exists a CacheTable with the \"id\" of " +
                 id + ": " + s_caches.get(id));
        }
        s_caches.put( id, cache );
    }

    static CacheTable getCache( String id ) {
        return (CacheTable) s_caches.get( id );
    }

    /**
     *  <p> Returns the maximum cache item age (in seconds). </p>
     */
    public int getMaxAge() {
        return m_list.getMaxAge();
    }

    /**
     *  <p> Sets the maximum cache item age (in seconds). </p>
     *
     * @param age desired maximum cache size in seconds
     */
    public synchronized void setMaxAge(int age) {
        m_list.setMaxAge(saneMaxAge(age));
    }

    private int saneMaxAge(int age) {
        if (age < MIN_CACHE_AGE  ||  age > MAX_CACHE_AGE) {
            s_log.warn("Cache age " + age + " was outside allowed range " + 
                       MIN_CACHE_AGE + "-" + MAX_CACHE_AGE);
            return DEFAULT_CACHE_AGE;
        } else {
            return age;
        }
    }

    /**
     *  Returns the actual number of items in cache.
     */
    public long getCurrentSize() {
        return m_list.size();
    }

    int getMaxSize() {
        return m_list.getMaxSize();
    }

    private void removeLRUEntry() {
        m_list.removeLRUEntry();
    }


    /**
     * A convenience wrapper around {@link #put(String, Object)}.
     *
     * @param key BigDecimal serving as a key for cache lookup
     *
     * @param value Object we're storing in cache
     */
    public synchronized void put(BigDecimal key, Object value) {
        put(key.toString(), value);
    }

    /**
     * <p> Puts object in a cache storage.  Object must be identified with a
     * unique key. </p>
     *
     * <p><em>Implementation note</em>: Any <code>put</code> operation on the
     * <code>CacheTable</code> invokes the {@link #remove(String)} method which
     * will invalidate that cache entry (if exists) on other nodes.  Had the
     * implementation failed to do so, the following scenario might have caused
     * cache incoherence.</p>
     *
     * <ul>
     *   <li>A cache table entry <code>("key1", value1)</code> exists on nodes X
     *   and Y.</li>
     *
     *   <li>Immediately after <code>"key1"</code> is evicted on X, it is
     *   reinserted into the same cache table with a different value, so that
     *   the entry on X is now <code>("key1", value2)</code>.</li>
     *
     *   <li>Nodes X and Y now have different values mapped to the
     *     same key. </li>
     * </ul>
     *
     * <p>To prevent this situation from happening, any insertion must first be
     * preceded by a removal of the affected entry from all peer nodes.</p>
     *
     * <p>As a performance optimization, we try to be clever and prevent valid
     * objects from being flushed out unnecessarily from peer nodes in the above
     * scenario.  This is accomplished by including in the "remove" message the
     * hash code of the object that may need removal from peer nodes.  If the
     * hash code sent by node X matches the hash code of the object mapped to
     * the same key on node Y, then the cache entry maintained by Y need not be
     * updated.</p>
     *
     * <p>Note that for this method to work, any cached object <em>O</em> must
     * hash to the same value regardless of the node on which the hash code is
     * computed.  The contract of {@link java.lang.Object#hashCode()} method
     * makes no such guarantees.  It is unreasonable to expect that hash codes
     * of the "same object" (for some reasonable definition of "sameness") are
     * always identical across different JVMs.  Even if you are running the same
     * JVM version on each of the participating nodes, hash codes of the "same
     * object" are not guaranteed to be identical (although in practice, they
     * often are). As a trivial illustration, consider the following
     * example.</p>
     *
     * <pre>
     * public final class QuuxEnum {
     *     public final static QuuxEnum QUUX1 = new QuuxEnum();
     *     public final static QuuxEnum QUUX2 = new QuuxEnum();
     * }
     * </pre>
     *
     * <p>The hash code of <code>QuuxEnum.QUUX1</code> is virtually guaranteed
     * to be different across peer nodes.</p>
     *
     * <p>Despite its seeming hokeyness, this approach works reasonably well in
     * practice.</p>
     *
     * @param key String serving as a key for cache lookup
     *
     * @param value Object we're storing in cache
     */
    public synchronized void put(String key, Object value) {
        m_list.put(key, value);
        final int hashCode = value.hashCode();

        if (s_log.isDebugEnabled()) {
            List trace = StringUtils.getStackList(new Throwable());
            final String caller = (String) trace.get(2);
            s_log.debug("Put key " + key + " in cache called at " + 
                        caller + ", object is of type " + value.getClass() + 
                        " with hash code: " + hashCode);
        }
        // If peer webservers don't contain the latest value,
        // remove this entry from their caches.
        CacheServlet.removeOutdatedFromPeers(m_cacheID, key, hashCode);
    }

    public synchronized void removeAll() {
        s_log.debug("removeAll");
        m_list.clear();
    }

    /**
     *  <p> Removes the object from cache.  Actually a wrapper for
     * {@link #remove(String,String)}. </p>
     *
     * @param key key of the object we're removing from cache
     */
    public void remove(String key) {
        remove(m_cacheID, key);
    }

    /**
     *  <p> Removes the object from cache.  Actually a wrapper for
     * {@link #remove(String,String)}. </p>
     *
     * @param key key of the object we're removing from cache
     */
    public void remove(BigDecimal key) {
        remove(key.toString());
    }

    /**
     *  <p> Static method which removes object from cache.  It is necessary
     * for implementing the {@link CacheServlet coherent caching}, since
     * it allows "outsiders" to invalidate (purge) certain objects from
     * cache. </p>
     *
     * @param id Unique identificator of cache storage
     *
     * @param key (BigDecimal) key of the object we're removing from cache
     */
    public static void remove(String id, BigDecimal key) {
        remove(id, key.toString());
    }

    /**
     *  <p> Static method which removes object from cache.  It is necessary
     * for implementing the {@link CacheServlet coherent caching}, since
     * it allows "outsiders" to invalidate (purge) certain objects from
     * cache. </p>
     *
     * @param id Unique identificator of cache storage
     *
     * @param key key of the object we're removing from cache
     */
    public static void remove(String id, String key) {
        CacheServlet.remove(id, key);
    }


    /**
     *  Unconditionally removes the entry from the local cache.
     *  This is meant to be invoked only from CacheServlet class.
     */
    synchronized void removeLocally(final String key) {
        m_list.remove(key);
        if (s_log.isDebugEnabled()) {
            s_log.debug("Removed key " + key);
        }
    }

    /**
     *  If the passed in hashCode doesn't match the that of local cache entry's,
     *  remove the cache entry locally.
     *  This is meant to be invoked only from CacheServlet class.
     */
    synchronized void removeLocallyIfOutdated(final String key, final int hashCode) {
        final boolean removed = m_list.removeIfOutdated(key, hashCode);

        if (s_log.isDebugEnabled()) {
            String msg;
            if (removed) {
                msg = "Removed ";
            } else {
                msg = "Didnt' remove ";
            }
            msg += " entry with key " + key + " hash " + hashCode;
            s_log.debug(msg);
        }
    }


    /**
     *  <p> Retrieves the object stored in cache.  If no object by the
     * passed key can be found in cache (maybe because it's expired or
     * it's been explicitly removed), null is returned. </p>
     *
     * @param key key of the object we're retrieving from cache
     *
     * @return Object stored in cache under key key
     */
    public synchronized Object get(BigDecimal key) {
        return get(key.toString());
    }

    /**
     *  <p> Retrieves the object stored in cache.  If no object by the
     * passed key can be found in cache (maybe because it's expired or
     * it's been explicitly removed), null is returned. </p>
     *
     * @param key key of the object we're retrieving from cache
     *
     * @return Object stored in cache under key key
     */
    public synchronized Object get(String key) {
        return m_list.get(key);
    }

    public interface TimestampedEntry {
        String getKey();
        Object getValue();
        Date getTimestamp();
    }

    private class TimeStamped implements TimestampedEntry {

        private long m_timestamp;
        private String m_key;
        private Object m_obj;

        TimeStamped(long timestamp, String key, Object obj) {
            if ( key==null ) { throw new NullPointerException("key"); }

            m_timestamp = timestamp;
            m_key = key;
            m_obj = obj;
        }

        TimeStamped(String key, Object obj) {
            m_timestamp = System.currentTimeMillis();
            m_key = key;
            m_obj = obj;
        }

        boolean isExpired(int seconds) {
            return (System.currentTimeMillis() - m_timestamp > seconds * 1000L);
        }

        public String getKey() {
            return m_key;
        }

        public Object getValue() {
            return m_obj;
        }

        void setValue(Object obj) {
            m_obj = obj;
        }

        /** for debugging only */
        public Date getTimestamp() {
            return new Date(m_timestamp);
        }

        public boolean equals(Object obj) {
            if ( ! (obj instanceof TimestampedEntry) ) { return false; }

            return getKey().equals(((TimestampedEntry) obj).getKey());
        }

        public int hashCode() {
            return m_key.hashCode();
        }
    }

    public interface Browser {
        Set getTableIDs();
        String getMaxSize(String tableID);
        String getCurrentSize(String tableID);
        String getMaxAge(String tableID);
        Set getEntrySet(String tableID);
    }

    private static class BrowserImpl implements Browser {
        private final Map m_tableMap;

        BrowserImpl(Map tableMap) {
            if ( tableMap==null ) { throw new NullPointerException(); }
            m_tableMap = tableMap;
        }

        public Set getTableIDs() {
            return new HashSet(m_tableMap.keySet());
        }

        public String getMaxSize(String tableID) {
            return String.valueOf(getCacheTable(tableID).getMaxSize());
        }

        public String getCurrentSize(String tableID) {
            return String.valueOf(getCacheTable(tableID).getCurrentSize());
        }

        public String getMaxAge(String tableID) {
            return String.valueOf(getCacheTable(tableID).getMaxAge());
        }

        private static CacheTable getCacheTable(String tableID) {
            if ( tableID==null ) { throw new NullPointerException(); }

            CacheTable table = CacheTable.getCache(tableID);

            if ( table==null ) {
                throw new IllegalArgumentException
                    ("no such table: " + tableID);
            }

            return CacheTable.getCache(tableID);
        }

        public Set getEntrySet(String tableID) {
            return getCacheTable(tableID).m_list.entrySet();
        }
    }
}
