package com.limegroup.gnutella;


import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Random;
import java.util.Set;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.cybergarage.upnp.Action;
import org.cybergarage.upnp.Argument;
import org.cybergarage.upnp.ControlPoint;
import org.cybergarage.upnp.Device;
import org.cybergarage.upnp.DeviceList;
import org.cybergarage.upnp.Service;
import org.cybergarage.upnp.device.DeviceChangeListener;

import com.limegroup.gnutella.settings.ApplicationSettings;
import com.limegroup.gnutella.settings.ConnectionSettings;
import com.limegroup.gnutella.util.ManagedThread;
import com.limegroup.gnutella.util.NetworkUtils;
import com.limegroup.gnutella.util.ThreadFactory;


/**
 * Manages the mapping of ports to limewire on UPnP-enabled routers.  
 * 
 * According to the UPnP Standards, Internet Gateway Devices must have a
 * specific hierarchy.  The parts of that hierarchy that we care about are:
 * 
 * Device: urn:schemas-upnp-org:device:InternetGatewayDevice:1
 * 	 SubDevice: urn:schemas-upnp-org:device:WANDevice:1
 *     SubDevice: urn:schemas-upnp-org:device:WANConnectionDevice:1
 *        Service: urn:schemas-upnp-org:service:WANIPConnection:1
 * 
 * Every port mapping is a tuple of:
 *  - External address ("" is wildcard)
 *  - External port
 *  - Internal address
 *  - Internal port
 *  - Protocol (TCP|UDP)
 *  - Description
 * 
 * Port mappings can be removed, but that is a blocking network operation which will
 * slow down the shutdown process of Limewire.  It is safe to let port mappings persist 
 * between limewire sessions. In the meantime however, the NAT may assign a different 
 * ip address to the local node.  That's why we need to find any previous mappings 
 * the node has created and update them with our new address. In order to uniquely 
 * distinguish which mappings were made by us, we put part of our client GUID in the 
 * description field.  
 * 
 * For the TCP mapping, we use the following description: "Lime/TCP:<cliengGUID>"
 * For the UDP mapping, we use "Lime/UDP:<clientGUID>"
 * 
 * NOTES:
 * 
 * Not all NATs support mappings with different external port and internal ports. Therefore
 * if we were unable to map our desired port but were able to map another one, we should
 * pass this information on to Acceptor. 
 * 
 * Some buggy NATs do not distinguish mappings by the Protocol field.  Therefore
 * we first map the UDP port, and then the TCP port since it is more important should the
 * first mapping get overwritten.
 * 
 * The cyberlink library uses an internal thread that tries to discover any UPnP devices.  
 * After we discover a router or give up on trying to, we should call stop().
 * 
 */
public class UPnPManager extends ControlPoint implements DeviceChangeListener {

    private static final Log LOG = LogFactory.getLog(UPnPManager.class);
	
	/** some schemas */
	private static final String ROUTER_DEVICE= 
		"urn:schemas-upnp-org:device:InternetGatewayDevice:1";
	private static final String WAN_DEVICE = 
		"urn:schemas-upnp-org:device:WANDevice:1";
	private static final String WANCON_DEVICE=
		"urn:schemas-upnp-org:device:WANConnectionDevice:1";
	private static final String SERVICE_TYPE = 
		"urn:schemas-upnp-org:service:WANIPConnection:1";
	
	/** prefixes and a suffix for the descriptions of our TCP and UDP mappings */
	private static final String TCP_PREFIX = "LimeTCP";
	private static final String UDP_PREFIX = "LimeUDP";
	private String _guidSuffix;
	
	/** amount of time to wait while looking for a NAT device. */
    private static final int WAIT_TIME = 3 * 1000; // 3 seconds
	
	private static final UPnPManager INSTANCE = new UPnPManager();

	public static UPnPManager instance() {
		return INSTANCE;
	}
	
	/** 
	 * the router we have and the sub-device necessary for port mapping 
	 *  LOCKING: DEVICE_LOCK
	 */
	private volatile Device _router;
	
	/**
	 * The port-mapping service we'll use.  LOCKING: DEVICE_LOCK
	 */
	private volatile Service _service;
	
	/** The tcp and udp mappings created this session */
	private volatile Mapping _tcp, _udp;
	
	/**
	 * Lock that everything uses.
	 */
	private final Object DEVICE_LOCK = new Object();
	
	private UPnPManager() {
	    super();
	    
        addDeviceChangeListener(this);
    }
    
