package com.limegroup.gnutella.bootstrap;

import com.limegroup.gnutella.Assert;
import com.limegroup.gnutella.ExtendedEndpoint;
import com.limegroup.gnutella.UDPPinger;
import com.limegroup.gnutella.RouterService;
import com.limegroup.gnutella.MessageListener;
import com.limegroup.gnutella.ReplyHandler;
import com.limegroup.gnutella.UDPReplyHandler;
import com.limegroup.gnutella.messages.Message;
import com.limegroup.gnutella.messages.PingRequest;
import com.limegroup.gnutella.util.IpPortSet;
import com.limegroup.gnutella.util.NetworkUtils;
import com.limegroup.gnutella.util.Cancellable;
import com.limegroup.gnutella.util.FixedSizeExpiringSet;

import java.io.Writer;
import java.io.IOException;
import java.util.Iterator;
import java.util.Set;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.Collection;
import java.util.List;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Collections;

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

/**
 * A collection of UDP Host Caches.
 */
public class UDPHostCache {
    
    private static final Log LOG = LogFactory.getLog(UDPHostCache.class);
    
    /**
     * The maximum number of failures to allow for a given cache.
     */
    private static final int MAXIMUM_FAILURES = 5;
    
    /**
     * The total number of udp host caches to remember between
     * launches, or at any given time.
     */
    public static final int PERMANENT_SIZE = 100;
    
    /**
     * The number of hosts we try to fetch from at once.
     */
    public static final int FETCH_AMOUNT = 5;
    
    /**
     * A list of UDP Host caches, to allow easy sorting & randomizing.
     * For convenience, a Set is also maintained, to easily look up duplicates.
     * INVARIANT: udpHosts contains no duplicates and contains exactly
     *  the same elements and udpHostsSet
     * LOCKING: obtain this' monitor before modifying either */
    private final List /* of ExtendedEndpoint */ udpHosts =
        new ArrayList(PERMANENT_SIZE);
    private final Set /* of ExtendedEndpoint */ udpHostsSet = new HashSet();
    
    private final UDPPinger pinger;
    
    /**
     * A set of hosts who we've recently contacted, so we don't contact them
     * again.
     */
    private final Set /* of ExtendedEndpoint */ attemptedHosts;
    
    /**
     * Whether or not we need to resort the udpHosts by failures.
     */
    private boolean dirty = false;
    
    /**
     * Whether or not the set contains data different than when we last wrote.
     */
    private boolean writeDirty = false;
    
    /**
     * Constructs a new UDPHostCache that remembers attempting hosts for 10 
	 * minutes.
     */
    public UDPHostCache(UDPPinger pinger) {
        this(10 * 60 * 1000,pinger);
    }
    
    /**
     * Constructs a new UDPHostCache that remembers attempting hosts for
     * the given amount of time, in msecs.
     */
    public UDPHostCache(long expiryTime,UDPPinger pinger) {
        attemptedHosts = new FixedSizeExpiringSet(PERMANENT_SIZE, expiryTime);
        this.pinger = pinger;
    }
    
    /**
     * Writes this' info out to the stream.
     */
    public synchronized void write(Writer out) throws IOException {
        for(Iterator iter = udpHosts.iterator(); iter.hasNext(); ) {
            ExtendedEndpoint e = (ExtendedEndpoint)iter.next();
            e.write(out);
        }
        writeDirty = false;
    }
    
    /**
     * Determines if data has been dirtied since the last time we wrote.
     */
    public synchronized boolean isWriteDirty() {
        return writeDirty;
    }
    
    /**
     * Returns the number of UDP Host Caches this knows about.
     */
    public synchronized int getSize() {
        return udpHostsSet.size();
    }
    
    /**
     * Erases the attempted hosts & decrements the failure counts.
     */
    public synchronized void resetData() {
        LOG.debug("Clearing attempted udp host caches");
        decrementFailures();
        attemptedHosts.clear();
    }
    
    /**
     * Decrements the failure count for each known cache.
     */
    protected synchronized void decrementFailures() {
        for(Iterator i = attemptedHosts.iterator(); i.hasNext(); ) {
            ExtendedEndpoint ep = (ExtendedEndpoint)i.next();
            ep.decrementUDPHostCacheFailure();
            // if we brought this guy down back to a managable
            // failure size, add'm back if we have room.
            if(ep.getUDPHostCacheFailures() == MAXIMUM_FAILURES &&
               udpHosts.size() < PERMANENT_SIZE)
                add(ep);
            dirty = true;
            writeDirty = true;
        }
    }
    
