/*
 * This file is part of jNAT-PMPlib.
 *
 * jNAT-PMPlib is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * jNAT-PMPlib 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 Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with jNAT-PMPlib.  If not, see <http://www.gnu.org/licenses/>.
 */

package net.tomp2p.natpmp;

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.PortUnreachableException;
import java.net.SocketException;
import java.net.SocketTimeoutException;

/**
 * The message class manages a message that is being sent to a NAT-PMP gateway.
 * The class respects the back-off time defined in the NAT-PMP specification.
 *
 * Consumers of this class need to ensure that only one message is being sent
 * at a time to remain compliant with the NAT-PMP specification.
 *
 * This class is thread-safe.
 *
 * @see ExternalAddressRequestMessage
 * @see MapRequestMessage
 * @author flszen
 */
public abstract class Message {
    private MessageType type;
    private DatagramSocket socket;

    // These are used to manage the response.
    private byte[] response;
    private NatPmpException responseException;
    private MessageResponseInterface listener;

    // These are global response values.
    private ResultCode resultCode;
    private Integer secondsSinceEpoch;

    /**
     * Constructs a new Message with the speficied type.
     *
     * @param type The {@link MessageType} of the message. This must not be
     * null.
     *
     *
     * @throws NullPointerException Thrown if MessageType is null.
     */
    Message(MessageType type, MessageResponseInterface listener) {
        // Localize the type. It is needed when a response is received.
        this.type = type;
        this.listener = listener;

        // Reject if MessageType is null.
        if (type == null) {
            throw new NullPointerException("MessageType must not be null.");
        }
    }

    /**
     * Gets a byte array initialized with the request packet payload. The
     * version and opcode fields are overwritten when the packet is sent,
     * therefore it is not necessary to initialize them in this method.
     * @return A byte arrray initialized with request packet payload.
     */
    abstract byte[] getRequestPayload();

    /**
     * Gets the opcode of this message.
     * @return The opcode.
     */
    abstract byte getOpcode();

    /**
     * Parses the response. This method is automatically called once after a
     * message is received.
     * @throws Throwable Any exception may be thrown by the implementer.
     */
    abstract void parseResponse(byte[] response) throws Throwable;

    /**
     * Gets the excpetion associated with the response. This exception is
     * generally provided by an external entity.
     * @return The {@link NatPmpException} associated with the response.
     */
    public NatPmpException getResponseException() {
        return responseException;
    }

    /**
     * Gets the result code.
     * @return The result code.
     * @throws NatPmpException Thrown when no response has been received or the
     * response parsing ran into some trouble.
     */
    public ResultCode getResultCode() throws NatPmpException {
        // Return the address.
        return resultCode;
    }

    /**
     * Gets the seconds since epoch as received from the NAT-PMP gateway.
     * @return The seconds since the epoch on the gateway.
     * @throws NatPmpException Thrown when no response has been received or the
     * response parsing ran into some trouble.
     */
    public Integer getSecondsSinceEpoch() throws NatPmpException {
        // Return the address.
        return secondsSinceEpoch;
    }

    /**
     * Sets the exception associated with the response. This exception is
     * generally provided by an external entity.
     * @param responseException The {@link NatPmpException} to associate with
     * the response.
     */
    private void setResponseException(NatPmpException responseException) {
        this.responseException = responseException;
    }

    /**
     * Parses the response and notifies the listener of the outcome.
     */
    void notifyListener() {
        // If there is no response, make that notification.
        if (response == null) {
            if (listener != null) {
                listener.noResponseReceived(this);
            }
            return;
        }

        // Run the internal method for this. It simplifies the logic in handling
        // exception cases.
        try {
            internalNotifyListener();
        } catch (NatPmpException ex) {
            setResponseException(ex);
        }

        // If an exception is present, report it, otherwise it's a good response.
        if (getResponseException() != null) {
            if (listener != null) {
                listener.exceptionGenerated(this, getResponseException());
            }
        } else {
            // Make the notification that everyone hopes for.
            if (listener != null) {
                listener.responseReceived(this);
            }
        }
    }

    private void internalNotifyListener() throws NatPmpException {
        // Parse the global response values.
        secondsSinceEpoch = intFromByteArray(response, 4);
        switch (shortFromByteArray(response, 2)) {
            case 0:
                resultCode = ResultCode.Success;
                break;

            case 1:
                resultCode = ResultCode.UnsupportedVersion;
                break;

            case 2:
                resultCode = ResultCode.NotAuthorizedRefused;
                break;

            case 3:
                resultCode = ResultCode.NetworkFailure;
                break;

            case 4:
                resultCode = ResultCode.OutOfResources;
                break;

            case 5:
                resultCode = ResultCode.UnsupportedOpcode;
                break;

            default:
                throw new NatPmpException("Unsupported Result Code: " + shortFromByteArray(response, 2));
        }

        // Throw an exception if the result code is not success.
        if (resultCode != ResultCode.Success) {
            throw new NatPmpException("Message was not successful. The returned message code was " + resultCode.toString() + ".");
        }

        // Verify the resultant opcode is correct.
        if (response[1] != getOpcode() + (byte)128) {
            throw new NatPmpException("Incorrect opcode received from gateway.");
        }

        // Parse the response.
        try {
            parseResponse(response);
        } catch (Throwable ex) {
            if (ex instanceof NatPmpException) {
                throw (NatPmpException)ex;
            } else {
                throw new NatPmpException("Exception encountered during parsing of response.", ex);
            }
        }
    }

