"""
Basic RPC API implementation
"""

import base64
import struct
from datetime import datetime
from typing import Literal, NamedTuple

from ecmind_blue_client.rpc import JobRequestFile, Jobs, JobValueTypes
from ecmind_blue_client.rpc._job_error import JobError
from ecmind_blue_client.rpc._job_parameter import JobParameter, JobParameterTypes

__all__ = [
    "serialize_file_footer",
    "serialize_file_header",
    "deserialize_file_header",
    "serialize_job_header",
    "deserialize_job_header",
    "serialize_job_request_data",
    "serialize_job_parameters",
    "deserialize_job_parameters",
    "serialize_job_parameter_description",
    "deserialize_job_parameter_description",
    "serialize_job_parameter_data",
    "deserialize_job_parameter_data",
    "deserialize_job_errors",
    "create_request_bin",
]


def serialize_job_header(
    parameter_length: int, protocol: Literal["BIN", "XML"] = "BIN", version: Literal["v50"] = "v50", compression: bool = False
) -> bytes:
    """
    Serialize a job header for the RPC protocol.

    The resulting byte string has the fixed format (20 bytes total):
    ``L:<protocol>-<request_length:10d><version><compression_flag>``
    e.g. ``L:BIN-0000000120v50N``

    The ``request_length`` field is ``parameter_length + 20`` (includes the header itself).

    Args:
        parameter_length (int): byte length of the request body (excluding the 20-byte header)
        protocol (Literal["BIN", "XML"]): wire protocol. Currently only ``"BIN"`` is supported.
        version (Literal["v50"]): protocol version. Currently only ``"v50"`` is supported.
        compression (bool): whether compression is enabled. Currently only ``False`` (``"N"``) is supported.

    Returns:
        bytes: the 20-byte serialized job header
    """

    # request length is the parameter length + the fixed 20 bytes header
    request_length = parameter_length + 20

    return (
        "L:".encode("ascii")
        + protocol.encode("ascii")
        + "-".encode("ascii")
        + f"{request_length:0>10}".encode("ascii")
        + version.encode("ascii")
        + ("Y" if compression else "N").encode("ascii")
    )


def deserialize_job_header(data: bytes) -> tuple[str, int, str, str]:
    """
    Deserialize a 20-byte job header from the RPC protocol.

    Args:
        data (bytes): exactly 20 bytes of serialized job header

    Returns:
        tuple[str, int, str, str]: ``(protocol, request_length, version, compression_flag)``
            where ``compression_flag`` is ``"Y"`` or ``"N"``

    Raises:
        ValueError: if the header does not start with ``b"L:"`` or is missing the ``"-"`` divider
        struct.error: if ``data`` is not exactly 20 bytes
    """

    [prefix, protocol, divider, request_length, version, compression] = struct.unpack(">2s3ss10s3ss", data)

    if prefix != b"L:" or divider != b"-":
        raise ValueError("Invalid job header format")

    return (bytes(protocol).decode("ascii"), int(request_length), bytes(version).decode("ascii"), bytes(compression).decode("ascii"))


def serialize_job_request_data(method_name: str, internal_parameters: bytes, parameters: bytes, mode: str = "C") -> bytes:
    """
    Serialize the job request body sent to the Blue server.

    The body is structured as:
    ``<mode_byte><method_name>\\0<internal_parameters><parameters>``

    Args:
        method_name (str): fully qualified job name, e.g. ``"dms.GetResultList"``
        internal_parameters (bytes): serialized internal parameters (e.g. stream count), produced by
            :func:`serialize_job_parameters`
        parameters (bytes): serialized job parameters, produced by :func:`serialize_job_parameters`
        mode (str): single-character mode prefix sent to the server (default ``"C"`` for client requests)

    Returns:
        bytes: the serialized job request body (without the 20-byte header)
    """

    return mode.encode("ascii") + method_name.encode("ascii") + b"\0" + internal_parameters + parameters


