package net.java.sip.communicator.impl.netaddr;

import net.java.sip.communicator.util.*;
import java.net.*;
import net.java.stun4j.*;
import net.java.stun4j.stack.*;
import net.java.stun4j.message.*;
import net.java.stun4j.attribute.*;

/**
 * Runs a separate thread of diagnostics for a given network address. The
 * diagnostics thread would discover NAT bindings through stun, update bindings
 * lifetime test connectivity and etc.
 *
 * @author Emil Ivov
 */
public class AddressDiagnosticsKit
        extends Thread
{
    private static final Logger logger =
        Logger.getLogger(AddressDiagnosticsKit.class);

    public static final int DIAGNOSTICS_STATUS_OFF = 1;
    public static final int DIAGNOSTICS_STATUS_DISOVERING_CONFIG = 2;
    public static final int DIAGNOSTICS_STATUS_RESOLVING = 3;
    public static final int DIAGNOSTICS_STATUS_COMPLETED = 4;
    public static final int DIAGNOSTICS_STATUS_DISOVERING_BIND_LIFETIME = 5;
    public static final int DIAGNOSTICS_STATUS_TERMINATED = 6;

    private int diagnosticsStatus = DIAGNOSTICS_STATUS_OFF;

    /**
     * These are used by (to my knowledge) mac and windows boxes when dhcp
     * fails and are only usable with other boxes using the same address
     * in the same net segment. That's why they get their low preference.
     */
    private static final AddressPreference ADDR_PREF_LOCAL_IPV4_AUTOCONF
        = new AddressPreference(40);

    /**
     * Local IPv6 addresses are assigned by default to any network iface running
     * an ipv6 stack. Theya are one of our last resorts since an internet
     * connected node would have generally configured sth else as well.
     */
    private static final AddressPreference ADDR_PREF_LOCAL_IPV6
        = new AddressPreference(40);

    /**
     * Local IPv4 addresses are either assigned by DHCP or manually configured
     * which means that even if they're unresolved to a globally routable
     * address they're still there for a reason (let the reason be ...) and this
     * reason might very well be purposeful so they should get a preference
     * higher than local IPv6 (even though I'm an IPv6 fan :) )
     */
    private static final AddressPreference ADDR_PREF_PRIVATE_IPV4
        = new AddressPreference(50);

    /**
     * Global IPv4 Addresses are a good think when they work. We are therefore
     * setting a high preference that will then be corrected by.
     */
    private static final AddressPreference ADDR_PREF_GLOBAL_IPV4
        = new AddressPreference(60);

    /**
     * There are many reasons why global IPv6 addresses should have the highest
     * preference. A global IPv6 address is most often delivered through
     * stateless address autoconfiguration which means an active router and
     * might also mean an active net connection.
     */
    private static final AddressPreference ADDR_PREF_GLOBAL_IPV6
        = new AddressPreference(70);

    /**
     * The address of the stun server to query
     */
    private StunAddress primaryStunServerAddress =
        new StunAddress("stun01.sipphone.com", 3478);

    /**
     * The address pool entry that this kit is diagnosing.
     */
    private AddressPoolEntry addressEntry = null;

    /**
     * Specifies whether stun should be used or not.
     * This field is updated during runtime to conform to the configuration.
     */
    private boolean useStun = true;

    private StunClient stunClient = null;

    /**
     * The port to be used locally for sending generic stun queries.
     */
    static final int LOCAL_STUN_PORT = 55126;
    private int bindRetries = 10;

    public AddressDiagnosticsKit(AddressPoolEntry      addressEntry)
    {
        this.addressEntry = addressEntry;
        setDiagnosticsStatus(DIAGNOSTICS_STATUS_OFF);
    }

    /**
     * Sets the current status of the address diagnostics process
     * @param status int
     */
    private void setDiagnosticsStatus(int status)
    {
        this.diagnosticsStatus = status;
    }

    /**
     * Returns the current status of this diagnosics process.
     * @return int
     */
    public int getDiagnosticsStatus()
    {
        return this.diagnosticsStatus;
    }

    /**
     * The diagnostics code itself.
     */
    public void run()
    {
        logger.debug("Started a diag kit for entry: " + addressEntry);

        //implements the algorithm from AssigningAddressPreferences.png

        setDiagnosticsStatus(this.DIAGNOSTICS_STATUS_DISOVERING_CONFIG);

        InetAddress address = addressEntry.getInetAddress();

        //is this an ipv6 address
        if (addressEntry.isIPv6())
        {
            if (addressEntry.isLinkLocal())
            {
                addressEntry.setAddressPreference(ADDR_PREF_LOCAL_IPV6);
                setDiagnosticsStatus(DIAGNOSTICS_STATUS_TERMINATED);
                return;
            }

            if (addressEntry.is6to4())
            {
                //right now we don't support these. we should though ... one day
                addressEntry.setAddressPreference(AddressPreference.MIN);
                setDiagnosticsStatus(DIAGNOSTICS_STATUS_TERMINATED);
                return;
            }

            //if we get here then we are a globally routable ipv6 addr
            addressEntry.setAddressPreference(ADDR_PREF_GLOBAL_IPV6);
            setDiagnosticsStatus(DIAGNOSTICS_STATUS_COMPLETED);
            //should do some connectivity testing here and proceed with firewall
            //discovery but since stun4j does not support ipv6 yet, this too
            //will happen another day.
            return;
        }

        //from now on we're only dealing with IPv4
        if (addressEntry.isIPv4LinkLocalAutoconf())
        {
            //not sure whether these are used for anything.
            addressEntry.setAddressPreference(AddressPreference.MIN);
            setDiagnosticsStatus(DIAGNOSTICS_STATUS_TERMINATED);
            return;
        }

        //first try and see what we can infer from just looking at the
        //address
        if (addressEntry.isLinkLocalIPv4Address())
        {
            addressEntry.setAddressPreference(ADDR_PREF_PRIVATE_IPV4);
        }
        else
        {
            //public address
            addressEntry.setAddressPreference(ADDR_PREF_GLOBAL_IPV4);
        }

        if (!useStun)
        {
            //if we're configured not to run stun - we're done.
            setDiagnosticsStatus(DIAGNOSTICS_STATUS_TERMINATED);
            return;
        }

        //start stunning
        for(int i = 0; i < bindRetries; i++){
            StunAddress localStunAddress = new StunAddress(
                    address, 1024 + (int) (Math.random() * 64512));
            try
            {

                stunClient = new StunClient(localStunAddress);
                stunClient.start();
                logger.debug("Successfully started StunClient for  "
                                 + localStunAddress + ".");
                break;
            }
            catch (StunException ex)
            {
                if (ex.getCause() instanceof SocketException
                    && i < bindRetries)
                {
                    logger.debug("Failed to bind to "
                                 + localStunAddress + ". Retrying ...");
                    logger.debug("Exception was ", ex);
                    continue;
                }
                logger.error("Failed to start a stun client for address entry ["
                             + addressEntry.toString()+"]:"
                             +localStunAddress.getPort() + ". Ceasing attempts",
                             ex);
                setDiagnosticsStatus(DIAGNOSTICS_STATUS_TERMINATED);
                return;
            }
        }
        //De Stun Test I
        StunMessageEvent event = null;
        try
        {
            event = stunClient.doStunTestI(
                primaryStunServerAddress);
        }
        catch (StunException ex)
        {
            logger.error("Failed to perform STUN Test I for address entry"
                + addressEntry.toString(), ex);
            setDiagnosticsStatus(DIAGNOSTICS_STATUS_TERMINATED);
            stunClient.shutDown();
            return;
        }

        if(event == null)
        {
            //didn't get a response - we either don't have connectivity or the
            //server is down
            /** @todo if possible try another stun server here. we should
             * support multiple stun servers*/
            logger.debug("There seems to be no inet connectivity for "
                         + addressEntry);
            setDiagnosticsStatus(DIAGNOSTICS_STATUS_TERMINATED);
            stunClient.shutDown();
            logger.debug("stun test 1 failed");
            return;
        }

        //the moment of the truth - are we behind a NAT?
        boolean isPublic;
        Message stunResponse = event.getMessage();

        Attribute mappedAttr = stunResponse.getAttribute(Attribute.MAPPED_ADDRESS);

        StunAddress mappedAddrFromTestI = ((MappedAddressAttribute)mappedAttr).getAddress();
        Attribute changedAddressAttributeFromTestI
            = stunResponse.getAttribute(Attribute.CHANGED_ADDRESS);
        StunAddress secondaryStunServerAddress =
            ((ChangedAddressAttribute)changedAddressAttributeFromTestI).
                getAddress();

        /** @todo verify whether the stun server returned the same address for
         * the primary and secondary server and act accordingly
         * */

        if(mappedAddrFromTestI == null){
            logger.error(
                "Stun Server did not return a mapped address for entry "
                + addressEntry.toString());
            setDiagnosticsStatus(DIAGNOSTICS_STATUS_TERMINATED);
            return;
        }

        if(mappedAddrFromTestI.equals(event.getSourceAccessPoint().getAddress()))
        {
            isPublic = true;
        }
        else
        {
            isPublic = false;
        }

        //do STUN Test II
        try
        {
            event = stunClient.doStunTestII(primaryStunServerAddress);
        }
        catch (StunException ex)
        {
            logger.error("Failed to perform STUN Test II for address entry"
                + addressEntry.toString(), ex);
            setDiagnosticsStatus(DIAGNOSTICS_STATUS_TERMINATED);
            stunClient.shutDown();
            logger.debug("stun test 2 failed");
            return;
        }

        if(event != null){
            logger.error("Secondary STUN server is down"
                         + addressEntry.toString());
            setDiagnosticsStatus(DIAGNOSTICS_STATUS_TERMINATED);
            stunClient.shutDown();
            return;
        }

        //might mean that either the secondary stun server is down
        //or that we are behind a restrictive firewall. Let's find out
        //which.
        try
        {
            event = stunClient.doStunTestI(secondaryStunServerAddress);
            logger.debug("stun test 1 succeeded with s server 2");
        }
        catch (StunException ex)
        {
            logger.error("Failed to perform STUN Test I for address entry"
                         + addressEntry.toString(), ex);
            setDiagnosticsStatus(DIAGNOSTICS_STATUS_TERMINATED);
            stunClient.shutDown();
            return;
        }

        if (event == null)
        {
            //secondary stun server is down
            logger.error("Secondary STUN server is down"
                         + addressEntry.toString());
            setDiagnosticsStatus(DIAGNOSTICS_STATUS_TERMINATED);
            stunClient.shutDown();
            return;
        }

        //we are at least behind a port restricted nat

        stunResponse = event.getMessage();
        mappedAttr = stunResponse.getAttribute(Attribute.MAPPED_ADDRESS);
        StunAddress mappedAddrFromSecServer =
            ((MappedAddressAttribute)mappedAttr).getAddress();

        if(!mappedAddrFromTestI.equals(mappedAddrFromSecServer))
        {
            //secondary stun server is down
            logger.debug("We are behind a symmetric nat"
                         + addressEntry.toString());
            setDiagnosticsStatus(DIAGNOSTICS_STATUS_TERMINATED);
            stunClient.shutDown();
            return;
        }

        //now let's run test III so that we could guess whether or not we're
        //behind a port restricted nat/fw or simply a restricted one.
        try
        {
            event = stunClient.doStunTestIII(primaryStunServerAddress);
            logger.debug("stun test 3 succeeded with s server 1");
        }
        catch (StunException ex)
        {
            logger.error("Failed to perform STUN Test III for address entry"
                         + addressEntry.toString(), ex);
            setDiagnosticsStatus(DIAGNOSTICS_STATUS_TERMINATED);
            stunClient.shutDown();
            return;
        }

        if (event == null)
        {
            logger.debug("We are behind a port restricted NAT or fw"
                         + addressEntry.toString());
            setDiagnosticsStatus(DIAGNOSTICS_STATUS_TERMINATED);
            stunClient.shutDown();
            return;
        }

        logger.debug("We are behind a restricted NAT or fw"
                     + addressEntry.toString());
        setDiagnosticsStatus(DIAGNOSTICS_STATUS_TERMINATED);
        stunClient.shutDown();
    }
}

