package com.limegroup.gnutella;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;

import com.limegroup.gnutella.messages.QueryRequest;
import com.limegroup.gnutella.util.CommonUtils;
import com.limegroup.gnutella.util.Comparators;
import com.limegroup.gnutella.util.ConverterObjectInputStream;

import org.apache.commons.logging.LogFactory;
import org.apache.commons.logging.Log;

/**
 * This class contains a systemwide File creation time cache that persists these
 * times across sessions.  Very similar to UrnCache but less complex.
 *
 * This class is needed because we don't want to consult
 * File.lastModifiedTime() all the time.  We want to preserve creation times
 * across the Gnutella network.
 *
 * In order to be speedy, this class maintains two data structures - one for
 * fast URN to creation time lookup, another for fast 'youngest' file lookup.
 * <br>
 * IMPLEMENTATION NOTES:
 * The two data structures do not reflect each other's internal representation
 * - specifically, the URN->Time lookup may have more URNs than the
 * Time->URNSet lookup.  This is a consequence of partial file sharing.  It is
 * the case that the URNs in the sets of the Time->URNSet lookup are a subset
 * of the URNs in the URN->Time lookup.  For more details, see addTime and
 * commitTime.
 *
 * LOCKING: Note on grabbing the FM lock - if I ever do that, I first grab that
 * lock before grabbing my lock.  Please keep doing that as you add code.
 */
public final class CreationTimeCache {
    
    private static final Log LOG = LogFactory.getLog(CreationTimeCache.class);
    
    /**
     * File where creation times for files are stored.
     */
    private static final File CTIME_CACHE_FILE = 
        new File(CommonUtils.getUserSettingsDir(), "createtimes.cache");

    /**
     * CreationTimeCache instance variable.  
     * LOCKING: obtain CreationTimeCache.class.
     */
    private static CreationTimeCache instance = new CreationTimeCache();

    /**
     * CreationTimeCache container.  LOCKING: obtain this.
     * URN -> Creation Time (Long)
     */
    private final Map URN_TO_TIME_MAP;

    /**
     * Alternate container.  LOCKING: obtain this.
     * Creation Time (Long) -> Set of URNs
     */
    private final SortedMap TIME_TO_URNSET_MAP;
    
    /**
     * Whether or not data is dirty since the last time we saved.
     */
    private boolean dirty = false;

    /**
	 * Returns the <tt>CreationTimeCache</tt> instance.
	 *
	 * @return the <tt>CreationTimeCache</tt> instance
     */
    public static synchronized CreationTimeCache instance() {
        return instance;
    }

    /**
     * Create and initialize urn cache.
     */
    private CreationTimeCache() {
        URN_TO_TIME_MAP = createMap();
        // use a custom comparator to sort the map in descending order....
        TIME_TO_URNSET_MAP = new TreeMap(Comparators.inverseLongComparator());
        constructURNMap();
	}
    
    /** Returns the number of URNS stored. */
    public synchronized int getSize() {
        return URN_TO_TIME_MAP.size();
    }
    
    /**
     * Get the Creation Time of the file.
	 * @param urn <tt>URN<tt> to look up Creation Time for
	 * @return A Long that represents the creation time of the urn.  Null if
     * there is no association.
     */
    public synchronized Long getCreationTime(URN urn) {
		return (Long) URN_TO_TIME_MAP.get(urn);
    }
    
    /**
     * Get the Creation Time of the file.
	 * @param urn <tt>URN<tt> to look up Creation Time for
	 * @return A long that represents the creation time of the urn. -1
	 *         if no time exists.
     */
    public long getCreationTimeAsLong(URN urn) {
        Long l = getCreationTime(urn);
        if(l == null)
            return -1;
        else
            return l.longValue();
    }    
    
    /**
     * Removes the CreationTime that is associated with the specified URN.
     */
    public synchronized void removeTime(URN urn) {
        Long time = (Long) URN_TO_TIME_MAP.remove(urn);
        removeURNFromURNSet(urn, time);
        if(time != null)
            dirty = true;
    }