def serialize_job_parameters(parameters: list[JobParameter]) -> bytes:
    """
    Serialize a list of job parameters into the binary wire format.

    The resulting byte string is structured as:
    ``[params_length:4][parameter_count:4][descriptions: count*12][data]``

    Note: callers of :func:`deserialize_job_parameters` must strip the leading 4-byte
    ``params_length`` prefix before passing data to it.

    Args:
        parameters (list[JobParameter]): parameters to serialize

    Returns:
        bytes: serialized parameters including the 4-byte length prefix
    """
    parameter_count = len(parameters)
    description_length = 4 + (parameter_count * 12)

    description: bytes = b""
    data: bytes = b""
    for parameter in parameters:
        description += serialize_job_parameter_description(description_length + len(data), parameter)
        data += serialize_job_parameter_data(parameter)

    params_length = 4 + len(description) + len(data)

    return struct.pack(">II", params_length, parameter_count) + description + data


def deserialize_job_parameters(data: bytes) -> list[JobParameter]:
    """
    Deserialize job parameters from their binary wire format.

    ``data`` must begin with the 4-byte ``parameter_count`` field, i.e. the leading 4-byte
    ``params_length`` prefix produced by :func:`serialize_job_parameters` must be stripped
    by the caller before passing data here.

    Args:
        data (bytes): serialized parameters starting at the ``parameter_count`` field

    Returns:
        list[JobParameter]: deserialized parameters in original order

    Raises:
        ValueError: if an unknown parameter type is encountered
    """

    data_length = len(data)
    [parameter_count] = struct.unpack(">I", data[:4])

    descriptions: list[tuple[int, int, int]] = [struct.unpack(">III", data[i * 12 : 12 + i * 12]) for i in range(parameter_count)]
    parameters: list[JobParameter] = []

    for i in range(parameter_count):
        start = descriptions[i][1]
        end = descriptions[i + 1][1] if i < parameter_count - 1 else data_length

        (name, value, _) = data[start:end].decode("UTF-8").split("\0", maxsplit=2)

        parameter_type = JobParameterTypes(descriptions[i][2])

        match parameter_type:
            case JobParameterTypes.STRING:
                value = str(value)
            case JobParameterTypes.INTEGER:
                value = int(value)
            case JobParameterTypes.BOOLEAN:
                value = value == "1"
            case JobParameterTypes.DOUBLE:
                value = float(value)
            case JobParameterTypes.DATE_TIME:
                value = datetime.fromtimestamp(int(value) / 1000)
            case JobParameterTypes.BASE64:
                value = base64.b64decode(value)
            case JobParameterTypes.BIGINT:
                value = int(value)
            case _:
                raise ValueError("Invalid parameter type " + parameter_type)

        parameters.append(JobParameter(name, value, parameter_type))

    return parameters


def serialize_job_parameter_description(data_offset: int, parameter: JobParameter) -> bytes:
    """
    Serialize the 12-byte description entry for a single job parameter.

    Each description entry encodes three 4-byte big-endian unsigned integers:
    ``[data_offset, type_value, value_offset]``
    where ``value_offset = data_offset + len(parameter.name) + 1`` (skips name + null terminator).

    Args:
        data_offset (int): absolute byte offset of the parameter's data block (name\\0value\\0)
            within the serialized parameters blob
        parameter (JobParameter): parameter whose description is being serialized

    Returns:
        bytes: 12-byte description entry
    """
    value_offset = data_offset + len(parameter.name) + 1
    return struct.pack(">III", data_offset, parameter.type.value, value_offset)


def deserialize_job_parameter_description(data: bytes) -> tuple[int, int, int]:
    """
    Deserialize a single 12-byte parameter description entry.

    Args:
        data (bytes): exactly 12 bytes of a description entry

    Returns:
        tuple[int, int, int]: ``(data_offset, type_value, value_offset)`` as big-endian unsigned ints
    """
    return struct.unpack(">III", data)


