
package uk.co.wingpath.io;

import java.io.*;
import java.net.*;
import uk.co.wingpath.event.*;
import uk.co.wingpath.util.*;

/**
* This class implements {@link Connection} using a TCP socket.
* <p>The methods in this class are thread-safe, but the synchronization
* does not guarantee atomicity of reads & writes. If this is needed, the
* recommended method is to synchronize on the SocketConnection instance.
*/
public class SocketConnection
    implements Connection
{
    private final String remoteHost;
    private final int remotePort;
    private final String localHost;
    private final int localPort;
    private final Reporter reporter;

    private volatile Socket socket;
    private InputStream input;
    private OutputStream output;
    private final Object lock = new Object ();
    private String name = null;

    /**
    * Constructs a {@code SocketConnection} using the supplied socket.
    * <p>This constructor is intended for server use. For client
    * connections the {@link SocketConnection#SocketConnection(String,int,
    * String,int,Reporter)} constructor is preferred.
    * <p>TCP_NODELAY is enabled (i.e. Nagle's algorithm is disabled) on the
    * socket, and input and output streams are obtained from the socket.
    * <p>The {@link SocketConnection#open open} method may not be used with a
    * {@code SocketConnection} constructed using this constructor.
    * @param socket the socket to be used for the connection.
    * The socket must already be connected and not closed.
    * @param reporter where to report closing of connection.
    * {@code null} if closing is not to be reported.
    * @throws IOException if any of the socket methods throws an
    * {@code IOException}.
    */
    public SocketConnection (Socket socket, Reporter reporter)
        throws IOException
    {
        this.localHost = null;
        this.localPort = 0;
        this.socket = socket;
        this.reporter = reporter;
        if (!socket.isConnected ())
            throw new IllegalArgumentException ("Socket is not connected");
        if (socket.isClosed ())
            throw new IllegalArgumentException ("Socket is closed");
        this.remoteHost =
            socket.getInetAddress ().getHostAddress ().toString ();
        this.remotePort = socket.getPort ();
        socket.setTcpNoDelay (true);
        input = socket.getInputStream ();
        output = socket.getOutputStream ();
    }

    /**
    * Constructs a {@code SocketConnection} using the supplied socket.
    * <p>This constructor is intended for server use. For client
    * connections the {@link SocketConnection#SocketConnection(String,int,
    * String,int,Reporter)} constructor is preferred.
    * <p>TCP_NODELAY is enabled (i.e. Nagle's algorithm is disabled) on the
    * socket, and input and output streams are obtained from the socket.
    * <p>The {@link SocketConnection#open open} method may not be used with a
    * {@code SocketConnection} constructed using this constructor.
    * @param socket the socket to be used for the connection.
    * The socket must already be connected and not closed.
    * @throws IOException if any of the socket methods throws an
    * {@code IOException}.
    */
    public SocketConnection (Socket socket)
        throws IOException
    {
        this (socket, null);
    }

    /**
    * Constructs a SocketConnection using the specified host and port.
    * <p>This constructor is intended for client use. For server
    * connections use the {@link SocketConnection#SocketConnection(Socket)}
    * constructor.
    * <p>The connection must be opened using the
    * {@link SocketConnection#open open} method before reading or writing.
    * @param remoteHost the host to connect to.
    * @param remotePort the port to connect to.
    * @param localHost name or IP address of the local interface to be used.
    * A null or empty host will assign the wildcard address.
    * @param localPort number of local port to bind the connection to.
    * A port number of zero will let the system pick up an ephemeral port.
    * @param reporter where to report opening/closing of connection.
    * {@code null} if opening/closing is not to be reported.
    */
    public SocketConnection (String remoteHost, int remotePort,
        String localHost, int localPort, Reporter reporter)
    {
        this.remoteHost = remoteHost;
        this.remotePort = remotePort;
        this.reporter = reporter;
        if (localHost != null && localHost.equals (""))
            localHost = null;
        this.localHost = localHost;
        this.localPort = localPort;
        socket = null;
        input = null;
        output = null;
    }

    /**
    * Opens the connection.
    * <p>TCP_NODELAY is enabled (i.e. Nagle's algorithm is disabled) on the
    * socket, and input and output streams are obtained from the socket.
    * @throws IOException if the connection cannot be opened.
    */
    public void open ()
        throws IOException
    {
        if (remoteHost == null)
            throw new IllegalStateException ("Host/port not specified");
        if (reporter != null)
        {
            reporter.info (null, "Connecting to host " + remoteHost +
                " port " + remotePort + " ...");
        }
        synchronized (lock)
        {
            if (socket != null)
                socket.close ();
            socket = new Socket ();
            try
            {
                InetAddress localAddr = localHost == null ? null :
                    InetAddress.getByName (localHost);
                socket.bind (new InetSocketAddress (localAddr, localPort));
            }
            catch (UnknownHostException e)
            {
                socket.close ();
                socket = null;
                throw new HIOException ("I111",
                    "Unknown local host: " + localHost);
            }
            catch (IOException e)
            {
                socket.close ();
                socket = null;
                throw new HIOException ("I112", "Can't bind to port " +
                    localPort + ": " + e.getMessage ());
            }
            try
            {
                socket.connect (
                    new InetSocketAddress (remoteHost, remotePort), 10000);
            }
            catch (UnknownHostException e)
            {
                socket.close ();
                socket = null;
                throw new HIOException ("I115", "Unknown host: " + remoteHost);
            }
            catch (SocketTimeoutException e)
            {
                socket.close ();
                socket = null;
                throw new HIOException ("I123",
                    "Can't connect to host '" + remoteHost + "' port " +
                        remotePort + ": " + e.getMessage ());
            }
            catch (ConnectException e)
            {
                socket.close ();
                socket = null;
                throw new HIOException ("I122",
                    "Can't connect to host '" + remoteHost + "' port " +
                        remotePort + ": " + e.getMessage ());
            }
            catch (NoRouteToHostException e)
            {
                socket.close ();
                socket = null;
                throw new HIOException ("I123",
                    "Can't connect to host '" + remoteHost + "': " +
                        e.getMessage ());
            }
            socket.setTcpNoDelay (true);
            input = socket.getInputStream ();
            output = socket.getOutputStream ();
        }
        if (reporter != null)
        {
            reporter.info (null, "Opened connection to " + remoteHost +
                ":" + remotePort);
        }
    }

    public boolean isOpen ()
    {
        return socket != null;
    }

    public void write (byte [] data, int offset, int length)
        throws IOException
    {
        Event.checkIsNotEventDispatchThread ();
        synchronized (lock)
        {
            if (socket == null)
                throw new HEOFException ("I100", "Connection closed");
            try
            {
                output.write (data, offset, length);
            }
            catch (IOException e)
            {
                socket.close ();
                socket = null;
                if (e.getMessage ().equals ("Connection reset"))
                    throw new HEOFException ("I100", "Connection closed");
                throw e;
            }
        }
    }

    public int read (byte [] data, int offset, int len,
            int timeout, boolean first)
        throws IOException, InterruptedException
    {
        Event.checkIsNotEventDispatchThread ();
        int count = 0;
        if (len == 0)
            return count;

        while (timeout > 0)
        {
            if (Thread.interrupted ())
                throw new InterruptedException ();
            synchronized (lock)
            {
                if (socket == null)
                    throw new HEOFException ("I100", "Connection closed");
                int to = timeout > 200 ? 200 : timeout;
                socket.setSoTimeout (to);

                try
                {
                    int b = input.read ();
                    if (b < 0)
                    {
                        close ();
                        socket = null;
                        throw new HEOFException ("I100", "Connection closed");
                    }
                    data [offset] = (byte) b;
                    offset++;
                    len--;
                    if (len == 0)
                        return 1;
                    count = 1;
                    break;
                }
                catch (SocketTimeoutException e)
                {
                    timeout -= to;
                }
                catch (IOException e)
                {
                    close ();
                    if (e.getMessage ().equals ("Connection reset"))
                        throw new HEOFException ("I100", "Connection closed");
                    throw e;
                }
            }
            Thread.yield ();
        }

        if (count == 0)
            throw new HInterruptedIOException ("I120", "Timed out");

        synchronized (lock)
        {
            if (socket == null)
                throw new HEOFException ("I100", "Connection closed");
            socket.setSoTimeout (1);

            try
            {
                int n = input.available ();
                if (n == 0)
                    return count;
                if (n > len)
                    n = len;
                n = input.read (data, offset, n);
                if (n > 0)
                    count += n;
            }
            catch (IOException e)
            {
            }
        }

        return count;
    }

    public void drain ()
    {
        // Note that "output.flush ()" is a no-op on a socket OutputStream.
        // Setting TCP_NODELAY should ensure that output is sent ASAP, but
        // there is no way to force output to be sent, or to wait until it has
        // been sent.
        // See http://www.unixguide.net/network/socketfaq/2.11.shtml.
    }

    public void flush ()
    {
        // There is no way to discard buffered output, apart from closing
        // and re-opening the socket.
    }

    public byte [] discardInput ()
    {
        Event.checkIsNotEventDispatchThread ();
        synchronized (lock)
        {
            if (socket == null)
                return null;
            try
            {
                int n = input.available ();
                if (n == 0)
                    return null;
                byte [] data = new byte [n];
                n = input.read (data, 0, n);
                return data;
            }
            catch (IOException e)
            {
            }
            return null;
        }
    }

    public void close ()
    {
        Event.checkIsNotEventDispatchThread ();
        synchronized (lock)
        {
            if (socket != null)
            {
                try
                {
                    socket.close ();
                }
                catch (Exception e)
                {
                }
                socket = null;
                if (reporter != null)
                {
                    reporter.info (null, "Closed connection to " +
                        remoteHost + ":" + remotePort);
                }
            }
        }
    }

    public String getName ()
    {
        if (name != null)
            return name;
        if (remoteHost != null)
            return remoteHost + ":" + remotePort;
        if (socket != null)
        {
            SocketAddress addr = socket.getRemoteSocketAddress ();
            if (addr != null)
                return addr.toString ();
        }
        return "";
    }

    public void setName (String name)
    {
        this.name = name;
    }
}