    /**
     * Clears away any URNs for files that do not exist anymore.
     * @param shouldClearURNSetMap true if you want to clear TIME_TO_URNSET_MAP
     * too
     */
    private void pruneTimes(boolean shouldClearURNSetMap) {
        // if i'm using FM, always grab that lock first and then me.  be quick
        // about it though :)
        synchronized (RouterService.getFileManager()) {
            synchronized (this) {
                Iterator iter = URN_TO_TIME_MAP.entrySet().iterator();
                while (iter.hasNext()) {
                    Map.Entry currEntry = (Map.Entry) iter.next();
                    if(!(currEntry.getKey() instanceof URN) ||
                       !(currEntry.getValue() instanceof Long)) {
                        iter.remove();
                        dirty = true;
                        continue;
                    }
                    URN currURN = (URN) currEntry.getKey();
                    Long cTime = (Long) currEntry.getValue();
                    
                    // check to see if file still exists
                    // NOTE: technically a URN can map to multiple FDs, but I only want
                    // to know about one.  getFileDescForUrn prefers FDs over iFDs.
                    FileDesc fd = RouterService.getFileManager().getFileDescForUrn(currURN);
                    if ((fd == null) || (fd.getFile() == null) || !fd.getFile().exists()) {
                        dirty = true;
                        iter.remove();
                        if (shouldClearURNSetMap)
                            removeURNFromURNSet(currURN, cTime);
                    }
                }
            }
        }
    }

    
    /**
     * Clears away any URNs for files that do not exist anymore.
     */
    public synchronized void pruneTimes() {
        pruneTimes(true);
    }


    /**
     * Add a CreationTime for the specified <tt>URN</tt> instance.  Can be 
     * called for any type of file (complete or partial).  Partial files
     * should be committed upon completion via commitTime.
	 *
	 * @param urn the <tt>URN</tt> instance containing Time to store
     * @param time The creation time of the urn.
     * @throws IllegalArgumentException If urn is null or time is invalid.
     */
    public synchronized void addTime(URN urn, long time) 
      throws IllegalArgumentException {
        if (urn == null)
            throw new IllegalArgumentException("Null URN.");
        if (time <= 0)
            throw new IllegalArgumentException("Bad Time = " + time);
        Long cTime = new Long(time);

        // populate urn to time
        Long existing = (Long)URN_TO_TIME_MAP.get(urn);
        if(existing == null || !existing.equals(cTime)) {
            dirty = true;
            URN_TO_TIME_MAP.put(urn, cTime);
        }
    }

    /**
     * Commits the CreationTime for the specified <tt>URN</tt> instance.  Should
     * be called for complete files that are shared.  addTime() for the input
     * URN should have been called first (otherwise you'll get a
     * IllegalArgumentException)
	 *
	 * @param urn the <tt>URN</tt> instance containing Time to store
     * @throws IllegalArgumentException If urn is null or the urn was never
     * added in addTime();
     */
    public synchronized void commitTime(URN urn) 
        throws IllegalArgumentException {
        if (urn == null) throw new IllegalArgumentException("Null URN.");
        Long cTime = (Long) URN_TO_TIME_MAP.get(urn);
        if  (cTime == null) 
            throw new IllegalArgumentException("Never added URN via addTime()");

        // populate time to set of urns
        Set urnSet = (Set) TIME_TO_URNSET_MAP.get(cTime);
        if (urnSet == null) {
            urnSet = new HashSet();
            TIME_TO_URNSET_MAP.put(cTime, urnSet);
        }
        urnSet.add(urn);
    }


    /**
     * Returns an List of URNs, from 'youngest' to 'oldest'.
     * @param max the maximum number of URNs you want returned.  if you
     * want all, give Integer.MAX_VALUE.
     * @return a List ordered by younger URNs.
     */
    public synchronized List getFiles(final int max)
        throws IllegalArgumentException {
        return getFiles(null, max);
    }    

    /**
     * Returns an List of URNs, from 'youngest' to 'oldest'.
     * @param max the maximum number of URNs you want returned.  if you
     * want all, give Integer.MAX_VALUE.
     * @param request in case the query has meta-flags, you can give it to
     * me. null is fine though.
     * @return a List ordered by younger URNs.
     */
    public List getFiles(final QueryRequest request, final int max)
        throws IllegalArgumentException {
        // if i'm using FM, always grab that lock first and then me.  be quick
        // about it though :)
        synchronized (RouterService.getFileManager()) {
        synchronized (this) {
        if (max < 1) throw new IllegalArgumentException("bad max = " + max);
        List urnList = new ArrayList();
        Iterator iter = TIME_TO_URNSET_MAP.entrySet().iterator();
        final MediaType.Aggregator filter = 
            (request == null ? null : MediaType.getAggregator(request));

        // may be non-null at loop end
        List toRemove = null;

        // we bank on the fact that the TIME_TO_URNSET_MAP iterator returns the 
        // entries in descending order....
        while (iter.hasNext() && (urnList.size() < max)) {
            Map.Entry currEntry = (Map.Entry) iter.next();
            Set urns = (Set) currEntry.getValue();

            // only put as many as desired, and possibly filter results based
            // on what the query desires
            Iterator innerIter = urns.iterator();
            while ((urnList.size() < max) && innerIter.hasNext()) {
                URN currURN = (URN) innerIter.next();
                FileDesc fd =
                    RouterService.getFileManager().getFileDescForUrn(currURN);
                // unfortunately fds can turn into ifds so ignore
                if ((fd == null) || (fd instanceof IncompleteFileDesc)) {
                    if (toRemove == null) toRemove = new ArrayList();
                    toRemove.add(currURN);
                    continue;
                }

                if (filter == null) urnList.add(currURN);
                else if (filter.allow(fd.getFileName())) urnList.add(currURN);
            }
        }

        // clear any ifd's or unshared files that may have snuck into structures
        if (toRemove != null) {
            Iterator removees = toRemove.iterator();
            while (removees.hasNext()) {
                URN currURN = (URN) removees.next();
                removeTime(currURN);
            }
        }

        return urnList;
        }
        }
    }


