/* This code is part of Freenet. It is distributed under the GNU General
 * Public License, version 2 (or at your option any later version). See
 * http://www.gnu.org/ for further details of the GPL. */
package freenet.io.comm;

import java.io.DataInput;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Arrays;

import freenet.io.AddressIdentifier;
import freenet.support.LogThresholdCallback;
import freenet.support.Logger;
import freenet.support.Logger.LogLevel;
import freenet.support.io.InetAddressIpv6FirstComparator;
import freenet.support.transport.ip.HostnameSyntaxException;
import freenet.support.transport.ip.HostnameUtil;
import freenet.support.transport.ip.IPUtil;

/**
 * Long-term InetAddress. If created with an IP address, then the IP address is primary.
 * If created with a name, then the name is primary, and the IP address can change.
 * Most code ripped from Peer.
 * 
 * Propagates the IP address on equals() but not the hostname. This does not change 
 * hashCode() because it only happens if hostname is set, and in that case, hashCode()
 * is based on the hostname and not on the IP address. So it is safe to put 
 * FreenetInetAddress's into hashtables: neither equals() nor getAddress() will change
 * its hashCode.
 * 
 * BUT a FreenetInetAddress with IP 1.2.3.4 and no hostname is *NOT* equal to one with
 * the IP address and no name. So if you want to match on only the IP address, you need
 * to either call dropHostname() first (after which neither propagation nor getAddress()
 * will change the hashcode), or just use InetAddress's.
 * 
 * FIXME reconsider whether we need this. The lazy lookup is useful but not THAT useful,
 * and we have a regular lookup task now anyway. Over-complex, could lead to odd bugs, 
 * although not if used correctly as explained above.
 * @author amphibian
 */
public class FreenetInetAddress {

	private static volatile boolean logMINOR;
	private static volatile boolean logDEBUG;

	static {
		Logger.registerLogThresholdCallback(new LogThresholdCallback(){
			@Override
			public void shouldUpdate(){
				logMINOR = Logger.shouldLog(LogLevel.MINOR, this);
				logDEBUG = Logger.shouldLog(LogLevel.DEBUG, this);
			}
		});
	}

	// hostname - only set if we were created with a hostname
	// and not an address
	private final String hostname;
	private InetAddress _address;

	/**
	 * Create from serialized form on a DataInputStream.
	 */
	public FreenetInetAddress(DataInput dis) throws IOException {
		int firstByte = dis.readUnsignedByte();
		byte[] ba;
		if(firstByte == 255) {
			if(logMINOR) Logger.minor(this, "New format IPv6 address");
			// New format IPv6 address
			ba = new byte[16];
			dis.readFully(ba);
		} else if(firstByte == 0) {
			if(logMINOR) Logger.minor(this, "New format IPv4 address");
			// New format IPv4 address
			ba = new byte[4];
			dis.readFully(ba);
		} else {
			throw new IOException("Unknown type byte (old form? corrupt stream? too short/long prev field?): "+firstByte);
		}
		_address = InetAddress.getByAddress(ba);
		String name = null;
		String s = dis.readUTF();
		if(!s.isEmpty())
			name = s;
		hostname = name;
	}

	/**
	 * Create from serialized form on a DataInputStream.
	 */
	public FreenetInetAddress(DataInput dis, boolean checkHostnameOrIPSyntax) throws HostnameSyntaxException,
	        IOException {
		int firstByte = dis.readUnsignedByte();
		byte[] ba;
		if(firstByte == 255) {
			if(logMINOR) Logger.minor(this, "New format IPv6 address");
			// New format IPv6 address
			ba = new byte[16];
			dis.readFully(ba);
		} else if(firstByte == 0) {
			if(logMINOR) Logger.minor(this, "New format IPv4 address");
			// New format IPv4 address
			ba = new byte[4];
			dis.readFully(ba);
		} else {
			// Old format IPv4 address
			ba = new byte[4];
			ba[0] = (byte)firstByte;
			dis.readFully(ba, 1, 3);
		}
		_address = InetAddress.getByAddress(ba);
		String name = null;
		String s = dis.readUTF();
		if(!s.isEmpty())
			name = s;
		hostname = name;
        if(checkHostnameOrIPSyntax && null != hostname) {
        	if(!HostnameUtil.isValidHostname(hostname, true)) throw new HostnameSyntaxException();
		}
	}