    public boolean start() {
	    LOG.debug("Starting UPnP Manager.");
	    

        synchronized(DEVICE_LOCK) {
            try {
    	        return super.start();
    	    } catch(Exception bad) {
    	        ConnectionSettings.DISABLE_UPNP.setValue(true);
    	        ErrorService.error(bad);
    	        return false;
    	    }
        }
	}
	
	/**
	 * @return whether we are behind an UPnP-enabled NAT/router
	 */
	public boolean isNATPresent() {
	    return _router != null && _service != null;
	}

	/**
	 * @return whether we have created mappings this session
	 */
	public boolean mappingsExist() {
	    return _tcp != null || _udp != null;
	}
	
	/**
	 * @return the external address the NAT thinks we have.  Blocking.
	 * null if we can't find it.
	 */
	public InetAddress getNATAddress() throws UnknownHostException {
		
        if (!isNATPresent())
            return null;
        
        Action getIP = _service.getAction("GetExternalIPAddress");
		if(getIP == null) {
		    LOG.debug("Couldn't find GetExternalIPAddress action!");
		    return null;
		}
		    
		
		if (!getIP.postControlAction()) {
			LOG.debug("couldn't get our external address");
			return null;
		}
		
		Argument ret = getIP.getOutputArgumentList().getArgument("NewExternalIPAddress");
		return InetAddress.getByName(ret.getValue());
	}
	
	/**
	 * Waits for a small amount of time before the device is discovered.
	 */
	public void waitForDevice() {
        if(isNATPresent())
            return;
	    synchronized(DEVICE_LOCK) {
    	    // already have it.
            // otherwise, wait till we grab it.
            try {
                DEVICE_LOCK.wait(WAIT_TIME);
            } catch(InterruptedException ie) {}
        }
        
    }
	
	/**
	 * this method will be called when we discover a UPnP device.
	 */
	public void deviceAdded(Device dev) {
        if (isNATPresent())
            return;
	    synchronized(DEVICE_LOCK) {
            if(LOG.isTraceEnabled())
                LOG.trace("Device added: " + dev.getFriendlyName());
    		
    		// did we find a router?
    		if (dev.getDeviceType().equals(ROUTER_DEVICE) && dev.isRootDevice())
    			_router = dev;
    		
    		if (_router == null) {
    			LOG.debug("didn't get router device");
    			return;
    		}
    		
    		discoverService();
    		
    		// did we find the service we need?
    		if (_service == null) {
    			LOG.debug("couldn't find service");
    			_router=null;
    		} else {
    		    if(LOG.isDebugEnabled())
    		        LOG.debug("Found service, router: " + _router.getFriendlyName() + ", service: " + _service);
                DEVICE_LOCK.notify();
    			stop();
    		}
        }
	}
	
	/**
	 * Traverses the structure of the router device looking for 
	 * the port mapping service.
	 */
	private void discoverService() {
		
		for (Iterator iter = _router.getDeviceList().iterator();iter.hasNext();) {
			Device current = (Device)iter.next();
			if (!current.getDeviceType().equals(WAN_DEVICE))
				continue;
			
			DeviceList l = current.getDeviceList();
			if (LOG.isDebugEnabled())
				LOG.debug("found "+current.getDeviceType()+", size: "+l.size() + ", on: " + current.getFriendlyName());
			
			for (int i=0;i<current.getDeviceList().size();i++) {
				Device current2 = l.getDevice(i);
				
				if (!current2.getDeviceType().equals(WANCON_DEVICE))
					continue;
			
				if (LOG.isDebugEnabled())
					LOG.debug("found "+current2.getDeviceType() + ", on: " + current2.getFriendlyName());
				
				_service = current2.getService(SERVICE_TYPE);
				return;
			}
		}
	}
	
