package com.example.fitbot.util.server;

import android.util.Log;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Scanner;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class WebSocketConnectionHandler implements Runnable {

    private final WebSocket theSocket;
    private List<Socket> clients = Collections.synchronizedList(new ArrayList<>());
    private Thread thread;
    private boolean forceClose = false;

    /**
     * Constructor for WebSocketConnectionHandler.
     * This class handles all new incoming Socket connections.
     *
     * @param webSocket The socket to check for new connections.
     */
    protected WebSocketConnectionHandler(WebSocket webSocket) {
        this.theSocket = webSocket;
    }

    @Override
    public void run() {
        // Listen for new connections until the socket closes.
        while (theSocket.isConnected()) {
            try {
                // Find a new connection
                Socket newSocket = this.theSocket.getSocket().accept();
                this.theSocket.eventHandler.onConnected(newSocket);
                clients.add(newSocket);

                InputStream streamIn = newSocket.getInputStream();
                OutputStream streamOut = newSocket.getOutputStream();

                // Check if the connection was successfully upgraded to WebSocket
                if (upgradeConnection(streamIn, streamOut)) {
                    applyMessageDecoder(streamIn);
                }
            } catch (IOException  error) {
                String reason = error.getMessage() == null ? "Unknown reason" : error.getMessage();
                Log.e("WebSocketConnectionHandler", "Error listening to Socket connections: " + reason);
                break;
            }
        }
    }

    /**
     * Method for upgrading a HTTP connection to a WebSocket connection.
     * This checks whether the client sent a GET header and sends back
     * the required headers to upgrade the connection.
     * @param streamIn  The InputStream of the client socket connection.
     * @param streamOut The OutputStream of the client socket connection.
     * @return Whether or not the connection was successfully upgraded.
     */
    private boolean upgradeConnection(InputStream streamIn, OutputStream streamOut) {
        Scanner scanner = new Scanner(streamIn, "UTF-8");
        String data = scanner.useDelimiter("\\r\\n\\r\\n").next();
        Matcher header = Pattern.compile("^GET").matcher(data);

        // Check if the header contains the GET keyword
        // If this is the case, upgrade the HTTP connection to WebSocket.
        if (!header.find())
            return false;

        Matcher match = Pattern.compile("Sec-WebSocket-Key: (.*)").matcher(data);
        match.find(); // Get next match

        try {
            String SECAccept = Base64.getEncoder().encodeToString(
                    MessageDigest.getInstance("SHA-1").digest((match.group(1) + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").getBytes(StandardCharsets.UTF_8)));

            byte[] response = (
                            "HTTP/1.1 101 Switching Protocols\r\n" +
                            "Connection: Upgrade\r\n" +
                            "Upgrade: websocket\r\n" +
                            "Sec-WebSocket-Accept: " +
                            SECAccept + "\r\n\r\n").getBytes(StandardCharsets.UTF_8);
            streamOut.write(response, 0, response.length);

        } catch (IOException | NoSuchAlgorithmException error) {
            Log.e("WebSocketConnectionHandler", "Failed upgrading HTTP to WebSocket connection" + error.getMessage());
            return false;
        }
        return true;
    }

    /**
     * Method for applying a message decoder for whenever a socket receives data.
     * This method attemps to decode a message received from a WebSocket connection.
     * This message is in the
     * @param streamIn The message stream to decode.
     */
    private void applyMessageDecoder(InputStream streamIn) {
        // TODO: Implement
    }

    /**
     * Method for decoding an encoded WebSocket message
     * @param bytes The message to decode, in UTF-8 format.
     * @return The decoded message.
     * @throws IllegalArgumentException When the `frame` content is in an incorrect format.
     */
    public static String decodeWebSocketMessage(byte[] bytes) {
        // Check if the packet isn't corrupted
        if (bytes.length <= 2 || (bytes[0] & 0b1110) != 0)
            throw new IllegalArgumentException("Attempted to decode corrupted WebSocket frame data");

        WebSocket.Message.Opcode opcode = WebSocket.Message.Opcode.decode((byte) (bytes[0] & 0b11110000));
        byte payloadLength = (byte) (bytes[1] & 0b01111111); // Payload size (7 bits)
        boolean fin        = (bytes[0] & 0b1) != 0; // Whether this is the whole message
        boolean masked     = (bytes[1] & 0b10000000) != 0; // Whether the 9th bit is masked

        long extendedPayloadLength = 0;
        int byteOffset = 2;

        // Check whether the payload length is 16-bit
        if (payloadLength == 126) {
            // 16-bit extended payload length (byte 2 and 3)
            extendedPayloadLength = ((bytes[2] & 0xFF) << 8) | (bytes[3] & 0xFF);
            byteOffset += 2;

            // Check whether payload length is 64-bit
        } else if (payloadLength == 127) {
            // 64-bit extended payload length
            for (int i = 0; i < 8; i++)
                extendedPayloadLength |= (long) (bytes[2 + i] & 0xFF) << ((7 - i) * 8);
            byteOffset += 8;

        } else {
            extendedPayloadLength = payloadLength;
        }

        byte[] maskingKey = null;
        byte[] payloadData = new byte[(int) extendedPayloadLength];


        // Check if the MASK bit was set, if so, copy the key to the `maskingKey` array.
        if (masked) {
            maskingKey = new byte[4];
            System.arraycopy(bytes, byteOffset, maskingKey, 0, 4); // move mask bytes
            byteOffset += 4;
        }

        // Copy payload bytes into `payloadData` array.
        System.arraycopy(bytes, byteOffset, payloadData, 0, payloadData.length);

        // If mask is present, decode the payload data with the mask.
        if (masked)
            for (int i = 0; i < payloadData.length; i++)
                payloadData[i] ^= maskingKey[i % 4];

        // Convert payload data to string
        return new String(payloadData, StandardCharsets.UTF_8);
    }

    /**
     * Method for checking whether the connection handler is actively listening.
     * @return Whether it's listening.
     */
    public boolean isActive() {
        return this.thread.isAlive();
    }

    /**
     * Method for listening to all new incoming socket connections.
     */
    public void listen() {
        this.thread = new Thread(this);
        this.thread.start();
        Log.i("WebSocketConnectionHandler", "Listening started.");
    }

    /**
     * Method for stopping the connection handler.
     */
    public void stop() {
        // Close the socket connection if not already closed
        if (!this.theSocket.getSocket().isClosed()) {
            try {
                this.theSocket.getSocket().close();
            } catch (IOException error) {
                Log.e("WebSocketConnectionHandler", "Failed to close the socket connection: " + error.getMessage());
            }
        }

        // Interrupt the thread
        this.thread.interrupt();

        // Close all connections
        this.clients.forEach(client -> {
            try {
                client.close();
            } catch (IOException error) {
                Log.e("WebSocketConnectionHandler", "Failed to close client: " + error.getMessage());
            }
        });
        this.clients.clear();


        Log.i("WebSocketConnectionHandler", "Listening stopped.");
    }
}