    /**
     * Attempts to contact a host cache to retrieve endpoints.
     *
     * Contacts 10 UDP hosts at a time.
     */
    public synchronized boolean fetchHosts() {
        // If the order has possibly changed, resort.
        if(dirty) {
            // shuffle then sort, ensuring that we're still going to use
            // hosts in order of failure, but within each of those buckets
            // the order will be random.
            Collections.shuffle(udpHosts);
            Collections.sort(udpHosts, FAILURE_COMPARATOR);
            dirty = false;
        }
        
        // Keep only the first FETCH_AMOUNT of the valid hosts.
        List validHosts = new ArrayList(Math.min(FETCH_AMOUNT, udpHosts.size()));
        List invalidHosts = new LinkedList();
        for(Iterator i = udpHosts.iterator(); i.hasNext() && validHosts.size() < FETCH_AMOUNT; ) {
            Object next = i.next();
            if(attemptedHosts.contains(next))
                continue;
                
            // if it was private (couldn't look up too) drop it.
            if(NetworkUtils.isPrivateAddress(((ExtendedEndpoint)next).getAddress())) {
                invalidHosts.add(next);
                continue;
            }
            
            validHosts.add(next);
        }
        
        // Remove all invalid hosts.
        for(Iterator i = invalidHosts.iterator(); i.hasNext();  )
            remove((ExtendedEndpoint)i.next());

        attemptedHosts.addAll(validHosts);
        
        return fetch(validHosts);
     }
     
     /**
      * Fetches endpoints from the given collection of hosts.
      */
     protected synchronized boolean fetch(Collection hosts) {
        if(hosts.isEmpty()) {
            LOG.debug("No hosts to fetch");
            return false;
        }

        if(LOG.isDebugEnabled())
            LOG.debug("Fetching endpoints from " + hosts + " host caches");

        pinger.rank(
            hosts,
            new HostExpirer(hosts),
            // cancel when connected -- don't send out any more pings
            new Cancellable() {
                public boolean isCancelled() {
                    return RouterService.isConnected();
                }
            },
            getPing()
        );
        return true;
    }
    
    /**
     * Returns a PingRequest to be used while fetching.
     *
     * Useful as a seperate method for tests to catch the Ping's GUID.
     */
    protected PingRequest getPing() {
        return PingRequest.createUHCPing();
    }

    /**
     * Removes a given hostcache from this.
     */
    public synchronized boolean remove(ExtendedEndpoint e) {
        if(LOG.isTraceEnabled())
            LOG.trace("Removing endpoint: " + e);
        boolean removed1=udpHosts.remove(e);
        boolean removed2=udpHostsSet.remove(e);
        Assert.that(removed1==removed2,
                    "Set "+removed1+" but queue "+removed2);
        if(removed1)
            writeDirty = true;
        return removed1;
    }
    
    /**
     * Adds a new udp hostcache to this.
     */
    public synchronized boolean add(ExtendedEndpoint e) {
        Assert.that(e.isUDPHostCache());
        
        if (udpHostsSet.contains(e))
            return false;
            
        // note that we do not do any comparisons to ensure that
        // this host is "better" than existing hosts.
        // the rationale is that we'll only ever be adding hosts
        // who have a failure count of 0 (unless we're reading
        // from gnutella.net, in which case all will be added),
        // and we always want to try new people.
        
        // if we've exceeded the maximum size, remove the worst element.
        if(udpHosts.size() >= PERMANENT_SIZE) {
            Object removed = udpHosts.remove(udpHosts.size() - 1);
            udpHostsSet.remove(removed);
            if(LOG.isTraceEnabled())
                LOG.trace("Ejected: " + removed);
        }
        
        // just insert.  we'll sort later.
        udpHosts.add(e);
        udpHostsSet.add(e);
        dirty = true;
        writeDirty = true;
        return true;
    }
    
    /**
     * Notification that all stored UDP host caches have been added.
     * If none are stored, we load a list of defaults.
     */
    public synchronized void hostCachesAdded() {
        if(udpHostsSet.isEmpty())
            loadDefaults();
    }
    