	/**
	 * adds a mapping on the router to the specified port
	 * @return the external port that was actually mapped. 0 if failed
	 */
	public int mapPort(int port) {
	    if(LOG.isTraceEnabled())
	        LOG.trace("Attempting to map port: " + port);
		
		Random gen=null;
		
		String localAddress = NetworkUtils.ip2string(
				RouterService.getAcceptor().getAddress(false));
		int localPort = port;
	
		// try adding new mappings with the same port
		Mapping udp = new Mapping("",
				port,
				localAddress,
				localPort,
				"UDP",
				UDP_PREFIX + getGUIDSuffix());
		
		// add udp first in case it gets overwritten.
		// if we can't add, update or find an appropriate port
		// give up after 20 tries
		int tries = 20;
		while (!addMapping(udp)) {
			if (tries<0)
				break;
			tries--;
			
			// try a random port
			if (gen == null)
				gen = new Random();
			port = gen.nextInt(50000)+2000;
			udp = new Mapping("",
					port,
					localAddress,
					localPort,
					"UDP",
					UDP_PREFIX + getGUIDSuffix());
		}
		
		if (tries < 0) {
			LOG.debug("couldn't map a port :(");
			return 0;
		}
		
		// at this stage, the variable port will point to the port the UDP mapping
		// got mapped to.  Since we have to have the same port for UDP and tcp,
		// we can't afford to change the port here.  So if mapping to this port on tcp
		// fails, we give up and clean up the udp mapping.
		// Note: Phillipe reported that on some routers adding an UDP mapping will also
		// create a TCP mapping.  So we no longer delete the UDP mapping if the TCP one 
		// fails.
		Mapping tcp = new Mapping("",
				port,
				localAddress,
				localPort,
				"TCP",
				TCP_PREFIX + getGUIDSuffix());
		if (!addMapping(tcp)) {
			LOG.debug(" couldn't map tcp to whatever udp was mapped. leaving udp around...");
			tcp = null;
		}
		
		// save a ref to the mappings
		synchronized(DEVICE_LOCK) {
			_tcp = tcp;
			_udp = udp;
		}
		
		// we're good - start a thread to clean up any potentially stale mappings
        ThreadFactory.startThread(new StaleCleaner(), "Stale Mapping Cleaner");
		return port;
	}
	
	/**
	 * @param m Port mapping to send to the NAT
	 * @return the error code
	 */
	private boolean addMapping(Mapping m) {
		
		if (LOG.isDebugEnabled())
			LOG.debug("adding "+m);
		
		Action add = _service.getAction("AddPortMapping");
		
		if(add == null) {
		    LOG.debug("Couldn't find AddPortMapping action!");
		    return false;
		}
		    
		
		add.setArgumentValue("NewRemoteHost",m._externalAddress);
		add.setArgumentValue("NewExternalPort",m._externalPort);
		add.setArgumentValue("NewInternalClient",m._internalAddress);
		add.setArgumentValue("NewInternalPort",m._internalPort);
		add.setArgumentValue("NewProtocol",m._protocol);
		add.setArgumentValue("NewPortMappingDescription",m._description);
		add.setArgumentValue("NewEnabled","1");
		add.setArgumentValue("NewLeaseDuration",0);
		
		boolean success = add.postControlAction();
		if(LOG.isTraceEnabled())
		    LOG.trace("Post succeeded: " + success);
		return success;
	}
	
	/**
	 * @param m the mapping to remove from the NAT
	 * @return whether it worked or not
	 */
	private boolean removeMapping(Mapping m) {
		
		if (LOG.isDebugEnabled())
			LOG.debug("removing "+m);
		
		Action remove = _service.getAction("DeletePortMapping");
		
		if(remove == null) {
		    LOG.debug("Couldn't find DeletePortMapping action!");
		    return false;
	    }
		
		remove.setArgumentValue("NewRemoteHost",m._externalAddress);
		remove.setArgumentValue("NewExternalPort",m._externalPort);
		remove.setArgumentValue("NewProtocol",m._protocol);
		
		boolean success = remove.postControlAction();
		if(LOG.isDebugEnabled())
		    LOG.debug("Remove succeeded: " + success);
		return success;
	}

	/**
	 * schedules a shutdown hook which will clear the mappings created
	 * this session. 
	 */
	public void clearMappingsOnShutdown() {
		final Mapping tcp, udp;
		synchronized(DEVICE_LOCK) {
			tcp = _tcp;
			udp = _udp;
		}
		
		Thread waiter = new Thread("UPnP Waiter") {
		    public void run() {
                Thread cleaner = new Thread("UPnP Cleaner") {
        			public void run() {
        				LOG.debug("start cleaning");
        				if (tcp != null) removeMapping(tcp);
        				if (udp != null) removeMapping(udp);
        				LOG.debug("done cleaning");
        			}
        		};
        		cleaner.setDaemon(true);
        		cleaner.start();
        		Thread.yield();
		        
		        try {
		            LOG.debug("waiting for UPnP cleaners to finish");
		            cleaner.join(30000); // wait at most 30 seconds.
		        } catch(InterruptedException ignored){}
		        LOG.debug("UPnP cleaners done");
		    }
		};
		
        RouterService.addShutdownItem(waiter);
	}
	
	public void finalize() {
		stop();
	}

	private String getGUIDSuffix() {
	    synchronized(DEVICE_LOCK) {
    	    if (_guidSuffix == null)
    			_guidSuffix = ApplicationSettings.CLIENT_ID.getValue().substring(0,10);
    	    return _guidSuffix;
        }
	}
	/**
	 * stub 
	 */
	public void deviceRemoved(Device dev) {}
	
