package com.limegroup.gnutella;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.BindException;
import java.net.ConnectException;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.NoRouteToHostException;
import java.net.PortUnreachableException;
import java.net.SocketAddress;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel;
import java.util.LinkedList;
import java.util.List;

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

import com.limegroup.gnutella.guess.GUESSEndpoint;
import com.limegroup.gnutella.io.NIODispatcher;
import com.limegroup.gnutella.io.ReadWriteObserver;
import com.limegroup.gnutella.messages.BadPacketException;
import com.limegroup.gnutella.messages.Message;
import com.limegroup.gnutella.messages.MessageFactory;
import com.limegroup.gnutella.messages.PingReply;
import com.limegroup.gnutella.messages.PingRequest;
import com.limegroup.gnutella.messages.vendor.ReplyNumberVendorMessage;
import com.limegroup.gnutella.settings.ConnectionSettings;
import com.limegroup.gnutella.util.BufferByteArrayOutputStream;
import com.limegroup.gnutella.util.IpPort;
import com.limegroup.gnutella.util.NetworkUtils;

/**
 * This class handles UDP messaging services.  It both sends and
 * receives messages, routing received messages to their appropriate
 * handlers.  This also handles issues related to the GUESS proposal, 
 * such as making sure that the UDP and TCP port match and sending
 * UDP acks for queries.
 *
 * @see UDPReplyHandler
 * @see MessageRouter
 * @see QueryUnicaster
 *
 */
public class UDPService implements ReadWriteObserver {

    private static final Log LOG = LogFactory.getLog(UDPService.class);
    
	/**
	 * Constant for the single <tt>UDPService</tt> instance.
	 */
	private final static UDPService INSTANCE = new UDPService();
	
	/**
	 * The DatagramChannel we're reading from & writing to.
	 */
	private DatagramChannel _channel;
	
	/**
	 * The list of messages to be sent, as SendBundles.
	 */
	private final List OUTGOING_MSGS;
	
	/**
	 * The buffer that's re-used for reading incoming messages.
	 */
	private final ByteBuffer BUFFER;
    
	/**
	 * The maximum size of a UDP message we'll accept.
	 */
	private final int BUFFER_SIZE = 1024 * 2;
    
    /** True if the UDPService has ever received a solicited incoming UDP
     *  packet.
     */
    private volatile boolean _acceptedSolicitedIncoming = false;
    
    /** True if the UDPService has ever received a unsolicited incoming UDP
     *  packet.
     */
    private volatile boolean _acceptedUnsolicitedIncoming = false;
    
    /** The last time the _acceptedUnsolicitedIncoming was set.
     */
    private long _lastUnsolicitedIncomingTime = 0;

    /**
     * The last time we received any udp packet
     */
    private volatile long _lastReceivedAny = 0;
    
    /** The last time we sent a UDP Connect Back.
     */
    private long _lastConnectBackTime = System.currentTimeMillis();
    void resetLastConnectBackTime() {
        _lastConnectBackTime = 
             System.currentTimeMillis() - Acceptor.INCOMING_EXPIRE_TIME;
    }
    
    /** Whether our NAT assigns stable ports for successive connections 
     * LOCKING: this
     */
    private boolean _portStable = true;
    
    /** The last reported port as seen from the outside
     *  LOCKING: this
     */
    private int _lastReportedPort;

    /**
     * The number of pongs carrying IP:Port info we have received.
     * LOCKING: this
     */
    private int _numReceivedIPPongs;

    /**
     * The GUID that we advertise out for UDPConnectBack requests.
     */
    private final GUID CONNECT_BACK_GUID = new GUID(GUID.makeGuid());

    /**
     * The GUID that we send for Pings, useful to test solicited support.
     */
    private final GUID SOLICITED_PING_GUID = new GUID(GUID.makeGuid());
    
    /**
     * Determines if this was ever started.
     */
    private boolean _started = false;


    /**
     * The time between UDP pings.  Used by the PeriodicPinger.  This is
     * useful for nodes behind certain firewalls (notably the MS firewall).
     */
    private static final long PING_PERIOD = 85 * 1000;  // 85 seconds
    
