package com.limegroup.gnutella;


import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;

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 org.limewire.concurrent.ThreadExecutor;
import org.limewire.inspection.Inspectable;
import org.limewire.inspection.InspectablePrimitive;
import org.limewire.inspection.InspectionPoint;
import org.limewire.io.NetworkUtils;
import org.limewire.service.ErrorService;

import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import com.limegroup.gnutella.settings.ApplicationSettings;
import com.limegroup.gnutella.settings.ConnectionSettings;


/**
 * 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().
 * 
 */
@Singleton
public class UPnPManager  {
    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;
	
	/** 
	 * 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 final LifecycleManager lifecycleManager;
	private final Provider<Acceptor> acceptor;
	
	private final AtomicBoolean started = new AtomicBoolean(false);
	
	private final ControlPoint controlPoint;
	
	private final CopyOnWriteArrayList<UPnPListener> listeners = new CopyOnWriteArrayList<UPnPListener>();
	
	@InspectablePrimitive("upnp manager creation time")
	@SuppressWarnings("unused")
	private final long creationTime = System.currentTimeMillis();
	@InspectablePrimitive("upnp manager start time")
	@SuppressWarnings("unused")
	private volatile long startTime;
	@InspectablePrimitive("upnp manager device found time")
	@SuppressWarnings("unused")
	private volatile long deviceFoundTime;
	
	@Inject
	UPnPManager(LifecycleManager lifecycleManager, Provider<Acceptor> acceptor) {
	    this.lifecycleManager = lifecycleManager;	
	    this.acceptor = acceptor;
	    this.controlPoint = new ControlPoint();
    }
	
	public void addListener(UPnPListener uPnPListener) {
	    listeners.add(uPnPListener);
	}
	
	private void notifyListeners() {
	    for(UPnPListener listener : listeners) 
	        listener.natFound();
	}
    
    public void start() {
        if (!started.getAndSet(true)) {
            startTime = System.currentTimeMillis();
            LOG.debug("Starting UPnP Manager.");
            controlPoint.addDeviceChangeListener(new DeviceListener());

            synchronized (DEVICE_LOCK) {
                try {
                    controlPoint.start();
                } catch (Exception bad) {
                    ConnectionSettings.DISABLE_UPNP.setValue(true);
                    ErrorService.error(bad);
                }
            }
        }
    }
    
    public void stop() {
        controlPoint.stop();
    }
	
	/**
     * @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());
	}
	
	/**
	 * 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(acceptor.get().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
        ThreadExecutor.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") {
		    @Override
            public void run() {
                Thread cleaner = new Thread("UPnP Cleaner") {
        			@Override
                    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");
		    }
		};
		
        lifecycleManager.addShutdownItem(waiter); 
	}
	
	@Override
    public void finalize() {
		stop();
	}

	private String getGUIDSuffix() {
	    synchronized(DEVICE_LOCK) {
    	    if (_guidSuffix == null)
    			_guidSuffix = ApplicationSettings.CLIENT_ID.getValue().substring(0,10);
    	    return _guidSuffix;
        }
	}
	
	private class DeviceListener implements DeviceChangeListener {
        /** this method will be called when we discover a UPnP device. */
        public void deviceAdded(Device dev) {
            if (isNATPresent())
                return;
            deviceFoundTime = System.currentTimeMillis();
            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) {
                    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);
                        stop();
                    }
                } else {
                    LOG.debug("didn't get router device");
                }
            }
            
            if(isNATPresent())
                notifyListeners();
        }
    	
    	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;
		}
		
		@Override
        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(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<Mapping> mappings = new HashSet<Mapping>();
			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(Mapping current : mappings) {
				if(LOG.isDebugEnabled())
				    LOG.debug("Analyzing: " + current);
				    
				if(current._description == null || current._internalAddress == 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);
				}
			}
		}
	}
    
    @InspectionPoint("upnp stats")
    @SuppressWarnings("unused")
	private final Inspectable UPnPStats = new Inspectable() {
	    public Object inspect() {
	        if (!isNATPresent())
	            return "N/A";
	        Map<String, Object> ret = new HashMap<String,Object>();
	        ret.put("name",_router.getFriendlyName());
	        if (mappingsExist())
	            ret.put("mappings", true);
	        return ret;
	    }
	};
}