    protected void loadDefaults() {
      // ADD DEFAULT UDP HOST CACHES HERE.
    }
    
    /**
     * Creates and adds a host/port as a UDP host cache.
     */
    private void createAndAdd(String host, int port) {
        try {
            ExtendedEndpoint ep = 
			  new ExtendedEndpoint(host, port).setUDPHostCache(true);
            add(ep);
        } catch(IllegalArgumentException ignored) {}
    }
    
    /**
     * Listener that listens for message from the specified hosts,
     * marking any hosts that did not have a message processed
     * as failed host caches, causing them to increment a failure
     * count.  If hosts exceed the maximum failures, they are
     * removed as potential hostcaches.
     */
    private class HostExpirer implements MessageListener {

        private final Set hosts = new IpPortSet();
        
        // allHosts contains all the hosts, so that we can
        // iterate over successful caches too.
        private final Set allHosts;
        private byte[] guid;
        
        /**
         * Constructs a new HostExpirer for the specified hosts.
         */
        public HostExpirer(Collection hostsToAdd) {
            hosts.addAll(hostsToAdd);
            allHosts = new HashSet(hostsToAdd);
            removeDuplicates(hostsToAdd, hosts);
        }
        
        /**
         * Removes any hosts that exist in 'all' but not in 'some'.
         */
        private void removeDuplicates(Collection all, Collection some) {
            // Iterate through what's in our collection vs whats in our set.
            // If any entries exist in the collection but not in the set,
            // then that means they resolved to the same address.
            // Automatically eject entries that resolve to the same address.
            Set duplicates = new HashSet(all);
            duplicates.removeAll(some); // remove any hosts we're keeping.
            for(Iterator i = duplicates.iterator(); i.hasNext(); ) {
                ExtendedEndpoint ep = (ExtendedEndpoint)i.next();
                if(LOG.isDebugEnabled())
                    LOG.debug("Removing duplicate entry: " + ep);
                remove(ep);
            }
        }
        
        /**
         * Notification that a message has been processed.
         */
        public void processMessage(Message m, ReplyHandler handler) {
            // allow only udp replies.
            if(handler instanceof UDPReplyHandler) {
                if(hosts.remove(handler)) {
                    if(LOG.isTraceEnabled())
                        LOG.trace("Recieved: " + m);
                }
                // OPTIMIZATION: if we've gotten succesful responses from
                // each hosts, unregister ourselves early.
                if(hosts.isEmpty())
                    RouterService.getMessageRouter().
					  unregisterMessageListener(guid, this);
            }
        }
        
        /**
         * Notification that this listener is now registered with the 
		 * specified GUID.
         */
        public void registered(byte[] g) {
            this.guid = g;
        }
        
        /**
         * Notification that this listener is now unregistered for the 
		 * specified guid.
         */
        public void unregistered(byte[] g) {
            synchronized(UDPHostCache.this) {
                // Record the failures...
                for(Iterator i = hosts.iterator(); i.hasNext(); ) {
                    ExtendedEndpoint ep = (ExtendedEndpoint)i.next();
                    if(LOG.isTraceEnabled())
                        LOG.trace("No response from cache: " + ep);
                    ep.recordUDPHostCacheFailure();
                    dirty = true;
                    writeDirty = true;
                    if(ep.getUDPHostCacheFailures() > MAXIMUM_FAILURES)
                        remove(ep);
                }
                // Then record the successes...
                allHosts.removeAll(hosts);
                for(Iterator i = allHosts.iterator(); i.hasNext(); ) {
                    ExtendedEndpoint ep = (ExtendedEndpoint)i.next();
                    if(LOG.isTraceEnabled())
                        LOG.trace("Valid response from cache: " + ep);
                    ep.recordUDPHostCacheSuccess();
                    dirty = true;
                    writeDirty = true;
                }
            }
        }
    }
    
    /**
     * The only FailureComparator we'll ever need.
     */
    private static final Comparator FAILURE_COMPARATOR = new FailureComparator();
    private static class FailureComparator implements Comparator {
        public int compare(Object a, Object b) {
            ExtendedEndpoint e1 = (ExtendedEndpoint)a;
            ExtendedEndpoint e2 = (ExtendedEndpoint)b;
            return e1.getUDPHostCacheFailures() - e2.getUDPHostCacheFailures();
        }
    }
}