    /**
     * A buffer used for reading the header of incoming messages.
     */
    private static final byte[] IN_HEADER_BUF = new byte[23];

	/**
	 * Instance accessor.
	 */
	public static UDPService instance() {
		return INSTANCE;
	}

	/**
	 * Constructs a new <tt>UDPAcceptor</tt>.
	 */
	protected UDPService() {	   
	    OUTGOING_MSGS = new LinkedList();
	    byte[] backing = new byte[BUFFER_SIZE];
	    BUFFER = ByteBuffer.wrap(backing);
        scheduleServices();
    }
    
    /**
     * Schedules IncomingValidator & PeriodicPinger for periodic use.
     */
    protected void scheduleServices() {
        RouterService.schedule(new IncomingValidator(), 
                               Acceptor.TIME_BETWEEN_VALIDATES,
                               Acceptor.TIME_BETWEEN_VALIDATES);
        RouterService.schedule(new PeriodicPinger(), 0, PING_PERIOD);
    }
    
    /** @return The GUID to send for UDPConnectBack attempts....
     */
    public GUID getConnectBackGUID() {
        return CONNECT_BACK_GUID;
    }

    /** @return The GUID to send for Solicited Ping attempts....
     */
    public GUID getSolicitedGUID() {
        return SOLICITED_PING_GUID;
    }
    
    /**
     * Starts listening for UDP messages & allowing UDP messages to be written.
     */
    public void start() {
        DatagramChannel channel;
        synchronized(this) {
            _started = true;
            channel = _channel;
        }
        
        if(channel != null)
            NIODispatcher.instance().registerReadWrite(channel, this);
    }

    /** 
     * Returns a new DatagramSocket that is bound to the given port.  This
     * value should be passed to setListeningSocket(DatagramSocket) to commit
     * to the new port.  If setListeningSocket is NOT called, you should close
     * the return socket.
     * @return a new DatagramSocket that is bound to the specified port.
     * @exception IOException Thrown if the DatagramSocket could not be
     * created.
     */
    DatagramSocket newListeningSocket(int port) throws IOException {
        try {
            DatagramChannel channel = DatagramChannel.open();
            channel.configureBlocking(false);
        	DatagramSocket s = channel.socket();
        	s.setReceiveBufferSize(64*1024);
        	s.setSendBufferSize(64*1024);
            s.bind(new InetSocketAddress(port));
            return s;
        } catch (SecurityException se) {
            throw new IOException("security exception on port: "+port);
        }
    }


	/** 
     * Changes the DatagramSocket used for sending/receiving.  Typically called
     * by Acceptor to commit to the new port.
     * @param datagramSocket the new listening socket, which must be be the
     *  return value of newListeningSocket(int).  A value of null disables 
     *  UDP sending and receiving.
	 */
	void setListeningSocket(DatagramSocket datagramSocket) {
	    if(_channel != null) {
	        try {
	            _channel.close();
	        } catch(IOException ignored) {}
	    }
	    
	    if(datagramSocket != null) {
	        boolean wasStarted;
	        synchronized(this) {
        	    _channel = datagramSocket.getChannel();
        	    if(_channel == null)
        	        throw new IllegalArgumentException("No channel!");
        	        
                wasStarted = _started;
            
                // set the port in the FWT records
                _lastReportedPort=_channel.socket().getLocalPort();
                _portStable=true;
            }
            
            // If it was already started at one point, re-start to register this new channel.
            if(wasStarted)
                start();
        }
	}
	
	/**
	 * Shuts down this service.
	 */
	public void shutdown() {
	    setListeningSocket(null);
	}
	
