/*
 * MegaMek - Copyright (C) 2005 Ben Mazur (bmazur@sev.org)
 *
 *  This program is free software; you can redistribute it and/or modify it
 *  under the terms of the GNU General Public License as published by the Free
 *  Software Foundation; either version 2 of the License, or (at your option)
 *  any later version.
 *
 *  This program is distributed in the hope that it will be useful, but
 *  WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 *  or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
 *  for more details.
 */

package megamek.common.net;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Enumeration;
import java.util.Vector;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;

import megamek.common.net.marshall.PacketMarshaller;
import megamek.common.net.marshall.PacketMarshallerFactory;
import megamek.common.util.CircularIntegerBuffer;

/**
 * Generic bidirectional connection between client and server
 */
public abstract class AbstractConnection implements IConnection {

    /*
     * mev wrote: This class provides common reusable code for both Client and
     * Server. I've constructed it from the Server & client implementations of
     * the read/write functionality. I'm not quite sure in the interface and the
     * implementation of this class and descendants, so comments/suggestions are
     * welcome.
     */

    private static PacketMarshallerFactory marshallerFactory = PacketMarshallerFactory
            .getInstance();

    private static final int DEFAULT_MARSHALLING = PacketMarshaller.NATIVE_SERIALIZATION_MARSHALING;

    /**
     * Peer Host Non null in case if it's a client connection
     */
    private String host;

    /**
     * Peer port != 0 in case if it's a client connection
     */
    private int port;

    /**
     * Connection state
     */
    private boolean open;

    /**
     * The socket for this connection.
     */
    private Socket socket;

    /**
     * The connection ID
     */
    private int id;

    /**
     * Bytes send during the connection lifecycle
     */
    private long bytesSent;

    /**
     * Bytes received during the connection lifecycle
     */
    private long bytesReceived;

    /**
     * Queue of <code>Packets</code> to send
     */
    private SendQueue sendQueue = new SendQueue();

    /**
     * Connection listeners list
     */
    private Vector<ConnectionListener> connectionListeners = new Vector<ConnectionListener>();

    /**
     * Buffer of the last commands sent; Used for debugging purposes.
     */
    private CircularIntegerBuffer debugLastFewCommandsSent = new CircularIntegerBuffer(
            50);

    /**
     * Buffer of the last commands received; Used for debugging purposes.
     */
    private CircularIntegerBuffer debugLastFewCommandsReceived = new CircularIntegerBuffer(
            50);

    /**
     * Type of marshalling used to represent sent packets
     */
    protected int marshallingType;

    /**
     * Marshaller used to send packets
     */
    private PacketMarshaller marshaller;

    /**
     * Indicates the need to compress sent data
     */
    private boolean zipData = true;

    /**
     * Creates new client (connection from client to server) connection
     * 
     * @param host target host
     * @param port target port
     * @param id connection ID
     */
    public AbstractConnection(String host, int port, int id) {
        this.host = host;
        this.port = port;
        this.id = id;
        setMarshallingType(DEFAULT_MARSHALLING);
    }

    /**
     * Creates new Server connection
     * 
     * @param socket accepted socket
     * @param id connection ID
     */
    public AbstractConnection(Socket socket, int id) {
        this.socket = socket;
        this.id = id;
        setMarshallingType(DEFAULT_MARSHALLING);
    }

    /**
     * Returns <code>true</code> if it's the Server connection
     * 
     * @return <code>true</code> if it's the Server connection
     */
    public boolean isServer() {
        return host == null;
    }

    /**
     * Returns the type of the marshalling used to send packets
     * 
     * @return the type of the marshalling used to send packets
     */
    protected int getMarshallingType() {
        return marshallingType;
    }

    /**
     * Sets the type of the marshalling used to send packets
     * 
     * @param marshallingType new marhalling type
     */
    protected void setMarshallingType(int marshallingType) {
        PacketMarshaller pm = marshallerFactory.getMarshaller(marshallingType);
        megamek.debug.Assert.assertTrue(pm != null, "Unknown marshalling type");
        this.marshallingType = marshallingType;
        marshaller = pm;
    }