    /** Returns all of the files URNs, from youngest to oldest.
     */
    public synchronized List getFiles() {
        return getFiles(Integer.MAX_VALUE);
    }
    
        
    /**
     * Write cache so that we only have to calculate them once.
     */
    public synchronized void persistCache() {
        if(!dirty)
            return;
        
        //It's not ideal to hold a lock while writing to disk, but I doubt think
        //it's a problem in practice.
        ObjectOutputStream oos = null;
        try {
            oos = new ObjectOutputStream(
                    new BufferedOutputStream(new FileOutputStream(CTIME_CACHE_FILE)));
            oos.writeObject(URN_TO_TIME_MAP);
        } catch (IOException e) {
            ErrorService.error(e);
        } finally {
            try {
                if (oos != null)
                    oos.close();
            }
            catch (IOException ignored) {}
        }
        
        dirty = false;
    }

    /** Evicts the urn from the TIME_TO_URNSET_MAP.
     *  @param if refTime is non-null, will try to eject from set referred to
     *  by refTime.  otherwise will do an iterative search.
     */
    private synchronized void removeURNFromURNSet(URN urn, Long refTime) {
        if (refTime != null) {
            Set urnSet = (Set) TIME_TO_URNSET_MAP.get(refTime);
            if ((urnSet != null) && (urnSet.remove(urn)))
                if (urnSet.size() < 1) TIME_TO_URNSET_MAP.remove(refTime);
        }
        else { // search everything
            Iterator iter = TIME_TO_URNSET_MAP.entrySet().iterator();
            // find the urn in the map:
            // 1) get rid of it
            // 2) get rid of the empty set if it exists
            while (iter.hasNext()) {
                Map.Entry currEntry = (Map.Entry) iter.next();
                Set urnSet = (Set) currEntry.getValue();
                if (urnSet.contains(urn)) {
                    urnSet.remove(urn); // 1)
                    if (urnSet.size() < 1) iter.remove(); // 2)
                    break;
                }
            }
        }
    }
    

    /**
     * Constructs the TIME_TO_URNSET_MAP, which is based off the entries in the
     * URN_TO_TIME_MAP.
     * IMPORTANT NOTE: currently this method is not synchronized, and does not
     * need to be since it is only called from the constructor (which auto-
     * magically disallows concurrent acess to the instance.  If this method
     * is ever made public, called from multiple entrypoints, etc.,
     * synchronization may be needed.
     */
    private void constructURNMap() {
        Set entries = URN_TO_TIME_MAP.entrySet();
        Iterator iter = entries.iterator();
        while (iter.hasNext()) {
            // for each entry, get the creation time and the urn....
            // if the entry has invalid data then remove from map and 
            // just continue iterating
            try {
                Map.Entry currEntry = (Map.Entry) iter.next();
                Long cTime = (Long) currEntry.getValue();
                URN urn = (URN) currEntry.getKey();    
                
                // don't ever add IFDs
                if (RouterService.getFileManager().getFileDescForUrn(urn)
                    instanceof IncompleteFileDesc) continue;

                // put the urn in a set of urns that have that creation time....
                Set urnSet = (Set) TIME_TO_URNSET_MAP.get(cTime);
                if (urnSet == null) {
                    urnSet = new HashSet();
                    // populate the reverse mapping
                    TIME_TO_URNSET_MAP.put(cTime, urnSet);
                }
                urnSet.add(urn);
            } catch(ClassCastException ignoredEx) {
                iter.remove();
                dirty = true;
            }
        }
    }


    /**
     * Loads values from cache file, if available
     */
    private Map createMap() {
        ObjectInputStream ois = null;
		try {
            ois = new ConverterObjectInputStream(new BufferedInputStream(
                            new FileInputStream(CTIME_CACHE_FILE)));
			return (Map)ois.readObject();
	    } catch(Throwable t) {
	        LOG.error("Unable to read creation time file", t);
	        return new HashMap();
	    } finally {
            if(ois != null) {
                try {
                    ois.close();
                } catch(IOException e) {
                    // all we can do is try to close it
                }
            }
        }
	}
}