	/**
	 * Notification that a read can happen.
	 */
	public void handleRead() throws IOException {
        while(true) {
            BUFFER.clear();
            
            SocketAddress from;
            try {
                from = _channel.receive(BUFFER);
            } catch(IOException iox) {
                break;
            } catch(Error error) {
                // Stupid implementations giving bogus errors.  Grrr!.
                break;
            }
            
            // no packet.
            if(from == null)
                break;
            
            if(!(from instanceof InetSocketAddress)) {
                Assert.silent(false, "non-inet SocketAddress: " + from);
                continue;
            }
            
            InetSocketAddress addr = (InetSocketAddress)from;
                
            if(!NetworkUtils.isValidAddress(addr.getAddress()))
                continue;
            if(!NetworkUtils.isValidPort(addr.getPort()))
                continue;
                
            byte[] data = BUFFER.array();
            int length = BUFFER.position();
            try {
                // we do things the old way temporarily
                InputStream in = new ByteArrayInputStream(data, 0, length);
                Message message = MessageFactory.read(in, Message.N_UDP, IN_HEADER_BUF);
                if(message == null)
                    continue;

                processMessage(message, addr);
            } catch (IOException ignored) {
            } catch (BadPacketException ignored) {
            }
        }
	}
	
	/**
	 * Notification that an IOException occurred while reading/writing.
	 */
	public void handleIOException(IOException iox) {
        if( !(iox instanceof java.nio.channels.ClosedChannelException ) )
            ErrorService.error(iox, "UDP Error.");
        else
            LOG.trace("Swallowing a UDPService ClosedChannelException", iox);
	}
	
	/**
	 * Processes a single message.
	 */
    protected void processMessage(Message message, InetSocketAddress addr) {
        updateState(message, addr);
        MessageDispatcher.instance().dispatchUDP(message, addr);
    }
	
	/** Updates internal state of the UDP Service. */
	private void updateState(Message message, InetSocketAddress addr) {
        _lastReceivedAny = System.currentTimeMillis();
	    if (!isGUESSCapable()) {
            if (message instanceof PingRequest) {
                GUID guid = new GUID(message.getGUID());
                if(isValidForIncoming(CONNECT_BACK_GUID, guid, addr)) {
                    _acceptedUnsolicitedIncoming = true;
                }
                _lastUnsolicitedIncomingTime = _lastReceivedAny;
            }
            else if (message instanceof PingReply) {
                GUID guid = new GUID(message.getGUID());
                if(!isValidForIncoming(SOLICITED_PING_GUID, guid, addr ))
                    return;
                
                _acceptedSolicitedIncoming = true;
                
                PingReply r = (PingReply)message;
                if (r.getMyPort() != 0) {
                    synchronized(this){
                        _numReceivedIPPongs++;
                        
                        if (_numReceivedIPPongs==1) 
                            _lastReportedPort=r.getMyPort();
                        else if (_lastReportedPort!=r.getMyPort()) {
                            _portStable = false;
                            _lastReportedPort = r.getMyPort();
                        }
                    }
                }
                
            }
        }
        // ReplyNumberVMs are always sent in an unsolicited manner,
        // so we can use this fact to keep the last unsolicited up
        // to date
        if (message instanceof ReplyNumberVendorMessage)
            _lastUnsolicitedIncomingTime = _lastReceivedAny;
	}
	
	/**
	 * Determines whether or not the specified message is valid for setting
	 * LimeWire as accepting UDP messages (solicited or unsolicited).
	 */
	private boolean isValidForIncoming(GUID match, GUID guidReceived, InetSocketAddress addr) {
        if(!match.equals(guidReceived))
            return false;
            
	    String host = addr.getAddress().getHostAddress();
        
        //  If addr is connected to us, then return false.  Otherwise (not connected), only return true if either:
        //      1) the non-connected party is NOT private
        //  OR
        //      2) the non-connected party _is_ private, and the LOCAL_IS_PRIVATE is set to false
        return
                !RouterService.getConnectionManager().isConnectedTo(host)
            &&  !NetworkUtils.isPrivateAddress(addr.getAddress())
             ;

    }
    
    /**
     * Sends the specified <tt>Message</tt> to the specified host.
     * 
     * @param msg the <tt>Message</tt> to send
     * @param host the host to send the message to
     */
    public void send(Message msg, InetSocketAddress host) {
        send(msg, host.getAddress(), host.getPort());
    }    
    