    /**
     * Opens the connection
     * 
     * @return <code>true</code> on success, <code>false</code> otherwise
     */
    public synchronized boolean open() {
        if (!open) {
            if (socket == null) {
                try {
                    socket = new Socket(host, port);
                } catch (Exception e) {
                    return false;
                }
            }
            open = true;
        }
        return true;
    }

    /**
     * Closes the socket and shuts down the receiver and sender threads
     */
    public void close() {
        synchronized (this) {
            System.err.print(getConnectionTypeAbbrevation());
            sendQueue.reportContents();
            sendQueue.finish();
            try {
                if (socket != null) {
                    socket.close();
                }
            } catch (IOException e) {
                System.err.print("Error closing connection #"); //$NON-NLS-1$
                System.err.print(getId());
                System.err.print(": "); //$NON-NLS-1$
                System.err.println(e.getMessage());
                // We don't need a full stack trace... we're
                // just closing the connection anyway.
                // e.printStackTrace();
            } catch (NullPointerException ex) {
                // never initialized, poor thing
            }
            socket = null;
        }
        processConnectionEvent(new DisconnectedEvent(this));
    }

    /**
     * Returns the connection ID
     * 
     * @return the connection ID
     */
    public int getId() {
        return id;
    }

    /**
     * Sets the connection ID
     * 
     * @param id new connection ID Be careful with this...
     */
    public void setId(int id) {
        this.id = id;
    }

    public String getInetAddress() {
        if (socket != null) {
            return socket.getInetAddress().toString();
        }
        return "Unknown";
    }

    /**
     * Returns <code>true</code> if this connection compress the sent data
     * 
     * @return <code>true</code> if this connection compress the sent data
     */
    public boolean isCompressed() {
        return zipData;
    }

    /**
     * Sets the compression
     * 
     * @param compress
     */
    public void setCompression(boolean compress) {
        zipData = compress;
    }

    /**
     * Adds a packet to the send queue to be send on a seperate thread.
     */
    public void send(Packet packet) {
        sendQueue.addPacket(new SendPacket(packet));
    }

