package nl.maartenvisscher.samsungtvcontrol;

import java.io.IOException;
import java.net.Socket;

import org.apache.commons.codec.binary.Base64;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.StringWriter;
import java.io.Writer;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.Arrays;

/**
 * API for controlling Samsung Smart TVs using a socket connection on port
 * 55000. The protocol information has been gathered from
 * http://sc0ty.pl/2012/02/samsung-tv-network-remote-control-protocol/ .
 *
 * @author Maarten Visscher <[email protected]>
 */
public class SamsungRemote {

    private final int PORT = 55000;
    private final int SO_TIMEOUT = 3 * 1000; // Socket connect and read timeout in milliseconds.
    private final int SO_AUTHENTICATE_TIMEOUT = 300 * 1000; // Socket read timeout while authenticating (waiting for user response) in milliseconds.
    private final String APP_STRING = "iphone.iapp.samsung";

    private final char[] ALLOWED = {0x64, 0x00, 0x01, 0x00}; // TV return payload.
    private final char[] DENIED = {0x64, 0x00, 0x00, 0x00};
    private final char[] TIMEOUT = {0x65, 0x00};
//    private final char[] WAIT = {0x0a, 0x00, 0x02, 0x00, 0x00, 0x00}; // Sent when a window popups on TV I think?
//    private final char[] SKIP = {0x0a, 0x00, 0x01, 0x00, 0x00, 0x00}; // Don't know yet what this means, seems like keep-alive, I skip them.

    private final Socket socket;
    private final BufferedWriter out;
    private final BufferedReader in;
    private final boolean debug;
    private final ArrayList<String> log; // A very simple log which will be filled when debug==true and can be obtained from outside using getLog().

    /**
     * Opens a socket connection to the television.
     *
     * @param host the host address.
     * @throws IOException if an I/O error occurs when creating the socket.
     */
    public SamsungRemote(InetAddress host) throws IOException {
        this(host, false);
    }

    /**
     * Opens a socket connection to the television and keeps a simple log when
     * debug is true.
     *
     * @param host the host address.
     * @param debug whether or not to keep a log.
     * @throws IOException if an I/O error occurs when creating the socket.
     */
    public SamsungRemote(InetAddress host, boolean debug) throws IOException {
        this.debug = debug;
        this.log = new ArrayList<>();
        this.socket = new Socket();
        socket.connect(new InetSocketAddress(host, PORT), SO_TIMEOUT);
        socket.setSoTimeout(SO_TIMEOUT);
        this.out = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
        this.in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
    }

    /**
     * Opens a socket connection to the television.
     *
     * @param host the host name.
     * @throws IOException if an I/O error occurs when creating the socket.
     * @deprecated
     */
    public SamsungRemote(String host) throws IOException {
        this(host, false);
    }

    /**
     * Opens a socket connection to the television and keeps a simple log when
     * debug is true.
     *
     * @param host the host name.
     * @param debug whether or not to keep a log.
     * @throws IOException if an I/O error occurs when creating the socket.
     * @deprecated
     */
    public SamsungRemote(String host, boolean debug) throws IOException {
        this.debug = debug;
        this.log = new ArrayList<>();
        this.socket = new Socket();
        socket.connect(new InetSocketAddress(host, PORT), SO_TIMEOUT);
        socket.setSoTimeout(SO_TIMEOUT);
        this.out = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
        this.in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
    }

    /**
     * Authenticates with the television using host IP address for the ip and id
     * parameters.
     *
     * @param name the name for this controller, which is displayed on the
     * television.
     * @return the response from the television.
     * @throws IOException if an I/O error occurs.
     * @see SamsungRemote#authenticate(java.lang.String, java.lang.String,
     * java.lang.String) authenticate
     */
    public TVReply authenticate(String name) throws IOException {
        String hostAddress = socket.getLocalAddress().getHostAddress();

        return authenticate(hostAddress, hostAddress, name);
    }

    /**
     * Authenticates with the television using host IP address for the ip
     * parameter.
     *
     * @param id a parameter for the television.
     * @param name the name for this controller, which is displayed on the
     * television.
     * @return the response from the television.
     * @throws IOException if an I/O error occurs.
     * @see SamsungRemote#authenticate(java.lang.String, java.lang.String,
     * java.lang.String) authenticate
     */
    public TVReply authenticate(String id, String name) throws IOException {
        String hostAddress = socket.getLocalAddress().getHostAddress();

        return authenticate(hostAddress, id, name);
    }

