from __future__ import annotations

import hashlib
import hmac
from dataclasses import dataclass, field

from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
    ApplicationControlAuthority,
    ApplicationRequestHeader,
    LiveDeviceControlBinding,
)
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
    OneShotPublishEnvelope,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import OPENAPI_SUCCESS
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
    ProtobufWireError,
    ProtoField,
    iter_fields,
)

CALIBRATION_FILE_REQUEST_TOPIC = "lixel/calibration/request/file"
CALIBRATION_FILE_RESPONSE_TOPIC = "lixel/calibration/response/file"
CALIBRATION_FILE_MESSAGE_TYPE = "CalibFileRequest"
CALIBRATION_FILE_READ_COMMAND = 5

FACTORY_CAMERA_CALIBRATION_PATH = "/mnt/system/factory-data/config/camera.yaml"
FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH = (
    "/mnt/system/factory-data/config/extrinsic_camera_lidar.yaml"
)
FACTORY_CALIBRATION_PATHS = (
    FACTORY_CAMERA_CALIBRATION_PATH,
    FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH,
)

MAX_CALIBRATION_FILE_BYTES = 1024 * 1024
MAX_CALIBRATION_RESPONSE_BYTES = MAX_CALIBRATION_FILE_BYTES + 16 * 1024
MAX_CALIBRATION_PATH_BYTES = 256
MAX_CALIBRATION_HEADER_BYTES = 4 * 1024


class CalibrationFileProtocolError(ValueError):
    """A K1 calibration-file message violated the read-only contract."""


class CalibrationFileRejected(CalibrationFileProtocolError):
    """The K1 rejected a correlated, exact-path calibration-file read."""

    def __init__(self, result_code: int) -> None:
        self.result_code = result_code
        super().__init__(f"K1 calibration-file read rejected with result code {result_code}")


@dataclass(frozen=True, slots=True)
class EncodedCalibrationFileRead:
    path: str
    session_id: str = field(repr=False)
    vendor_device_id: str = field(repr=False)
    payload: bytes = field(repr=False)
    payload_sha256: str
    payload_bytes: int
    topic: str = CALIBRATION_FILE_REQUEST_TOPIC
    response_topic: str = CALIBRATION_FILE_RESPONSE_TOPIC
    qos: int = 2
    retain: bool = False
    mutates_device: bool = False
    automatic_retry: bool = False

    def envelope(self, *, ordinal: int) -> OneShotPublishEnvelope:
        if ordinal not in (1, 2):
            raise CalibrationFileProtocolError(
                "factory calibration read ordinal must be one or two"
            )
        return OneShotPublishEnvelope(
            operation_key=f"calibration:read:{ordinal}",
            topic=self.topic,
            payload=self.payload,
            payload_sha256=self.payload_sha256,
            payload_bytes=self.payload_bytes,
            qos=self.qos,
            retain=self.retain,
        )


@dataclass(frozen=True, slots=True)
class CalibrationFileContent:
    path: str
    content: bytes = field(repr=False)
    content_sha256: str
    content_bytes: int
    result_code: int


def build_factory_calibration_file_read(
    authority: ApplicationControlAuthority,
    binding: LiveDeviceControlBinding,
    path: str,
) -> EncodedCalibrationFileRead:
    """Build command 5 for one of the two reviewed factory YAML paths.

    The command is deliberately not a parameter.  This module has no API that
    can encode command 6 (file write), and an arbitrary path cannot cross this
    boundary.
    """

    _require_reviewed_binding(binding)
    _require_exact_factory_path(path)
    header = ApplicationRequestHeader(
        message_type=CALIBRATION_FILE_MESSAGE_TYPE,
        authority=authority,
        binding=binding,
    )
    encoded_header = b"".join(
        (
            _text_field(4, binding.vendor_device_id),
            _text_field(5, header.session_id),
            _text_field(6, authority.openapi_key),
        )
    )
    if len(encoded_header) > MAX_CALIBRATION_HEADER_BYTES:
        raise CalibrationFileProtocolError("calibration request header exceeds bound")
    payload = b"".join(
        (
            _bytes_field(1, encoded_header),
            _varint_field(2, CALIBRATION_FILE_READ_COMMAND),
            _text_field(3, path),
        )
    )
    if len(payload) > MAX_CALIBRATION_RESPONSE_BYTES:
        raise CalibrationFileProtocolError("calibration request exceeds bound")
    return EncodedCalibrationFileRead(
        path=path,
        session_id=header.session_id,
        vendor_device_id=binding.vendor_device_id,
        payload=payload,
        payload_sha256=hashlib.sha256(payload).hexdigest(),
        payload_bytes=len(payload),
    )