def serialize_job_parameter_data(parameter: JobParameter) -> bytes:
    """
    Serialize the data block for a single job parameter.

    Produces ``<name>\\0<value>\\0`` where both name and value are UTF-8 encoded.
    The value encoding depends on the parameter type:
    - ``STRING``: raw string value
    - ``INTEGER`` / ``DOUBLE``: ``str(value)``
    - ``BOOLEAN``: ``"1"`` or ``"0"``
    - ``DATE_TIME``: Unix timestamp in milliseconds as a decimal string
    - ``BASE64``: base64-encoded bytes

    Args:
        parameter (JobParameter): parameter to serialize

    Returns:
        bytes: null-terminated name and null-terminated value concatenated

    Raises:
        ValueError: if the parameter type is ``BIGINT`` (not yet supported) or unknown
    """

    name: str = parameter.name
    value: str

    match parameter.type:
        case JobParameterTypes.STRING:
            value = str(parameter.value)
        case JobParameterTypes.INTEGER:
            value = str(parameter.value)
        case JobParameterTypes.BIGINT:
            raise ValueError("Bigint is currently not supported")
        case JobParameterTypes.BOOLEAN:
            value = "1" if parameter.value else "0"
        case JobParameterTypes.DOUBLE:
            value = str(parameter.value)
        case JobParameterTypes.DATE_TIME:
            if isinstance(parameter.value, datetime):
                value = str(int(parameter.value.timestamp() * 1000))
            else:
                value = str(parameter.value)
        case JobParameterTypes.BASE64:
            if isinstance(parameter.value, (bytearray, bytes)):
                value = base64.b64encode(parameter.value).decode("UTF-8")
            else:
                value = base64.b64encode(str(parameter.value).encode("UTF-8")).decode("UTF-8")
        case _:
            raise ValueError("Invalid parameter type " + str(parameter.type))

    return name.encode() + b"\0" + value.encode() + b"\0"


def deserialize_job_parameter_data(data: bytes) -> tuple[str, str]:
    """
    Deserialize the raw data block of a single job parameter.

    Args:
        data (bytes): UTF-8 encoded ``<name>\\0<value>\\0`` bytes

    Returns:
        tuple[str, str]: ``(name, value)`` both as raw strings (no type conversion applied)
    """

    parts = data.decode("UTF-8").split("\0")
    return parts[0], parts[1]


def serialize_file_header(file_length: int, extension: str) -> bytes:
    """
    Serialize the 32-byte file header for a file attachment in the RPC protocol.

    The resulting byte string has the fixed format:
    ``@ASSTREAM@<file_length:10d>@<extension:10s>@``
    where the extension is left-justified and right-padded with ``\\x11`` to 10 characters.

    Args:
        file_length (int): byte length of the file content that follows
        extension (str): file extension without leading dot (e.g. ``"pdf"``, ``"docx"``)

    Returns:
        bytes: the 32-byte serialized file header
    """
    return (
        "@ASSTREAM@".encode("ascii")
        + f"{file_length:0>10}".encode("ascii")
        + "@".encode("ascii")
        + extension.ljust(10, b"\x11".decode("ascii")).encode("ascii")
        + "@".encode("ascii")
    )


def deserialize_file_header(data: bytes) -> tuple[int, str]:
    """
    Deserialize the 32-byte file header from the RPC protocol.

    Args:
        data (bytes): exactly 32 bytes of serialized file header

    Returns:
        tuple[int, str]: ``(file_length, extension)`` where ``extension`` has no leading dot
            and trailing ``\\x11`` padding is stripped

    Raises:
        ValueError: if the ``@ASSTREAM@`` prefix or ``@`` dividers are not present
        struct.error: if ``data`` is not exactly 32 bytes
    """

    [prefix, file_length, divider_1, extension, divider_2] = struct.unpack(">10s10ss10ss", data)
    if prefix != b"@ASSTREAM@" or divider_1 != b"@" or divider_2 != b"@":
        raise ValueError("Invalid file header format")
    file_length = int(file_length)
    extension = extension.rstrip(b"\x11").decode("ascii")

    return (file_length, extension)


def serialize_file_footer() -> bytes:
    """
    Serialize the static 20-byte file footer for the RPC protocol.

    Returns:
        bytes: the fixed footer ``b"@0000000000@MAERTSSA"``
    """
    return "@0000000000@MAERTSSA".encode("ascii")