	/**
	 * Create from an InetAddress. The IP address is primary i.e. fixed.
	 * The hostname either doesn't exist, or is looked up.
	 */
	public FreenetInetAddress(InetAddress address) {
		_address = address;
		hostname = null;
	}

	public FreenetInetAddress(String host, boolean allowUnknown) throws UnknownHostException {
        InetAddress addr = null;
        if(host != null){
        	if(host.startsWith("/")) host = host.substring(1);
        	host = host.trim();
        }
        // if we were created with an explicit IP address, use it as such
        // debugging log messages because AddressIdentifier doesn't appear to handle all IPv6 literals correctly, such as "fe80::204:1234:dead:beef"
        AddressIdentifier.AddressType addressType = AddressIdentifier.getAddressType(host);
        if(logDEBUG) Logger.debug(this, "Address type of '"+host+"' appears to be '"+addressType+ '\'');
        if(addressType != AddressIdentifier.AddressType.OTHER) {
        	// Is an IP address
            addr = InetAddress.getByName(host);
            // Don't catch UnknownHostException here, if it happens there's a bug in AddressIdentifier.
            if(logDEBUG) Logger.debug(this, "host is '"+host+"' and addr.getHostAddress() is '"+addr.getHostAddress()+ '\'');
            if(addr != null) {
                host = null;
            } else {
                addr = null;
            }
        }
        if( addr == null ) {
        	if(logDEBUG) Logger.debug(this, '\'' +host+"' does not look like an IP address");
        }
        this._address = addr;
        this.hostname = host;
        // we're created with a hostname so delay the lookup of the address
        // until it's needed to work better with dynamic DNS hostnames
	}

	public FreenetInetAddress(String host, boolean allowUnknown, boolean checkHostnameOrIPSyntax) throws HostnameSyntaxException, UnknownHostException {
        InetAddress addr = null;
        if(host != null){
        	if(host.startsWith("/")) host = host.substring(1);
        	host = host.trim();
        }
        // if we were created with an explicit IP address, use it as such
        // debugging log messages because AddressIdentifier doesn't appear to handle all IPv6 literals correctly, such as "fe80::204:1234:dead:beef"
        AddressIdentifier.AddressType addressType = AddressIdentifier.getAddressType(host);
        if(logDEBUG) Logger.debug(this, "Address type of '"+host+"' appears to be '"+addressType+ '\'');
        if(addressType != AddressIdentifier.AddressType.OTHER) {
            try {
                addr = InetAddress.getByName(host);
            } catch (UnknownHostException e) {
            	if(!allowUnknown) throw e;
                addr = null;
            }
            if(logDEBUG) Logger.debug(this, "host is '"+host+"' and addr.getHostAddress() is '"+(addr != null ? addr.getHostAddress()+ '\'' : ""));
            if(addr != null && addr.getHostAddress().equals(host)) {
            	if(logDEBUG) Logger.debug(this, '\'' +host+"' looks like an IP address");
                host = null;
            } else {
                addr = null;
            }
        }
        if( addr == null ) {
        	if(logDEBUG) Logger.debug(this, '\'' +host+"' does not look like an IP address");
        }
        this._address = addr;
        this.hostname = host;
        if(checkHostnameOrIPSyntax && null != this.hostname) {
        	if(!HostnameUtil.isValidHostname(this.hostname, true)) throw new HostnameSyntaxException();
		}
        // we're created with a hostname so delay the lookup of the address
        // until it's needed to work better with dynamic DNS hostnames
	}
	
