package com.limegroup.gnutella.dht;

import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.limewire.collection.Cancellable;
import org.limewire.io.IpPort;
import org.limewire.io.IpPortImpl;
import org.limewire.io.NetworkUtils;

import com.google.inject.Provider;
import com.limegroup.gnutella.ConnectionServices;
import com.limegroup.gnutella.ExtendedEndpoint;
import com.limegroup.gnutella.HostCatcher;
import com.limegroup.gnutella.MessageListener;
import com.limegroup.gnutella.ReplyHandler;
import com.limegroup.gnutella.UDPPinger;
import com.limegroup.gnutella.dht.DHTManager.DHTMode;
import com.limegroup.gnutella.messages.Message;
import com.limegroup.gnutella.messages.PingReply;
import com.limegroup.gnutella.messages.PingRequestFactory;
import com.limegroup.gnutella.settings.ConnectionSettings;
import com.limegroup.gnutella.settings.DHTSettings;

/**
 * Fetches DHT hosts from the Gnutella network through the use of a UDP ping.
 * <p>
 * Implicitly, a request for DHT hosts will also propagate the knowledge 
 * through the network, thanks to the <code>MessageRouter</code>'s pong 
 * forwarding logic.
 * <p>
 * First the <code>DHTNodeFetcher</code> tries to get active nodes directly from 
 * the {@link HostCatcher}. If that fails, it tries to send UDP pings to nodes 
 * who support the DHT. If that fails too, it sends UDP pings to all nodes in 
 * the HostCatcher.
 * <p>
 * <code>DHTNodeFetcher</code> uses the ranker from the <code>HostCatcher</code>,
 * which it cancels when the <tt>AbstractDHTController</tt> is able to bootstrap. 
 * <p>
 * <code>DHTNodeFetcher</code> can also start a timer task to periodically 
 * requests hosts until the manager is able to bootstrap. 
 */
public class DHTNodeFetcher {
    
    private static final Log LOG = LogFactory.getLog(DHTNodeFetcher.class);
    
    /**
     * The instance of the bootstrapper to which we hand back bootstrap hosts.
     * Also used to cancel this node fetcher if the DHT was able to bootstrap.
     */
    private final DHTBootstrapper bootstrapper;
    
    /**
     * The time of the last ping request(s) in the network.
     */
    private volatile long lastRequest = 0L;
    
    /**
     * The Runnable that requests DHT hosts.
     */
    private ScheduledFuture<?>  fetcherTask = null;
    
    /**
     * A lock for the TimerTask
     */
    private final Object fetcherTaskLock = new Object();
    
    /**
     * Whether or not the fetcher is currently pinging a single host
     */
    private final AtomicBoolean pingingSingleHost = new AtomicBoolean(false);
    
    /**
     * The pinger used to send out the UDP pings.
     */
    private UDPPinger pinger;
    
    /**
     * A settable expiry time for the pings.
     */
    private volatile int pingExpireTime = -1;
    
    private final ConnectionServices connectionServices;
    private final Provider<HostCatcher> hostCatcher;
    private final ScheduledExecutorService backgroundExecutor;
    private final Provider<UDPPinger> udpPingerFactory;

    private final PingRequestFactory pingRequestFactory;
    
    public DHTNodeFetcher(DHTBootstrapper bootstrapper,
            ConnectionServices connectionServices,
            Provider<HostCatcher> hostCatcher,
            ScheduledExecutorService backgroundExecutor,
            Provider<UDPPinger> udpPingerFactory,
            PingRequestFactory pingRequestFactory) {
        this.connectionServices = connectionServices;
        this.hostCatcher = hostCatcher;
        this.backgroundExecutor = backgroundExecutor;
        this.udpPingerFactory = udpPingerFactory;

        this.bootstrapper = bootstrapper;
        this.pingRequestFactory = pingRequestFactory;
    }
    
    /**
     * Requests active DHT hosts from the Gnutella network. This method has to
     * be synchronized because it can be called either directly by the manager
     * or by the timer task.
     * <p>
     * This method gets hosts from the HostCatcher and therefore uses the
     * UniqueHostPinger of the HostCatcher in order to avoid pinging hosts
     * twice.
     */
    private synchronized void requestDHTHosts() {
        
        LOG.debug("Requesting DHT hosts");
        
        if(!connectionServices.isConnected()) {
            return;
        }
        
        //TODO: min version: for now, 0
        List<ExtendedEndpoint> dhtHosts = 
            hostCatcher.get().getDHTSupportEndpoint(0);
        
        //first see if we have any active dht node in our HostCatcher and add them all 
        //to the bootstrapper.
        //The list is ordered by Active nodes first so as soon as we get an inactive
        //node we can exit the loop.
        boolean haveActive = false;
        for(ExtendedEndpoint ep : dhtHosts) {
            if (!DHTMode.ACTIVE.equals(ep.getDHTMode())) {
                break;
            }
            
            if(LOG.isDebugEnabled()){
                LOG.debug("Adding active host from HostCatcher: "+ ep.getInetSocketAddress());
            }
            
            haveActive = true;
            bootstrapper.addBootstrapHost(ep.getInetSocketAddress());
        }
        
        if(haveActive) { //we have added active hosts already - no need to request
            return;
        }
        
        //We don't have active hosts --> send UDP pings
        long now = System.currentTimeMillis();
        if(now - lastRequest < DHTSettings.DHT_NODE_FETCHER_TIME.getValue()) {
            return;
        }
        
        if(pingingSingleHost.get()) {
            return;
        }
        
        lastRequest = now;
        Message m = pingRequestFactory.createUDPingWithDHTIPPRequest();
        MessageListener listener = new UDPPingerRequestListener();
        Cancellable canceller = new UDPPingRankerCanceller();
        
        if(!dhtHosts.isEmpty()) { 
            
            LOG.debug("Sending ping to dht capable hosts");
            
            //we don't have active hosts but have hosts that support dht
            hostCatcher.get().getPinger().rank(dhtHosts, 
                                                            listener, canceller, m);
        } else {
            
            LOG.debug("Sending ping to all hosts");
            
            //send to all hosts
            hostCatcher.get().sendMessageToAllHosts(m, 
                    listener, canceller);
        }
    }
    