def deserialize_job_errors(data: bytes) -> list[JobError]:
    """
    Deserialize the error section of a job response.

    Binary layout of ``data``::

        [error_count:4][unknown:4]
        per error:
            [source_offset:4][source_code:4][message_offset:4][error_code:4][info_count:4]
            per info element:
                [info_offset:4]
        per error (string data, sequentially):
            <source_string>\\0
            <message_string>\\0
            per info element:
                <info_string>\\0

    All offsets are absolute byte positions measured from the start of ``data``.

    Args:
        data (bytes): the raw error section bytes as received from the server

    Returns:
        list[JobError]: deserialized errors; empty list if the server reported no errors
    """

    errors: list[JobError] = []

    [error_count, _unknown] = struct.unpack(">II", data[:8])
    total = len(data)
    data = data[8:]

    class ErrorDescription(NamedTuple):
        """
        Internal tuple to save error header descriptions
        """

        source_length: int
        source_code: int
        message_length: int
        error_code: int
        info_elements_offsets: list[int]

    descriptions: list[ErrorDescription] = []
    for i in range(error_count):
        [
            source_offset,
            source_code,
            message_offset,
            error_code,
            info_elements_length,
        ] = struct.unpack(">iiiii", data[:20])
        data = data[20:]

        source_length = message_offset - source_offset

        info_elements_offsets: list[int] = []
        for _ in range(info_elements_length):
            info_offset = struct.unpack(">i", data[:4])[0]
            info_elements_offsets.append(info_offset)
            data = data[4:]

        if i < error_count - 1:
            next_source_offset = struct.unpack(">i", data[:4])[0]
            message_length = next_source_offset - message_offset
        else:
            message_length = total - message_offset

        descriptions.append(ErrorDescription(source_length, source_code, message_length, error_code, info_elements_offsets))

    for description in descriptions:

        source = str(data[: description.source_length - 1], "utf-8")
        data = data[description.source_length :]
        message = str(data[: description.message_length - 1], "utf-8")
        data = data[description.message_length :]

        infos: list[str] = []
        for info_element_offset in description.info_elements_offsets:
            info = str(data[: info_element_offset - 1], "utf-8")
            infos.append(info)
            data = data[info_element_offset:]

        errors.append(JobError(source, message, description.source_code, description.error_code, infos))

    return errors


def create_request_bin(job: Jobs | str, parameters: list[JobParameter], request_files: list[JobRequestFile]) -> bytes:
    """
    Create binary request data based on the provided job and parameters.

    Args:
        job (Jobs | str): The job to be executed. Can be a string or an instance of the Jobs enum.
        parameters (list[JobParameter]): List of JobParameter objects representing the parameters for the job
        request_files (list[JobRequestFile]): List of JobRequestFile objects representing the request files for
            the job

    Returns:
        bytes: Binary data representing the request data
    """
    internal_parameters = [JobParameter("streams", len(request_files) if request_files else 0, JobParameterTypes.INTEGER)]
    internal_parameters_bin = serialize_job_parameters(internal_parameters)

    parameters_bin = serialize_job_parameters(parameters)

    name = job if isinstance(job, str) else job.value

    return serialize_job_request_data(name, internal_parameters_bin, parameters_bin)


def append_inferred_params(parameters: list[JobParameter], inferred_params: dict[str, JobValueTypes]) -> None:
    """
    Infer the :class:`JobParameterTypes` from each value's Python type and append the resulting
    :class:`JobParameter` entries to ``parameters``.

    Type mapping:
    - ``bool`` → ``BOOLEAN`` (must be checked before ``int`` since ``bool`` is a subclass of ``int``)
    - ``int`` → ``INTEGER``
    - ``float`` → ``DOUBLE``
    - ``datetime`` → ``DATE_TIME``
    - ``str`` → ``STRING``
    - ``bytes`` → ``BASE64``

    Args:
        parameters (list[JobParameter]): existing parameter list; new entries are appended in place
        inferred_params (dict[str, JobValueTypes]): mapping of parameter name to value

    Raises:
        ValueError: if a parameter name already exists in ``parameters``
        ValueError: if a value's type has no mapping (e.g. ``list``, ``dict``)
    """
    for name, value in inferred_params.items():
        if next((obj.value for obj in parameters if obj.name == name), None) is not None:
            raise ValueError(f"Parameter '{name}' is already defined in parameters")
        match value:
            case bool():
                parameters.append(JobParameter(name, value, JobParameterTypes.BOOLEAN))
            case int():
                parameters.append(JobParameter(name, value, JobParameterTypes.INTEGER))
            case float():
                parameters.append(JobParameter(name, value, JobParameterTypes.DOUBLE))
            case datetime():
                parameters.append(JobParameter(name, value, JobParameterTypes.DATE_TIME))
            case str():
                parameters.append(JobParameter(name, value, JobParameterTypes.STRING))
            case bytes():
                parameters.append(JobParameter(name, value, JobParameterTypes.BASE64))
            case _:
                raise ValueError(f"Unsupported parameter type '{type(value).__name__}' for parameter '{name}'")