    /**
     * Authenticates with the television. Has to be done every time when a new
     * socket connection has been made, prior to sending key codes. Blocks
     * while waiting for the television response.
     *
     * @param ip a parameter for the television.
     * @param id a parameter for the television.
     * @param name the name for this controller, which is displayed on the
     * television.
     * @return the response from the television.
     * @throws IOException if an I/O error occurs.
     */
    public TVReply authenticate(String ip, String id, String name)
            throws IOException {
        emptyReaderBuffer(in);

        log("Authenticating with ip: " + ip + ", id: " + id + ", name: " + name + ".");
        out.write(0x00);
        writeString(out, APP_STRING);
        writeString(out, getAuthenticationPayload(ip, id, name));
        out.flush(); // Send authentication.

        socket.setSoTimeout(SO_AUTHENTICATE_TIMEOUT);
        char[] payload = readRelevantMessage(in);
        socket.setSoTimeout(SO_TIMEOUT);

        if (Arrays.equals(payload, ALLOWED)) {
            log("Authentication response: access granted.");
            return TVReply.ALLOWED; // Access granted.
        } else if (Arrays.equals(payload, DENIED)) {
            log("Authentication response: access denied.");
            return TVReply.DENIED; // Access denied.
        } else if (Arrays.equals(payload, TIMEOUT)) {
            log("Authentication response: timeout.");
            return TVReply.TIMEOUT; // Timeout.
        }
        log("Authentication message is unknown.");
        throw new IOException("Got unknown response.");
    }

    /**
     * Sends a key code to TV, blocks shortly waiting for TV response to check
     * delivery. Only works when you are successfully authenticated.
     *
     * @param keycode the key code to send.
     * @throws IOException if an I/O error occurs.
     */
    public void keycode(Keycode keycode) throws IOException {
        keycode(keycode.name());
    }

    /**
     * Sends a key code to TV, blocks shortly waiting for TV response to check
     * delivery. Only works when you are successfully authenticated.
     *
     * @param keycode the key code to send.
     * @throws IOException if an I/O error occurs.
     */
    public void keycode(String keycode) throws IOException {
        emptyReaderBuffer(in);

        log("Sending keycode: " + keycode + ".");
        out.write(0x00);
        writeString(out, APP_STRING);
        writeString(out, getKeycodePayload(keycode));
        out.flush(); // Send key code.

        readMessage(in);
    }

    /**
     * Sends a key code to TV in a non-blocking manner, thus it does not check
     * the delivery (use checkConnection() to poll the TV status). Only works
     * when you are successfully authenticated.
     *
     * @param keycode the key code to send.
     * @throws IOException if an I/O error occurs.
     */
    public void keycodeAsync(Keycode keycode) throws IOException {
        keycodeAsync(keycode.name());
    }

    /**
     * Sends a key code to TV in a non-blocking manner, thus it does not check
     * the delivery (use checkConnection() to poll the TV status). Only works
     * when you are successfully authenticated.
     *
     * @param keycode the key code to send.
     * @throws IOException if an I/O error occurs.
     */
    public void keycodeAsync(String keycode) throws IOException {
        log("Sending keycode without reading: " + keycode + ".");
        out.write(0x00);
        writeString(out, APP_STRING);
        writeString(out, getKeycodePayload(keycode));
        out.flush(); // Send key code.
    }

    /**
     * Checks the connection by sending an empty key code, does not return
     * anything but instead throws an exception when a problem arose (for
     * instance the TV turned off).
     *
     * @throws IOException if an I/O error occurs.
     */
    public void checkConnection() throws IOException {
        keycode("PING");
    }

    /**
     * Returns the authentication payload.
     *
     * @param ip the ip of the controller.
     * @param id the id of the controller.
     * @param name the name of the controller.
     * @return the authentication payload.
     * @throws IOException if an I/O error occurs.
     */
    private String getAuthenticationPayload(String ip, String id, String name)
            throws IOException {
        StringWriter writer = new StringWriter();
        writer.write(0x64);
        writer.write(0x00);
        writeBase64(writer, ip);
        writeBase64(writer, id);
        writeBase64(writer, name);
        writer.flush();
        return writer.toString();
    }