    /**
     * Sends a UDP ping requesting DHT node to the specified host.
     * 
     * @param hostAddress the <tt>SocketAddress</tt> of the host to send the ping to.
     */
    public void requestDHTHosts(SocketAddress hostAddress) {
        
        if(!connectionServices.isConnected()) {
            return;
        }   
        
        if(!(hostAddress instanceof InetSocketAddress)) {
            return;
        }
        
        //this should preempt over ping ranker, as we know this hostAddress
        //can send back DHT hosts
        if(!pingingSingleHost.getAndSet(true)) {

            IpPort ipp = new IpPortImpl((InetSocketAddress) hostAddress);
            
            if(LOG.isDebugEnabled()) {
                LOG.debug("Requesting DHT hosts from host " + hostAddress);
            }
            
            Message m = pingRequestFactory.createUDPingWithDHTIPPRequest();
            
            if(pinger == null) {
                pinger = udpPingerFactory.get();
            }
            
            pinger.rank(Arrays.asList(ipp), 
                    new SinglePingRequestListener(), null, m, pingExpireTime);
        }
    }
    
    /**
     * Starts the DHTNodeFetcher
     */
    public void start() {
        synchronized (fetcherTaskLock) {
            if (fetcherTask != null) {
                return;
            }
            
            long fetcherTime = DHTSettings.DHT_NODE_FETCHER_TIME.getValue();
            long initialFetch = (long) (Math.random() * fetcherTime);
            
            Runnable task = new Runnable() {
                public void run() {
                    requestDHTHosts();
                }
            };
            
            fetcherTask = backgroundExecutor.scheduleWithFixedDelay(task, initialFetch, fetcherTime, TimeUnit.MILLISECONDS);
        }
    }
    
    /**
     * Stops the DHTNodeFetcher
     */
    public void stop() {
        synchronized (fetcherTaskLock) {
            if (fetcherTask != null) {
                fetcherTask.cancel(true);
                fetcherTask = null;
            }
        }
    }
    
    /**
     * Returns true if the DHTNodeFetcher is running
     */
    public boolean isRunning() {
        synchronized (fetcherTaskLock) {
            return fetcherTask != null;
        }
    }
    
    /**
     * Processes the ping reply containing DHT IP:Ports and
     * hands those back to the DHT bootstrapper.
     */
    private void processPingReply(Message m) {
        
        if(!(m instanceof PingReply)) {
            return;
        }
        
        if(!isRunning()) {
            return;
        }
        
        PingReply reply = (PingReply) m;
        Collection<IpPort> list = ConnectionSettings.FILTER_CLASS_C.getValue() ?
            NetworkUtils.filterOnePerClassC(reply.getPackedDHTIPPorts()) :
                reply.getPackedDHTIPPorts();

        if(LOG.isDebugEnabled()) {
            LOG.debug("Received ping reply from "+reply.getAddress());
        }
        
        for (IpPort ipp : list) {
            bootstrapper.addBootstrapHost(new InetSocketAddress(
                    ipp.getInetAddress(), ipp.getPort()));
        }
    }
    
    public void setPingExpireTime(int expireTime) {
        pingExpireTime = expireTime;
    }
    
    /**
     * This <tt>Cancellable</tt> is used to cancel UDP ping requests
     * sent to sets of hosts. Cancelling is triggered by any of the following conditions:
     * <ol>
     * <li>We are now sending a ping to a single host.
     * <li>The maximum delay has been exceeded
     * <li>We are not connected to the network
     * <li>We are not anymore waiting for DHT nodes to bootstrap
     *
     */
    private class UDPPingRankerCanceller implements Cancellable{
        
        /** Cancels the HostCatcher pings **/
        public boolean isCancelled() {
            //disregard delay if this is a ping to a single host
            long delay = System.currentTimeMillis() - lastRequest;
            //stop when not waiting anymore OR when not connected to the Gnutella network 
            //OR timeout
            boolean cancel = (pingingSingleHost.get() 
                                || delay > DHTSettings.MAX_DHT_NODE_FETCHER_TIME.getValue()
                                || !connectionServices.isConnected()
                                || !isRunning());
            if(cancel){
                if(LOG.isDebugEnabled()) {
                    LOG.debug("Cancelling UDP ping after "+delay+" ms, connected: "
                            +connectionServices.isConnected()+", waiting: "+bootstrapper.isWaitingForNodes());
                }
            }
            return cancel;
        }
    }
    
    private class UDPPingerRequestListener implements MessageListener{
        /** Response to our UDP ping **/
        public void processMessage(Message m, ReplyHandler handler) {
            processPingReply(m);
        }
        public void registered(byte[] guid) {}
        public void unregistered(byte[] guid) {}
    }

    /**
     * In the case of a single ping, we have to handle the deregistration
     */
    private class SinglePingRequestListener extends UDPPingerRequestListener{
        
        @Override
        public void processMessage(Message m, ReplyHandler handler) {
            super.processMessage(m, handler);
            pingingSingleHost.set(false);
        }

        @Override
        public void unregistered(byte[] guid) {
            if(LOG.isDebugEnabled()) {
                LOG.debug("Unregistering Ping");
            }
            pingingSingleHost.set(false);
        }
    }
}