	public boolean laxEquals(FreenetInetAddress addr) {
		if(hostname != null) {
			if(addr.hostname == null) {
				if(_address == null) return false; // No basis for comparison.
				if(addr._address != null) {
					return _address.equals(addr._address);
				}
			} else {
				if (!hostname.equalsIgnoreCase(addr.hostname)) {
					return false;
				}
				// Now that we know we have the same hostname, we can propagate the IP.
				if((_address != null) && (addr._address == null))
					addr._address = _address;
				if((addr._address != null) && (_address == null))
					_address = addr._address;
				// Except if we actually do have two different looked-up IPs!
				if((addr._address != null) && (_address != null) && !addr._address.equals(_address))
					return false;
				// Equal.
				return true;
			}
		}
		// His hostname might not be null. Not a problem.
		return _address.equals(addr._address);
	}
	
	@Override
	public boolean equals(Object o) {
		if(!(o instanceof FreenetInetAddress)) {
			return false;
		}
		FreenetInetAddress addr = (FreenetInetAddress)o;
		if(hostname != null) {
			if(addr.hostname == null)
				return false;
			if (!hostname.equalsIgnoreCase(addr.hostname)) {
				return false;
			}
			// Now that we know we have the same hostname, we can propagate the IP.
			if((_address != null) && (addr._address == null))
				addr._address = _address;
			if((addr._address != null) && (_address == null))
				_address = addr._address;
			// Except if we actually do have two different looked-up IPs!
			if((addr._address != null) && (_address != null) && !addr._address.equals(_address))
				return false;
			// Equal.
			return true;
		}
		if(addr.hostname != null)
			return false;

		// No hostname, go by address.
		if(!_address.equals(addr._address)) {
			return false;
		}
		
		return true;
	}

	public boolean strictEquals(FreenetInetAddress addr) {
		if(hostname != null) {
			if(addr.hostname == null)
				return false;
			if (!hostname.equalsIgnoreCase(addr.hostname)) {
				return false;
			}
			// Now that we know we have the same hostname, we can propagate the IP.
			if((_address != null) && (addr._address == null))
				addr._address = _address;
			if((addr._address != null) && (_address == null))
				_address = addr._address;
			// Except if we actually do have two different looked-up IPs!
			if((addr._address != null) && (_address != null) && !addr._address.equals(_address))
				return false;
			// Equal.
			return true;
		} else if(addr.hostname != null /* && hostname == null */) {
			return false;
		}

		// No hostname, go by address.
		String reverseHostNameISee = getHostName(_address);
		String reverseHostNameTheySee = getHostName(addr._address);
		if(reverseHostNameISee == null
				|| !reverseHostNameISee.equalsIgnoreCase(reverseHostNameTheySee)) {
			//Logger.minor(this, "Addresses do not match: mine="+getHostName(_address)+" his="+getHostName(addr._address));
			return false;
		}
		
		return true;
	}

	/**
	 * Get the IP address. Look it up if necessary, but return the last value if it
	 * has ever been looked up before; will not trigger a new lookup if it has been
	 * looked up before.
	 */
	public InetAddress getAddress() {
	  return getAddress(true);
	}

	/**
	 * Get the IP address. Look it up only if allowed to, but return the last value if it
	 * has ever been looked up before; will not trigger a new lookup if it has been
	 * looked up before.
	 */
	public InetAddress getAddress(boolean doDNSRequest) {
		if (_address != null) {
			return _address;
		} else {
		        if(!doDNSRequest) return null;
		        InetAddress addr = getHandshakeAddress();
		        if( addr != null ) {
		                this._address = addr;
		        }
		        return addr;
		}
	}