    /**
     * Sends the specified <tt>Message</tt> to the specified host.
     * 
     * @param msg the <tt>Message</tt> to send
     * @param host the host to send the message to
     */
    public void send(Message msg, IpPort host) {
        send(msg, host.getInetAddress(), host.getPort());
    }

	/**
	 * Sends the <tt>Message</tt> via UDP to the port and IP address specified.
     * This method should not be called if the client is not GUESS enabled.
     *
	 * @param msg  the <tt>Message</tt> to send
	 * @param ip   the <tt>InetAddress</tt> to send to
	 * @param port the port to send to
     * @param err  an <tt>ErrorCallback<tt> if you want to be notified errors
     * @throws IllegalArgumentException if msg, ip, or err is null.
	 */
    public void send(Message msg, InetAddress ip, int port) 
        throws IllegalArgumentException {
        if (msg == null)
            throw new IllegalArgumentException("Null Message");
        if (ip == null)
            throw new IllegalArgumentException("Null InetAddress");
        if (!NetworkUtils.isValidPort(port))
            throw new IllegalArgumentException("Invalid Port: " + port);
        if(_channel == null || _channel.socket().isClosed())
            return; // ignore if not open.

        int length = msg.getTotalLength();
        ByteBuffer buffer = NIODispatcher.instance().getBufferCache().getHeap(length);
        if(buffer.remaining() != length)
            throw new IllegalStateException("retrieved a buffer with wrong remaining! " +
                                            "wanted: " + length +
                                            ", had: " + buffer.remaining() +
                                            ", position: " + buffer.position() +
                                            ", limit: " + buffer.limit());

        BufferByteArrayOutputStream baos = new BufferByteArrayOutputStream(buffer);
        try {
            msg.writeQuickly(baos);
        } catch(IOException e) {
            // this should not happen -- we should always be able to write
            // to this output stream in memory
            ErrorService.error(e);
            // can't send the hit, so return
            return;
        }
       
        buffer.flip();
        send(buffer, ip, port, false);
    }
    
    public void send(ByteBuffer buffer, InetAddress ip, int port, boolean custom) { 
        synchronized(OUTGOING_MSGS) {
            OUTGOING_MSGS.add(new SendBundle(buffer, ip, port, custom));
            if(_channel != null)
                NIODispatcher.instance().interestWrite(_channel, true);
        }
	}
	
	/**
	 * Notification that a write can happen.
	 */
	public boolean handleWrite() throws IOException {
	    synchronized(OUTGOING_MSGS) {
	        while(!OUTGOING_MSGS.isEmpty()) {
                boolean releaseBuffer = true;
                SendBundle bundle = (SendBundle)OUTGOING_MSGS.remove(0);
	            try {
    	            if(_channel.send(bundle.buffer, bundle.addr) == 0) {
    	                // we removed the bundle from the list but couldn't send it,
    	                // so we have to put it back in.
    	                OUTGOING_MSGS.add(0, bundle);
                        releaseBuffer = false;
    	                return true; // no room left to send.
                    } else if(bundle.custom) {
                        bundle.buffer.rewind();
                        releaseBuffer = false;
                    }
                } catch(IOException ignored) {
                    LOG.warn("Ignoring exception on socket", ignored);
                } finally {
                    if (releaseBuffer)
                        NIODispatcher.instance().getBufferCache().release(bundle.buffer);
                }
	        }
	        
	        // if there's no data left to send, we don't wanna be notified of write events.
	        NIODispatcher.instance().interestWrite(_channel, false);
	        return false;
	    }
    }       
	
	/** Wrapper for outgoing data */
	private static class SendBundle {
	    private final ByteBuffer buffer;
	    private final SocketAddress addr;
        private final boolean custom;
	    
	    SendBundle(ByteBuffer b, InetAddress addr, int port, boolean custom) {
	        buffer = b;
	        this.addr = new InetSocketAddress(addr, port);
            this.custom = custom;
	    }
	}