def decode_factory_calibration_file_response(
    payload: bytes,
    request: EncodedCalibrationFileRead,
    authority: ApplicationControlAuthority,
    binding: LiveDeviceControlBinding,
) -> CalibrationFileContent:
    """Decode and correlate one exact-path command-5 response."""

    _require_reviewed_binding(binding)
    _require_exact_factory_path(request.path)
    if request.vendor_device_id != binding.vendor_device_id:
        raise CalibrationFileProtocolError("calibration request binding changed")
    if len(payload) > MAX_CALIBRATION_RESPONSE_BYTES:
        raise CalibrationFileProtocolError("calibration response exceeds bound")

    top = _selected_unique_fields(
        payload,
        "calibration response",
        selected={1, 2, 3, 4, 15},
        max_fields=32,
    )
    header = _selected_unique_fields(
        _required_bytes(top, 1, "response.header"),
        "calibration response header",
        selected={4, 5, 6},
        max_fields=16,
    )
    device_id = _required_ascii(header, 4, "response.header.device_id")
    session_id = _required_ascii(header, 5, "response.header.session_id")
    openapi_key = _required_ascii(header, 6, "response.header.openapi_key")
    if not hmac.compare_digest(device_id, binding.vendor_device_id):
        raise CalibrationFileProtocolError("calibration response device identity mismatch")
    if not hmac.compare_digest(session_id, request.session_id):
        raise CalibrationFileProtocolError("calibration response session mismatch")
    if not hmac.compare_digest(openapi_key, authority.openapi_key):
        raise CalibrationFileProtocolError("calibration response authority mismatch")

    command = _required_uint(top, 2, "response.cmd")
    if command != CALIBRATION_FILE_READ_COMMAND:
        raise CalibrationFileProtocolError("calibration response is not a file-read result")
    observed_path = _required_utf8(top, 3, "response.file_path", MAX_CALIBRATION_PATH_BYTES)
    if not hmac.compare_digest(observed_path, request.path):
        raise CalibrationFileProtocolError("calibration response path mismatch")

    error = _selected_unique_fields(
        _required_bytes(top, 15, "response.error"),
        "calibration response error",
        selected={1, 2},
        max_fields=8,
    )
    result_code = _required_uint(error, 1, "response.error.code")
    if result_code != OPENAPI_SUCCESS:
        raise CalibrationFileRejected(result_code)

    content = _required_bytes(top, 4, "response.file_content")
    if not content:
        raise CalibrationFileProtocolError("calibration response file content is empty")
    if len(content) > MAX_CALIBRATION_FILE_BYTES:
        raise CalibrationFileProtocolError("calibration response file exceeds bound")
    try:
        content.decode("utf-8", errors="strict")
    except UnicodeDecodeError as exc:
        raise CalibrationFileProtocolError(
            "calibration response file is not valid UTF-8"
        ) from exc
    if b"\x00" in content:
        raise CalibrationFileProtocolError("calibration response file contains NUL")
    return CalibrationFileContent(
        path=observed_path,
        content=content,
        content_sha256=hashlib.sha256(content).hexdigest(),
        content_bytes=len(content),
        result_code=result_code,
    )


def _require_reviewed_binding(binding: LiveDeviceControlBinding) -> None:
    if not binding.ready_for_reviewed_profile:
        raise CalibrationFileProtocolError(
            "live DeviceInfo does not match the reviewed activated K1 FW 3.0.2 profile"
        )


def _require_exact_factory_path(path: object) -> None:
    if not isinstance(path, str) or path not in FACTORY_CALIBRATION_PATHS:
        raise CalibrationFileProtocolError(
            "calibration file path is outside the exact two-file allowlist"
        )


def _selected_unique_fields(
    payload: bytes,
    name: str,
    *,
    selected: set[int],
    max_fields: int,
) -> dict[int, ProtoField]:
    result: dict[int, ProtoField] = {}
    try:
        for item in iter_fields(payload, max_fields=max_fields):
            if item.number not in selected:
                continue
            if item.number in result:
                raise CalibrationFileProtocolError(
                    f"{name} field {item.number} is duplicated"
                )
            result[item.number] = item
    except ProtobufWireError as exc:
        raise CalibrationFileProtocolError(f"invalid {name}: {exc}") from exc
    return result


def _required_bytes(fields: dict[int, ProtoField], number: int, name: str) -> bytes:
    item = fields.get(number)
    if item is None or item.wire_type != 2 or not isinstance(item.value, bytes):
        raise CalibrationFileProtocolError(f"{name} is missing or has wrong wire type")
    return item.value


def _required_uint(fields: dict[int, ProtoField], number: int, name: str) -> int:
    item = fields.get(number)
    if item is None or item.wire_type != 0 or not isinstance(item.value, int):
        raise CalibrationFileProtocolError(f"{name} is missing or has wrong wire type")
    return item.value


def _required_utf8(
    fields: dict[int, ProtoField],
    number: int,
    name: str,
    maximum_bytes: int,
) -> str:
    raw = _required_bytes(fields, number, name)
    if not raw or len(raw) > maximum_bytes:
        raise CalibrationFileProtocolError(f"{name} is empty or exceeds bound")
    try:
        return raw.decode("utf-8", errors="strict")
    except UnicodeDecodeError as exc:
        raise CalibrationFileProtocolError(f"{name} is not valid UTF-8") from exc


def _required_ascii(fields: dict[int, ProtoField], number: int, name: str) -> str:
    value = _required_utf8(fields, number, name, MAX_CALIBRATION_HEADER_BYTES)
    try:
        encoded = value.encode("ascii", errors="strict")
    except UnicodeEncodeError as exc:
        raise CalibrationFileProtocolError(f"{name} is not printable ASCII") from exc
    if any(byte <= 0x20 or byte > 0x7E for byte in encoded):
        raise CalibrationFileProtocolError(f"{name} is not printable ASCII")
    return value


def _varint(value: int) -> bytes:
    if value < 0:
        raise CalibrationFileProtocolError("negative protobuf varint is unsupported")
    encoded = bytearray()
    while value > 0x7F:
        encoded.append((value & 0x7F) | 0x80)
        value >>= 7
    encoded.append(value)
    return bytes(encoded)


def _key(number: int, wire_type: int) -> bytes:
    if number < 1:
        raise CalibrationFileProtocolError("protobuf field number must be positive")
    return _varint((number << 3) | wire_type)


def _varint_field(number: int, value: int) -> bytes:
    return _key(number, 0) + _varint(value)


def _bytes_field(number: int, value: bytes) -> bytes:
    return _key(number, 2) + _varint(len(value)) + value


def _text_field(number: int, value: str) -> bytes:
    return _bytes_field(number, value.encode("utf-8", errors="strict"))