    /**
     * Gets the {@link MessageType} of this Message.
     * @return The MessageType.
     */
    MessageType getMessageType() {
        return type;
    }

    /**
     * Parses an unsigned integer (long) from a byte array in network byte
     * order.
     * @param src The source array.
     * @param offset The offset in the array.
     * @return The parsed unsigned integer. (Represented as a long.)
     */
    static final int intFromByteArray(byte[] src, int offset) {
        return (int)((src[offset] & 0xFF) << 24)
                + ((src[offset + 1] & 0xFF) << 16)
                + ((src[offset + 2] & 0xFF) << 8)
                + (src[offset + 3] & 0xFF);
    }

    /**
     * Writes a unsigned integer (long) to a byte array in network byte order.
     * @param value The value to write.
     * @param array The array to write to.
     * @param offset The offset in the array.
     */
    static final void intToByteArray(int value, byte[] array, int offset) {
        array[offset] = (byte)(value >>> 24);
        array[offset + 1] = (byte)(value >>> 16);
        array[offset + 2] = (byte)(value >>> 8);
        array[offset + 3] = (byte)value;
    }
    
    /**
     * Parses an unsigned short (int) from a byte array in network byte order.
     * @param src The source array.
     * @param offset The offset in the array.
     * @return The parsed unsigned short. (Represented as a long.)
     */
    static final int shortFromByteArray(byte[] src, int offset) {
        return (int)((src[offset] & 0xFF) << 8)
                + (src[offset + 1] & 0xFF);
    }

    /**
     * Writes a unsigned short (int) to a byte array in network byte order.
     * @param value The value to write.
     * @param array The array to write to.
     * @param offset The offset in the array.
     */
    static final void shortToByteArray(int value, byte[] array, int offset) {
        array[offset] = (byte)(value >>> 8);
        array[offset + 1] = (byte)value;
    }
    
    /**
     * Sends the message. The sending process is synchronous. The NAT-PMP
     * protocol specifies back-off times that are respected by this
     * implementation. Each message object may only be sent once.
     *
     * @param destination The destination address.
     *
     * @throws NatPmpException Thrown when this message is already being sent or
     * if there is a problem setting up the {@link DatagramSocket}.
     */
    synchronized void sendMessage(InetAddress destination) {
        try {
            sendMessageInternal(destination);
        } catch (NatPmpException ex) {
            setResponseException(ex);
        }
    }

    private void sendMessageInternal(InetAddress destination) throws NatPmpException {
        // Reject if a send is ongoing.
        if (this.socket != null) {
            throw new NatPmpException("Message is already being sent.");
        }

        // Set up the socket.
        try {
            socket = new DatagramSocket();
            socket.connect(destination, 5351);
            socket.setSoTimeout(250);
        } catch (IOException ex) {
            // Try to clean up the socket.
            if (socket != null) {
                socket.close();
                socket = null;
            }

            throw new NatPmpException("Exception during socket setup.", ex);
        }

        // Perform the sending and receiving process.
        sendMessageInternal();

        // Close the socket.
        socket.close();
    }

    /**
     * This handles the execution of the message sending and receiving process.
     */
    private void sendMessageInternal() throws NatPmpException {
        // Loop until all attempts have been made.
        for (int attempts = 9; attempts > 0; attempts--) {
            // Send the packet.
            try {
                byte[] payload = getRequestPayload();
                payload[0] = 0;
                payload[1] = getOpcode();
                DatagramPacket packet = new DatagramPacket(payload, payload.length, socket.getRemoteSocketAddress());
                socket.send(packet);
            } catch (PortUnreachableException ex) {
                throw new NatPmpException("The gateway is unreachable.");
            } catch (IOException ex) {
                throw new NatPmpException("Exception while sending packet.", ex);
            }

            // Wait for a response.
            try {
                byte[] localResponse = new byte[16];
                DatagramPacket packet = new DatagramPacket(localResponse, 0, 16);
                socket.receive(packet);

                // Handle a response.
                if (packet.getLength() > 0) {
                    this.response = localResponse;
                    return;
                }
            } catch (SocketTimeoutException ex) {
                // Ignore this.
            } catch (PortUnreachableException ex) {
                throw new NatPmpException("The gateway is unreachable.");
            } catch (IOException ex) {
                throw new NatPmpException("Exception while waiting for packet to be received.", ex);
            }

            // Double the socket timeout for the next attempt.
            try {
                socket.setSoTimeout(socket.getSoTimeout() * 2);
            } catch (SocketException ex) {
                throw new NatPmpException("Exception while increasing socket timeout time.", ex);
            }
        }
    }
}