	/**
	 * Returns whether or not this node is capable of sending its own
	 * GUESS queries.  This would not be the case only if this node
	 * has not successfully received an incoming UDP packet.
	 *
	 * @return <tt>true</tt> if this node is capable of running its own
	 *  GUESS queries, <tt>false</tt> otherwise
	 */	
	public boolean isGUESSCapable() {
		return canReceiveUnsolicited() && canReceiveSolicited();
	}

	/**
	 * Returns whether or not this node is capable of receiving UNSOLICITED
     * UDP packets.  It is false until a UDP ConnectBack ping has been received.
	 *
	 * @return <tt>true</tt> if this node has accepted a UNSOLICITED UDP packet.
	 */	
	public boolean canReceiveUnsolicited() {
		return _acceptedUnsolicitedIncoming;
	}

	/**
	 * Returns whether or not this node is capable of receiving SOLICITED
     * UDP packets.  
	 *
	 * @return <tt>true</tt> if this node has accepted a SOLICITED UDP packet.
	 */	
	public boolean canReceiveSolicited() {
        return _acceptedSolicitedIncoming;
	}
	
	/**
	 * 
	 * @return whether this node can do Firewall-to-firewall transfers.
	 *  Until we get back any udp packet, the answer is no.
	 *  If we have received an udp packet but are not connected, or haven't 
	 * received a pong carrying ip info yet, see if we ever disabled fwt in the 
	 * past.
	 *  If we are connected and have gotten a single ip pong, our port must be 
	 * the same as our tcp port or our forced tcp port.
	 *  If we have received more than one ip pong, they must all report the same
	 * port.
	 */
	public boolean canDoFWT(){
	    // this does not affect EVER_DISABLED_FWT.
	    if (!canReceiveSolicited()) 
	        return false;

	    if (!RouterService.isConnected())
	        return !ConnectionSettings.LAST_FWT_STATE.getValue();
	    
	    boolean ret = true;
	    synchronized(this) {     	
	        if (_numReceivedIPPongs < 1) 
	            return !ConnectionSettings.LAST_FWT_STATE.getValue();
	        
	        if (LOG.isTraceEnabled()) {
	            LOG.trace("stable "+_portStable+
	                    " last reported port "+_lastReportedPort+
	                    " our external port "+RouterService.getPort()+
	                    " our non-forced port "+RouterService.getAcceptor().getPort(false)+
	                    " number of received IP pongs "+_numReceivedIPPongs+
	                    " valid external addr "+NetworkUtils.isValidAddress(
	                            RouterService.getExternalAddress()));
	        }
	        
	        ret= 
	            NetworkUtils.isValidAddress(RouterService.getExternalAddress()) && 
	    		_portStable;
	        
	        if (_numReceivedIPPongs == 1){
	            ret = ret &&
	            	(_lastReportedPort == RouterService.getAcceptor().getPort(false) ||
	                    _lastReportedPort == RouterService.getPort());
	        }
	    }
	    
	    ConnectionSettings.LAST_FWT_STATE.setValue(!ret);
	    
	    return ret;
	}
	
	// Some getters for bug reporting 
	public boolean portStable() {
	    return _portStable;
	}
	
	public int receivedIpPong() {
	    return _numReceivedIPPongs;
	}
	
	public int lastReportedPort() {
	    return _lastReportedPort;
	}
	
	/**
	 * @return the stable UDP port as seen from the outside.
	 *   If we have received more than one IPPongs and they report
	 * the same port, we return that.
	 *   If we have received just one IPpong, and if its address 
	 * matches either our local port or external port, return that.
	 *   If we have not received any IPpongs, return whatever 
	 * RouterService thinks our port is.
	 */
	public int getStableUDPPort() {

	    int localPort = RouterService.getAcceptor().getPort(false);
	    int forcedPort = RouterService.getPort();

	    synchronized(this) {
	        if (_portStable && _numReceivedIPPongs > 1)
	            return _lastReportedPort;

		if (_numReceivedIPPongs == 1 &&
			(localPort == _lastReportedPort || 
				forcedPort == _lastReportedPort))
		    return _lastReportedPort;
	    }

	    return forcedPort; // we haven't received an ippong.
	}

