import binascii
import hashlib
from typing import Optional
from typing_extensions import Final

from bemani.protocol.lz77 import Lz77
from bemani.protocol.binary import BinaryEncoding
from bemani.protocol.xml import XmlEncoding
from bemani.protocol.node import Node


class EAmuseException(Exception):
    """
    An exception thrown when we encounter an error with E-Amusement encapsulation.
    """


class EAmuseProtocol:
    """
    A wrapper object that encapsulates encoding/decoding the E-Amusement protocol by Konami.
    """
    SHARED_SECRET: Final[bytes] = b'\x69\xD7\x46\x27\xD9\x85\xEE\x21\x87\x16\x15\x70\xD0\x8D\x93\xB1\x24\x55\x03\x5B\x6D\xF0\xD8\x20\x5D\xF5'

    XML: Final[int] = 1
    BINARY: Final[int] = 2

    SHIFT_JIS_LEGACY: Final[str] = "shift-jis-legacy"
    SHIFT_JIS: Final[str] = "shift-jis"
    EUC_JP: Final[str] = "euc-jp"
    UTF_8: Final[str] = "utf-8"
    ASCII: Final[str] = "ascii"

    def __init__(self) -> None:
        """
        Initialize the object.
        """
        self.last_text_encoding: Optional[str] = None
        self.last_packet_encoding: Optional[int] = None

    def _rc4_crypt(self, data: bytes, key: bytes) -> bytes:
        """
        Given a data blob and a key blob, perform RC4 encryption/decryption.

        Parameters:
            data - Binary string representing data to be encrypted/decrypted
            key - Binary string representing the key to use

        Returns:
            binary string representing the encrypted/decrypted data
        """
        S = list(range(256))
        j = 0
        out = []

        # KSA Phase
        for i in range(256):
            j = (j + S[i] + key[i % len(key)]) & 0xFF
            S[i], S[j] = S[j], S[i]

        # PRGA Phase
        i = j = 0
        for char in data:
            i = (i + 1) & 0xFF
            j = (j + S[i]) & 0XFF
            S[i], S[j] = S[j], S[i]
            out.append(char ^ S[(S[i] + S[j]) & 0xFF])

        return bytes(out)

    def __decrypt(self, encryption_key: Optional[str], data: bytes) -> bytes:
        """
        Given data and an optional encryption key, decrypt the data.

        Parameters:
            encryption_key - A string encryption key as returned from a HTTP request.
                             Should be in the form 1-xxyyzzww-aabb. If it is None, this
                             performs a null decrypt.
            data - Binary string representing data to transform.

        Returns:
            binary string representing transformed data
        """
        key: Optional[bytes] = None
        if encryption_key:
            # Key is concatenated with the shared secret above
            version, first, second = encryption_key.split('-')
            key = binascii.unhexlify((first + second).encode('ascii')) + EAmuseProtocol.SHARED_SECRET

            # Next, key is sent through MD5 to derive the real key
            m = hashlib.md5()
            m.update(key)
            key = m.digest()

        if key:
            # This is an encrypted old-style packet
            return self._rc4_crypt(data, key)

        # No encryption
        return data

    def __encrypt(self, encryption_key: Optional[str], data: bytes) -> bytes:
        """
        Given data and an optional encryption key, encrypt the data.

        Parameters:
            encryption_key - A string encryption key as returned from a HTTP request.
                             Should be in the form 1-xxyyzzww-aabb. If it is None, this
                             performs a null decrypt.
            data - Binary string representing data to transform.

        Returns:
            binary string representing transformed data
        """
        # RC4 is symmetric
        return self.__decrypt(encryption_key, data)

    def __decompress(self, compression: Optional[str], data: bytes) -> bytes:
        """
        Given data and an optional compression scheme, decompress the data.

        Parameters:
            compression - A string specifying the compression used. Should
                          be of the form 'l7zz' or 'none'. You can also pass in
                          the python value None.
            data - Binary string representing data to transform.

        Returns:
            binary string representing transformed data
        """
        if compression is None or compression == 'none':
            # This isn't compressed
            return data
        elif compression == 'lz77':
            # This is a compressed new-style packet
            lz = Lz77()
            return lz.decompress(data)
        else:
            raise EAmuseException(f'Unknown compression {compression}')

    def __compress(self, compression: Optional[str], data: bytes) -> bytes:
        """
        Given data and an optional compression scheme, compress the data.

        Parameters:
            compression - A string specifying the compression used. Should
                          be of the form 'l7zz' or 'none'. The python value
                          None will also be recognized as 'none'.
            data - Binary string representing data to transform.

        Returns:
            binary string representing transformed data
        """
        if compression is None or compression == 'none':
            # This isn't compressed
            return data
        elif compression == 'lz77':
            # This is a compressed new-style packet
            lz = Lz77()
            return lz.compress(data)
        else:
            raise EAmuseException(f'Unknown compression {compression}')

    def __decode(self, data: bytes) -> Node:
        """
        Given data, decode the data into a Node tree.

        Parameters:
            data - Binary string representing data to decode.

        Returns:
            Node tree on success or None on failure.
        """
        # Assume it's a binary page
        binary = BinaryEncoding()
        ret = binary.decode(data, skip_on_exceptions=True)

        if ret is not None:
            # We got a result, it was binary
            self.last_text_encoding = binary.encoding
            self.last_packet_encoding = EAmuseProtocol.BINARY

            return ret

        # Assume its XML
        xml = XmlEncoding()
        ret = xml.decode(data, skip_on_exceptions=True)

        if ret is not None:
            # We got a result, it was XML
            self.last_text_encoding = xml.encoding
            self.last_packet_encoding = EAmuseProtocol.XML

            return ret

        # Couldn't decode
        raise EAmuseException('Unknown packet encoding')

    def __encode(self, tree: Node, text_encoding: str, packet_encoding: int) -> bytes:
        """
        Given a Node tree, encode the data into the given packet encoding.

        Parameters:
            tree - A Node object representing the root of the tree to encode.
            text_encoding - The text encoding for any strings that will be encoded.
                            Should be EAmuseProtocol.SHIFT_JIS, EAmuseProtocol.EUC_JP or
                            EAmuseProtocol.UTF8.
            packet_encoding - The encoding used for the packet. Should be EAmuseProtocol.XML
                              or EAmuseProtocol.BINARY.

        Returns:
            A string blob representing the encoded packet.
        """
        if packet_encoding == EAmuseProtocol.BINARY:
            # It's binary, encode it
            binary = BinaryEncoding()
            return binary.encode(tree, encoding=text_encoding)
        elif packet_encoding == EAmuseProtocol.XML:
            # It's XML, encode it
            xml = XmlEncoding()
            return xml.encode(tree, encoding=text_encoding)
        else:
            raise EAmuseException(f"Invalid packet encoding {packet_encoding}")

    def decode(self, compression: Optional[str], encryption: Optional[str], data: bytes) -> Node:
        """
        Given a request with optional compression and encryption set, decrypt,
        decompress and decode the data, returning a parsed tree.

        Parameters:
            compression - A string specifying the compression type, should be 'lz77' or 'none'.
                          The python value None can also be passed in.
            encryption - A string specifying the encryption key, or None if no encryption.
            data - A binary string of data to parse.

        Returns:
            A Node tree structure representing the parsed request, or None on failure.
        """
        data = self.__decrypt(encryption, data)
        data = self.__decompress(compression, data)
        return self.__decode(data)

    def encode(
        self,
        compression: Optional[str],
        encryption: Optional[str],
        tree: Node,
        text_encoding: Optional[str]=None,
        packet_encoding: Optional[int]=None,
    ) -> bytes:
        """
        Given a response with optional compression and encryption set, encode, compress
        and encrypt the data, returning a binary blob suitable for forwarding on a network.

        Parameters:
            compression - A string specifying the compression type, should be 'lz77' or 'none'.
                          The python value None can also be passed in.
            encryption - A string specifying the encryption key, or None if no encryption.
            data - A binary string of data to parse.
            text_encoding - A text encoding to use. If not provided, uses the text encoding of the
                            last decoded packet. See __encode for values.
            packet_encpding - A packet encoding to use. If not provided, uses the packet encoding
                              of the last decoded packet. See __encode for values.

        Returns:
            A blob of data representing the encoded packet.
        """
        # Either auto-set response based on request, or explicitly override in parameters
        if text_encoding is None:
            text_encoding = self.last_text_encoding
        if text_encoding is None:
            raise EAmuseException("Unknown text encoding")

        if packet_encoding is None:
            packet_encoding = self.last_packet_encoding
        if packet_encoding is None:
            raise EAmuseException("Unknown packet encoding")

        # Clear last packet since we sent a response
        self.last_text_encoding = None
        self.last_packet_encoding = None

        data = self.__encode(tree, text_encoding, packet_encoding)
        data = self.__compress(compression, data)
        return self.__encrypt(encryption, data)