	/**
	 * Get the IP address, looking up the hostname if the hostname is primary, even if
	 * it has been looked up before. Typically called on a reconnect attempt, when the
	 * dyndns address may have changed.
	 */
	public InetAddress getHandshakeAddress() {
	    // Since we're handshaking, hostname-to-IP may have changed
	    if ((_address != null) && (hostname == null)) {
	    	if(logMINOR) Logger.minor(this, "hostname is null, returning "+_address);
	        return _address;
	    } else {
	    	if(logMINOR) Logger.minor(this, "Looking up '"+hostname+"' in DNS", new Exception("debug"));
	        /*
	         * Peers are constructed from an address once a
	         * handshake has been completed, so this lookup
	         * will only be performed during a handshake
	         * (this method should normally only be called
	         * from PeerNode.getHandshakeIPs() and once
	         * each connection from this.getAddress()
	         * otherwise) - it doesn't mean we perform a
	         * DNS lookup with every packet we send.
	         */
	        try {
	        	InetAddress[] addresses = InetAddress.getAllByName(hostname);
	        	if(logMINOR) Logger.minor(this, "Look up got '"+addresses+ '\'');
	        	if( addresses.length > 1 ) {
	        		/* sort by IPv6 first */
	        		Arrays.sort(addresses, InetAddressIpv6FirstComparator.COMPARATOR);
	        		/*
	        		 * cache the answer since getHandshakeAddress()
	        		 * doesn't use the cached value, thus
	        		 * getHandshakeIPs() should always get the
	        		 * latest value from DNS (minus Java's caching)
	        		 */
	        		this._address = InetAddress.getByAddress(addresses[0].getAddress());
	        		if(logMINOR) Logger.minor(this, "Setting address to "+_address);
	        	}
	        	return addresses[0];
	        } catch (UnknownHostException e) {
	        	if(logMINOR) Logger.minor(this, "DNS said hostname '"+hostname+"' is an unknown host, returning null");
	            return null;
	        }
	    }
	}

	@Override
	public int hashCode() {
		if(hostname != null) {
			return hostname.hashCode(); // Was set at creation, so it can safely be used here.
		} else {
			return _address.hashCode(); // Can be null, but if so, hostname will be non-null.
		}
	}
	
	@Override
	public String toString() {
		if(hostname != null) {
			return hostname;
		} else {
			return _address.getHostAddress();
		}
	}
	
	public String toStringPrefNumeric() {
		if(_address != null)
			return _address.getHostAddress();
		else
			return hostname;
	}

	public void writeToDataOutputStream(DataOutputStream dos) throws IOException {
		InetAddress addr = this.getAddress();
		if (addr == null) throw new UnknownHostException();
		byte[] data = addr.getAddress();
		if(data.length == 4)
			dos.write(0);
		else
			dos.write(255);
		dos.write(data);
		if(hostname != null)
			dos.writeUTF(hostname);
		else
			dos.writeUTF("");
	}

	/**
	 * Return the hostname or the IP address of the given InetAddress.
	 * Does not attempt to do a reverse lookup; if the hostname is
	 * known, return it, otherwise return the textual IP address.
	 */
	public static String getHostName(InetAddress primaryIPAddress) {
		if(primaryIPAddress == null) return null;
		String s = primaryIPAddress.toString();
		String addr = s.substring(0, s.indexOf('/')).trim();
		if(addr.isEmpty())
			return primaryIPAddress.getHostAddress();
		else
			return addr;
	}

	public boolean isRealInternetAddress(boolean lookup, boolean defaultVal, boolean allowLocalAddresses) {
		if(_address != null) {
			return IPUtil.isValidAddress(_address, allowLocalAddresses);
		} else {
			if(lookup) {
				InetAddress a = getAddress();
				if(a != null)
					return IPUtil.isValidAddress(a, allowLocalAddresses);
			}
			return defaultVal;	
		}
	}

	/**
	 * Get a new <code>FreenetInetAddress</code> with host name removed.
	 * 
	 * @return a new <code>FreenetInetAddress</code> with host name removed; or {@code null} if no
	 *         known ip address is associated with this object. You may want to do a
	 *         <code>getAddress(true)</code> before calling this.
	 */
	public FreenetInetAddress dropHostname() {
		if(_address == null) {
			Logger.error(this, "Can't dropHostname() if no address!");
			return null;
		}
		if(hostname != null) {
			return new FreenetInetAddress(_address);
		} else return this;
	}

	public boolean hasHostname() {
		return hostname != null && !hostname.isEmpty();
	}

	public boolean hasHostnameNoIP() {
		return hasHostname() && _address == null;
	}

	public boolean isIPv6(boolean defaultValue) {
		if(_address == null)
			return defaultValue;
		else
			return (_address instanceof Inet6Address);
	}
}