	/**
	 * Sets whether or not this node is capable of receiving SOLICITED
     * UDP packets.  This is useful for testing UDPConnections.
	 *
	 */	
	public void setReceiveSolicited(boolean value) {
		_acceptedSolicitedIncoming = value;
	}
    
    public long getLastReceivedTime() {
        return _lastReceivedAny;
    }

	/**
	 * Returns whether or not the UDP socket is listening for incoming
	 * messsages.
	 *
	 * @return <tt>true</tt> if the UDP socket is listening for incoming
	 *  UDP messages, <tt>false</tt> otherwise
	 */
	public boolean isListening() {
		if(_channel == null)
		    return false;
		    
		return (_channel.socket().getLocalPort() != -1);
	}

	/** 
	 * Overrides Object.toString to give more informative information
	 * about the class.
	 *
	 * @return the <tt>DatagramSocket</tt> data
	 */
	public String toString() {
		return "UDPService::channel: " + _channel;
	}

    private static class MLImpl implements MessageListener {
        public boolean _gotIncoming = false;

        public void processMessage(Message m, ReplyHandler handler) {
            if ((m instanceof PingRequest))
                _gotIncoming = true;
        }
        
        public void registered(byte[] guid) {}
        public void unregistered(byte[] guid) {}
    }

    private class IncomingValidator implements Runnable {
        public IncomingValidator() {}
        public void run() {
            // clear and revalidate if 1) we haven't had in incoming in an hour
            // or 2) we've never had incoming and we haven't checked in an hour
            final long currTime = System.currentTimeMillis();
            final MessageRouter mr = RouterService.getMessageRouter();
            final ConnectionManager cm = RouterService.getConnectionManager();
            // if these haven't been created yet, exit and wait till they have.
            if(mr == null || cm == null)
                return;
            if (
                (_acceptedUnsolicitedIncoming && //1)
                 ((currTime - _lastUnsolicitedIncomingTime) > 
                  Acceptor.INCOMING_EXPIRE_TIME)) 
                || 
                (!_acceptedUnsolicitedIncoming && //2)
                 ((currTime - _lastConnectBackTime) > 
                  Acceptor.INCOMING_EXPIRE_TIME))
                ) {
                
                final GUID cbGuid = new GUID(GUID.makeGuid());
                final MLImpl ml = new MLImpl();
                mr.registerMessageListener(cbGuid.bytes(), ml);
                // send a connectback request to a few peers and clear
                if(cm.sendUDPConnectBackRequests(cbGuid))  {
                    _lastConnectBackTime = System.currentTimeMillis();
                    Runnable checkThread = new Runnable() {
                            public void run() {
                                if ((_acceptedUnsolicitedIncoming && 
                                     (_lastUnsolicitedIncomingTime < currTime))
                                    || (!_acceptedUnsolicitedIncoming)) {
                                    // we set according to the message listener
                                    _acceptedUnsolicitedIncoming = 
                                        ml._gotIncoming;
                                }
                                mr.unregisterMessageListener(cbGuid.bytes(), ml);
                            }
                        };
                    RouterService.schedule(checkThread, 
                                           Acceptor.WAIT_TIME_AFTER_REQUESTS,
                                           0);
                }
                else
                    mr.unregisterMessageListener(cbGuid.bytes(), ml);
            }
        }
    }

    private class PeriodicPinger implements Runnable {
        public void run() {
            // straightforward - send a UDP ping to a host.  it doesn't really
            // matter who the guy is - we are just sending to open up any
            // potential firewall to UDP traffic
            GUESSEndpoint ep = QueryUnicaster.instance().getUnicastEndpoint();
            if (ep == null) return;
            // only do this if you can receive some form of UDP traffic.
            if (!canReceiveSolicited() && !canReceiveUnsolicited()) return;

            // good to use the solicited guid
            PingRequest pr = new PingRequest(getSolicitedGUID().bytes(),
                                             (byte)1, (byte)0);
            
            pr.addIPRequest();
            send(pr, ep.getAddress(), ep.getPort());
        }
    }

}