    /**
     * Returns the key code payload.
     *
     * @param keycode the key code.
     * @return the key code payload.
     * @throws IOException if an I/O error occurs.
     */
    private String getKeycodePayload(String keycode) throws IOException {
        StringWriter writer = new StringWriter();
        writer.write(0x00);
        writer.write(0x00);
        writer.write(0x00);
        writeBase64(writer, keycode);
        writer.flush();
        return writer.toString();
    }

    /**
     * Reads an incoming message or waits for a new one when it is not relevant.
     * I believe non-relevant messages has to do with showing or hiding of
     * windows on the TV, and start with 0x0a. This method returns the payload
     * of the relevant message.
     *
     * @param reader the reader.
     * @return the payload which was sent with the relevant message.
     */
    private char[] readRelevantMessage(Reader reader) throws IOException {
        char[] payload = readMessage(reader);
        while (payload[0] == 0x0a) {
            log("Message is not relevant, waiting for new message.");
            payload = readMessage(reader);
        }
        return payload;
    }

    /**
     * Reads an incoming message from the television and returns the payload.
     *
     * @param reader the reader.
     * @return the payload which was sent with the message.
     */
    private char[] readMessage(Reader reader) throws IOException {
        int first = reader.read();
        if (first == -1) {
            throw new IOException("End of stream has been reached (TV could have powered off).");
        }
        String response = readString(reader);
        char[] payload = readCharArray(reader);
        log("Message: first byte: " + Integer.toHexString(first) + ", response: " + response + ", payload: " + readable(payload));
        return payload;
    }

    /**
     * Returns a human readable string in hexadecimal of the char array.
     *
     * @param charArray the characters to translate.
     * @return the human readable string.
     */
    private String readable(char[] charArray) {
        String readable = Integer.toHexString(charArray[0]);
        for (int i = 1; i < charArray.length; i++) {
            readable += " " + Integer.toHexString(charArray[i]);
        }
        return readable;
    }

    /**
     * Writes the string length and the string itself to the writer.
     *
     * @param writer the writer.
     * @param string the string to write.
     * @throws IOException if an I/O error occurs.
     */
    private void writeString(Writer writer, String string) throws IOException {
        writer.write(string.length());
        writer.write(0x00);
        writer.write(string);
    }

    /**
     * Encodes the string with base64 and writes the result length and the
     * result itself to the writer.
     *
     * @param writer the writer.
     * @param string the string to encode using base64 and write.
     * @throws IOException if an I/O error occurs.
     */
    private void writeBase64(Writer writer, String string) throws IOException {
        String base64 = new String(Base64.encodeBase64(string.getBytes()));
        writeString(writer, base64);
    }

    /**
     * Reads the next string from the reader.
     *
     * @param reader the reader.
     * @return the string which is read.
     * @throws IOException if an I/O error occurs.
     */
    private String readString(Reader reader) throws IOException {
        return new String(readCharArray(reader));
    }

    /**
     * Reads the next characters from the reader using the length given in the
     * first byte.
     *
     * @param reader the reader.
     * @return the characters which were read.
     * @throws IOException if an I/O error occurs.
     */
    private char[] readCharArray(Reader reader) throws IOException {
        int length = reader.read();
        reader.read();
        char[] charArray = new char[length];
        reader.read(charArray);
        return charArray;
    }

    /**
     * Reads all messages which are left in the buffer and therefore empties it.
     *
     * @param reader the reader.
     * @throws IOException if an I/O error occurs.
     */
    private void emptyReaderBuffer(Reader reader) throws IOException {
        log("Emptying reader buffer.");
        while (reader.ready()) {
            readMessage(reader);
        }
    }

    /**
     * Returns a simple log with for instance TV response payloads as string
     * array, will only be filled when this class is constructed with debug true
     * (otherwise the array will be empty).
     *
     * @return a simple log.
     */
    public String[] getLog() {
        return log.toArray(new String[log.size()]);
    }

    /**
     * Logs a message when debug is true.
     *
     * @param message the message to log.
     */
    private void log(String message) {
        if (debug) {
            String time = (System.currentTimeMillis() % 1000) + ""; // Time is current milliseconds between 0 and 1000.
            while (time.length() < 3) {
                time = " " + time;
            }
            log.add(time + ". " + message);
        }
    }

    /**
     * Closes the socket connection. Should always be called at the end of a
     * session.
     */
    public void close() {
        log("Closing socket connection.");
        try {
            socket.close();
        } catch (IOException e) {
            log("IOException when closing connection: " + e.getMessage());
        }
    }
}