    /**
     * Send packet now; This is the blocking call.
     */
    public synchronized void sendNow(SendPacket packet) {
        try {
            sendNetworkPacket(packet.getData(), packet.isCompressed());
            debugLastFewCommandsSent.push(packet.getCommand());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * Returns <code>true</code> if there are pending packets
     * 
     * @return <code>true</code> if there are pending packets
     */
    public boolean hasPending() {
        return sendQueue.hasPending();
    }

    /**
     * Returns a very approximate count of how many bytes were sent
     * 
     * @return a very approximate count of how many bytes were sent
     */
    public synchronized long bytesSent() {
        return bytesSent;
    }

    /**
     * Returns a very approximate count of how many bytes were received
     * 
     * @return a very approximate count of how many bytes were received
     */
    public synchronized long bytesReceived() {
        return bytesReceived;
    }

    /**
     * Adds the specified connection listener to receive connection events from
     * connection.
     * 
     * @param listener the connection listener.
     */
    public void addConnectionListener(ConnectionListener listener) {
        connectionListeners.addElement(listener);
    }

    /**
     * Removes the specified connection listener.
     * 
     * @param listener the connection listener.
     */
    public void removeConnectionListener(ConnectionListener listener) {
        connectionListeners.removeElement(listener);
    }

    /**
     * Reports receive exception to the <code>System.err</code>
     * 
     * @param ex <code>Exception</code>
     * @param packet <code>Packet</code>
     */
    protected void reportSendException(Exception ex, SendPacket packet) {
        System.err.print(getConnectionTypeAbbrevation());
        System.err.print(" error sending command #");
        System.err.print(packet.getCommand()); //$NON-NLS-1$
        System.err.print(": ");
        System.err.println(ex.getMessage()); //$NON-NLS-1$
        reportLastCommands();
    }

    /**
     * Reports receive exception to the <code>System.err</code>
     * 
     * @param ex <code>Exception</code>
     */
    protected void reportReceiveException(Exception ex) {
        StringBuffer message = new StringBuffer();
        reportReceiveException(ex, message);
        System.err.println(message);
    }

    /**
     * Appends the receive exception report to the given
     * <code>StringBuffer</code>
     * 
     * @param ex <code>Exception</code>
     */
    protected void reportReceiveException(Exception ex, StringBuffer buffer) {
        System.err.print(getConnectionTypeAbbrevation());
        System.err.print(" error reading command: ");
        System.err.println(ex.getMessage()); //$NON-NLS-1$
        reportLastCommands();
    }

    /**
     * Appends the last commands sent/received to the given
     * <code>StringBuffer</code>
     * 
     * @param buffer <code>StringBuffer</code> to add the report to
     */
    protected void reportLastCommands() {
        reportLastCommands(true);
        System.err.println();
        reportLastCommands(false);
        System.err.println();
        sendQueue.reportContents();
    }

    /**
     * Appends the last commands sent or received to the given
     * <code>StringBuffer</code> dependig on the <code>sent</code> parameter
     * 
     * @param buffer <code>StringBuffer</code> to add the report to
     * @param sent indicates which commands (sent/received) should be reported
     */
    protected void reportLastCommands(boolean sent) {
        CircularIntegerBuffer buf = sent ? debugLastFewCommandsSent
                : debugLastFewCommandsReceived;
        System.err.print("    Last "); //$NON-NLS-1$
        System.err.print(buf.length());
        System.err.print(" commands that were "); //$NON-NLS-1$ 
        System.err.print(sent ? "sent" : "received"); //$NON-NLS-1$
        System.err.print(" (oldest first): "); //$NON-NLS-1$
        System.err.println(buf);
    }

    /**
     * Returns the the connection type abbrevation (client/server) that used in
     * the debug messages and so on.
     * 
     * @return
     */
    protected String getConnectionTypeAbbrevation() {
        return isServer() ? "s:" : "c:"; //$NON-NLS-1$ //$NON-NLS-2$
    }

    /**
     * Returns an input stream
     * 
     * @return an input stream
     * @throws IOException
     */
    protected InputStream getInputStream() throws IOException {
        return socket.getInputStream();
    }

    /**
     * Returns an output stream
     * 
     * @return an output stream
     * @throws IOException
     */
    protected OutputStream getOutputStream() throws IOException {
        return socket.getOutputStream();
    }

    /**
     * checks if there is anything to send or receive and sends or receives that
     * stuff. Should not block and will not flush the actual socket, just the
     * packet sendqueue should not block for too long at a time, instead should
     * return 0 and hope to be called soon again
     * 
     * @return the amount of milliseconds that we assume we should be called
     *         again in. must return >=0 always
     */
    public synchronized long update() {
        flush();
        INetworkPacket np;
        try {
            while ((np = readNetworkPacket()) != null) {
                processPacket(np);
            }
        } catch (IOException e) {
            System.err
                    .println("IOException during AbstractConnection#update()");
            close();
        } catch (Exception e) {
            e.printStackTrace();
            reportReceiveException(e);
            close();
        }
        return 50;
    }

    /**
     * this is the method that should be overridden
     */
    public synchronized void flush() {
        doFlush();
    }

    protected synchronized void doFlush() {
        SendPacket packet = null;
        try {
            while ((packet = sendQueue.getPacket()) != null) {
                processPacket(packet);
            }
        } catch (Exception e) {
            reportSendException(e, packet);
            close();
        }
    }

    /**
     * process a received packet
     */
    protected void processPacket(INetworkPacket np) throws Exception {
        PacketMarshaller pm = marshallerFactory.getMarshaller(np
                .getMarshallingType());
        megamek.debug.Assert.assertTrue(pm != null, "Unknown marshalling type");
        Packet packet = null;
        byte[] data = np.getData();
        bytesReceived += data.length;
        ByteArrayInputStream bis = new ByteArrayInputStream(data);
        InputStream in;
        if (np.isCompressed()) {
            in = new GZIPInputStream(bis);
        } else {
            in = bis;
        }
        // TBD this is stupid. pm should be always non null right?
        if (pm != null) {
            packet = pm.unmarshall(in);
            if (packet != null) {
                debugLastFewCommandsReceived.push(packet.getCommand());
                processConnectionEvent(new PacketReceivedEvent(
                        AbstractConnection.this, packet));
            }
        }

    }

    /**
     * process a packet to be sent
     */
    protected void processPacket(SendPacket packet) throws Exception {
        sendNow(packet);
    }

    /**
     * Reads a complete <code>NetworkPacket</code> must not block, must return
     * null instead
     * 
     * @return the <code>NetworkPacket</code> that was sent.
     */
    protected abstract INetworkPacket readNetworkPacket() throws Exception;

    /**
     * Sends the data must not block for too long
     * 
     * @param data data to send
     * @param zipped should the data be compressed
     * @throws Exception
     */
    protected abstract void sendNetworkPacket(byte[] data, boolean zipped)
            throws Exception;

    private static class SendQueue {

        private Vector<SendPacket> queue = new Vector<SendPacket>();
        private boolean finished = false;

        public synchronized void addPacket(SendPacket packet) {
            queue.addElement(packet);
            notifyAll();
        }

        public synchronized void finish() {
            queue.removeAllElements();
            finished = true;
            notifyAll();
        }

        /**
         * Waits for a packet to appear in the queue and then returns it.
         * 
         * @return the first available packet in the queue or null if none
         */
        public synchronized SendPacket getPacket() {
            SendPacket packet = null;
            if (!hasPending())
                return null;
            if (!finished) {
                packet = queue.elementAt(0);
                queue.removeElementAt(0);
            }
            return packet;
        }

        /**
         * Returns true if this connection has pending data
         */
        public synchronized boolean hasPending() {
            return queue.size() > 0;
        }

        public void reportContents() {
            System.err.print("Contents of Send Queue: ");
            for (SendPacket p : queue) {
                System.err.print(p.command);
            }
            System.err.println();
        }
    }

    /**
     * Processes game events occurring on this connection by dispatching them to
     * any registered GameListener objects.
     * 
     * @param event the game event.
     */
    protected void processConnectionEvent(ConnectionEvent event) {
        for (Enumeration<ConnectionListener> e = connectionListeners.elements(); e
                .hasMoreElements();) {
            ConnectionListener l = e.nextElement();
            switch (event.getType()) {
                case ConnectionEvent.CONNECTED:
                    l.connected((ConnectedEvent) event);
                    break;
                case ConnectionEvent.DISCONNECTED:
                    l.disconnected((DisconnectedEvent) event);
                    break;
                case ConnectionEvent.PACKET_RECEIVED:
                    l.packetReceived((PacketReceivedEvent) event);
                    break;
            }
        }
    }

    private class SendPacket implements INetworkPacket {
        byte[] data;
        boolean zipped = false;
        int command;

        public SendPacket(Packet packet) {
            command = packet.getCommand();
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            OutputStream out;
            try {
                if (zipData && packet.getData() != null) {
                    out = new GZIPOutputStream(bos);
                    zipped = true;
                } else {
                    out = bos;
                }
                marshaller.marshall(packet, out);
                out.close();
                data = bos.toByteArray();
                bytesSent += data.length;
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        public int getMarshallingType() {
            return marshallingType;
        }

        public byte[] getData() {
            return data;
        }

        public boolean isCompressed() {
            return zipped;
        }

        public int getCommand() {
            return command;
        }
    }

    /**
     * Connection layer data packet.
     */
    protected interface INetworkPacket {

        /**
         * Returns data marshalling type
         * 
         * @return data marshalling type
         */
        public abstract int getMarshallingType();

        /**
         * Returns packet data
         * 
         * @return packet data
         */
        public abstract byte[] getData();

        /**
         * Returns <code>true</code> if data is compressed
         * 
         * @return <code>true</code> if data is compressed
         */
        public abstract boolean isCompressed();
    }
}
