from __future__ import annotations

import math
import struct
from dataclasses import dataclass
from typing import Protocol

from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
    ProtobufWireError,
    ProtoField,
    iter_fields,
)
from k1link.viewer.metrics import BridgeMetrics

MODELING_REPORT_TOPIC = "lixel/application/report/modeling"
# Retained physical FW 3.0.2 captures independently correlate ScanTime with
# host-monotonic duration at two ticks per second (one tick = 0.5 s). Keeping
# the profile-scoped scale named avoids silently treating a vendor counter as
# SI seconds.
SCAN_TIME_TICKS_PER_SECOND = 2


class ModelingReportDecodeError(ValueError):
    """A K1 ModelingReport violated the profile-scoped protobuf contract."""


@dataclass(frozen=True, slots=True)
class ModelingTelemetry:
    """Profile-scoped acquisition telemetry reported by the K1 itself."""

    move_distance_meters: float
    move_speed_meters_per_second: float
    scan_time_ticks: int
    pgo_progress: int

    @property
    def elapsed_seconds(self) -> float:
        return self.scan_time_ticks / SCAN_TIME_TICKS_PER_SECOND


class ModelingTransportMessage(Protocol):
    @property
    def topic(self) -> str: ...

    @property
    def payload(self) -> bytes: ...


def is_modeling_report_topic(topic: str) -> bool:
    return topic == MODELING_REPORT_TOPIC


def observe_modeling_report(
    message: ModelingTransportMessage,
    metrics: BridgeMetrics,
) -> bool:
    """Consume one K1 ModelingReport before the visual preview queue."""

    if not is_modeling_report_topic(message.topic):
        return False
    metrics.received(len(message.payload))
    try:
        telemetry = decode_modeling_report(message.payload)
    except ModelingReportDecodeError:
        metrics.modeling_decode_error()
    else:
        metrics.acquisition_telemetry(
            elapsed_seconds=telemetry.elapsed_seconds,
            route_distance_meters=telemetry.move_distance_meters,
            speed_meters_per_second=telemetry.move_speed_meters_per_second,
            pgo_progress=telemetry.pgo_progress,
        )
    return True


def decode_modeling_report(
    payload: bytes, *, max_payload_bytes: int = 64 * 1024
) -> ModelingTelemetry:
    """Decode the verified FW 3.0.2 ``ModelingReport`` status message.

    The nested schema recovered from the reviewed LixelGO build is:
    ``1 Header, 2 int32 PgoProgress, 3 ScanStatus`` and ScanStatus contains
    ``1 float MoveDistance, 2 float MoveSpeed, 3 int64 ScanTime``. Header data
    is intentionally not retained here because it can contain device identity
    and an OpenAPI credential.
    """

    if len(payload) > max_payload_bytes:
        raise ModelingReportDecodeError("ModelingReport exceeds configured limit")

    pgo_progress = 0
    scan_status: bytes | None = None
    seen: set[int] = set()
    try:
        for field in iter_fields(payload, max_fields=64):
            if field.number == 2:
                _mark_once(seen, 2, "modeling.pgo_progress")
                pgo_progress = _nonnegative_int(
                    field,
                    "modeling.pgo_progress",
                    max_value=0x7FFF_FFFF,
                )
            elif field.number == 3:
                _mark_once(seen, 3, "modeling.scan_status")
                scan_status = _bytes(field, "modeling.scan_status")
    except ProtobufWireError as exc:
        raise ModelingReportDecodeError(f"invalid ModelingReport: {exc}") from exc

    if scan_status is None:
        raise ModelingReportDecodeError("ModelingReport has no scan_status")

    distance = 0.0
    speed = 0.0
    scan_time = 0
    seen.clear()
    try:
        for field in iter_fields(scan_status, max_fields=16):
            if field.number == 1:
                _mark_once(seen, 1, "scan_status.move_distance")
                distance = _nonnegative_float32(field, "scan_status.move_distance")
            elif field.number == 2:
                _mark_once(seen, 2, "scan_status.move_speed")
                speed = _nonnegative_float32(field, "scan_status.move_speed")
            elif field.number == 3:
                _mark_once(seen, 3, "scan_status.scan_time")
                scan_time = _nonnegative_int(
                    field,
                    "scan_status.scan_time",
                    max_value=0x7FFF_FFFF_FFFF_FFFF,
                )
    except ProtobufWireError as exc:
        raise ModelingReportDecodeError(f"invalid ModelingReport.scan_status: {exc}") from exc

    return ModelingTelemetry(
        move_distance_meters=distance,
        move_speed_meters_per_second=speed,
        scan_time_ticks=scan_time,
        pgo_progress=pgo_progress,
    )


def _bytes(field: ProtoField, name: str) -> bytes:
    if field.wire_type != 2 or not isinstance(field.value, bytes):
        raise ModelingReportDecodeError(f"{name} has the wrong protobuf wire type")
    return field.value


def _nonnegative_int(field: ProtoField, name: str, *, max_value: int) -> int:
    if field.wire_type != 0 or not isinstance(field.value, int):
        raise ModelingReportDecodeError(f"{name} has the wrong protobuf wire type")
    if field.value > max_value:
        raise ModelingReportDecodeError(f"{name} is outside its recovered signed range")
    return field.value


def _mark_once(seen: set[int], number: int, name: str) -> None:
    if number in seen:
        raise ModelingReportDecodeError(f"{name} is duplicated")
    seen.add(number)


def _nonnegative_float32(field: ProtoField, name: str) -> float:
    if field.wire_type != 5 or not isinstance(field.value, bytes):
        raise ModelingReportDecodeError(f"{name} has the wrong protobuf wire type")
    value = float(struct.unpack("<f", field.value)[0])
    if not math.isfinite(value) or value < 0:
        raise ModelingReportDecodeError(f"{name} must be finite and nonnegative")
    return value