	private final class Mapping {
		public final String _externalAddress;
		public final int _externalPort;
		public final String _internalAddress;
		public final int _internalPort;
		public final String _protocol,_description;
		
		// network constructor
		public Mapping(String externalAddress,String externalPort,
				String internalAddress, String internalPort,
				String protocol, String description) throws NumberFormatException{
			_externalAddress=externalAddress;
			_externalPort=Integer.parseInt(externalPort);
			_internalAddress=internalAddress;
			_internalPort=Integer.parseInt(internalPort);
			_protocol=protocol;
			_description=description;
		}
		
		// internal constructor
		public Mapping(String externalAddress,int externalPort,
				String internalAddress, int internalPort,
				String protocol, String description) {

			if ( !NetworkUtils.isValidPort(externalPort) ||
				!NetworkUtils.isValidPort(internalPort))
			    throw new IllegalArgumentException();

			_externalAddress=externalAddress;
			_externalPort=externalPort;
			_internalAddress=internalAddress;
			_internalPort=internalPort;
			_protocol=protocol;
			_description=description;
		}
		
		public String toString() {
			return _externalAddress+":"+_externalPort+"->"+_internalAddress+":"+_internalPort+
				"@"+_protocol+" desc: "+_description;
		}
		
	}
	
	/**
	 * This thread reads all the existing mappings on the NAT and if it finds
	 * a mapping which appears to be created by us but points to a different
	 * address (i.e. is stale) it removes the mapping.
	 * 
	 * It can take several minutes to finish, depending on how many mappings there are.  
	 */
	private class StaleCleaner implements Runnable {
	    
	    // TODO: remove
	    private String list(java.util.List l) {
	        String s = "";
	        for(Iterator i = l.iterator(); i.hasNext(); ) {
	            Argument next = (Argument)i.next();
	            s += next.getName() + "->" + next.getValue() + ", ";
	        }
	        return s;
	    }
	    
		public void run() {
		    
		    LOG.debug("Looking for stale mappings...");
		    
			Set mappings = new HashSet();
			Action getGeneric = _service.getAction("GetGenericPortMappingEntry");
			
			if(getGeneric == null) {
			    LOG.debug("Couldn't find GetGenericPortMappingEntry action!");
			    return;
			}
			
			// get all the mappings
			try {
				for (int i=0;;i++) {
    				getGeneric.setArgumentValue("NewPortMappingIndex",i);
    				if(LOG.isDebugEnabled())
				        LOG.debug("Stale Iteration: " + i + ", generic.input: " + list(getGeneric.getInputArgumentList()) + ", generic.output: " + list(getGeneric.getOutputArgumentList()));
					
					if (!getGeneric.postControlAction())
						break;
					
					mappings.add(new Mapping(
							getGeneric.getArgumentValue("NewRemoteHost"),
							getGeneric.getArgumentValue("NewExternalPort"),
							getGeneric.getArgumentValue("NewInternalClient"),
							getGeneric.getArgumentValue("NewInternalPort"),
							getGeneric.getArgumentValue("NewProtocol"),
							getGeneric.getArgumentValue("NewPortMappingDescription")));
				    // TODO: erase output arguments.
				
				}
			}catch(NumberFormatException bad) {
			    LOG.error("NFE reading mappings!", bad);
				//router broke.. can't do anything.
				return;
			}
			
			if (LOG.isDebugEnabled())
				LOG.debug("Stale cleaner found "+mappings.size()+ " total mappings");
			
			// iterate and clean up
			for (Iterator iter = mappings.iterator();iter.hasNext();) {
				Mapping current = (Mapping)iter.next();
				if(LOG.isDebugEnabled())
				    LOG.debug("Analyzing: " + current);
				    
				if(current._description == null)
				    continue;
				
				// does it have our description?
				if (current._description.equals(TCP_PREFIX+getGUIDSuffix()) ||
						current._description.equals(UDP_PREFIX+getGUIDSuffix())) {
					
					// is it not the same as the mappings we created this session?
					synchronized(DEVICE_LOCK) {
						
						if (_udp != null &&
								current._externalPort == _udp._externalPort &&
								current._internalAddress.equals(_udp._internalAddress) &&
								current._internalPort == _udp._internalPort)
							continue;
					}
					
					// remove it.
					if (LOG.isDebugEnabled())
						LOG.debug("mapping "+current+" appears to be stale");
					removeMapping(current);
				}
			}
		}
	}
}
