"""Dependency-free teaching implementation of the Hall sensor wire format.

This is a new example, separate from firmware/host/bus.py.  It implements
read-only requests and parsing; it is not a hardware-qualified robot driver.
"""

from __future__ import annotations

from dataclasses import dataclass
import math
import struct
import time

SOF = b"\xa5\x5a"
HOST = 0x00
TYPE_ENCODER = 0x01
TYPE_POSITION = 0x02
TYPE_ANY = 0xFF
CMD_GET_INFO = 0x01
CMD_GET_DATA = 0x10
CMD_REPORT = 0x30
CMD_RESPONSE = 0x80
PAYLOAD_MAX = 32
BAUD_TABLE = (9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600)


class ProtocolError(ValueError):
    """A valid frame carries a status or payload we cannot use."""


def crc16(data: bytes) -> int:
    """CRC-16/MODBUS: init FFFF, reflected polynomial A001, no final xor."""
    crc = 0xFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            crc = (crc >> 1) ^ (0xA001 if crc & 1 else 0)
    return crc


def build_frame(dst: int, src: int, dev_type: int, cmd: int, seq: int,
                payload: bytes = b"") -> bytes:
    if len(payload) > PAYLOAD_MAX:
        raise ValueError("payload exceeds the firmware limit of 32 bytes")
    body = bytes((dst, src, dev_type, cmd, seq, len(payload))) + payload
    return SOF + body + struct.pack("<H", crc16(body))


@dataclass(frozen=True)
class Frame:
    dst: int
    src: int
    dev_type: int
    cmd: int
    seq: int
    payload: bytes


class Parser:
    """Parse split or concatenated frames, resynchronizing after corrupt bytes."""

    def __init__(self, gap_timeout_s: float = 0.02) -> None:
        self.buffer = bytearray()
        self.crc_errors = 0
        self.length_errors = 0
        self.partial_timeouts = 0
        self.gap_timeout_s = gap_timeout_s
        self._last_data_at: float | None = None

    def feed(self, data: bytes, *, now: float | None = None) -> list[Frame]:
        now = time.monotonic() if now is None else now
        # A truncated frame with an in-range LEN must not hide a complete later
        # frame forever.  Only abandon it after an idle read, never merely on a
        # read boundary: arbitrary USB/serial chunk boundaries are normal.
        if (not data and self.buffer and self._last_data_at is not None and
                now - self._last_data_at >= self.gap_timeout_s):
            del self.buffer[0]
            self.partial_timeouts += 1
        if data:
            self._last_data_at = now
        self.buffer.extend(data)
        frames = []
        while True:
            start = self.buffer.find(SOF)
            if start < 0:
                # A trailing A5 can be the first byte of the next call's SOF.
                self.buffer[:] = b"\xa5" if self.buffer[-1:] == b"\xa5" else b""
                break
            if start:
                del self.buffer[:start]
            if len(self.buffer) < 8:
                break
            length = self.buffer[7]
            if length > PAYLOAD_MAX:
                self.length_errors += 1
                del self.buffer[0]
                continue
            total = 10 + length
            if len(self.buffer) < total:
                break
            body = bytes(self.buffer[2:8 + length])
            received = struct.unpack_from("<H", self.buffer, 8 + length)[0]
            if crc16(body) != received:
                self.crc_errors += 1
                del self.buffer[0]
                continue
            frames.append(Frame(*self.buffer[2:7], bytes(self.buffer[8:8 + length])))
            del self.buffer[:total]
        return frames


def checked_payload(payload: bytes, expected: int) -> bytes:
    if not payload:
        raise ProtocolError("response has no status byte")
    if payload[0] != 0:
        raise ProtocolError(f"device status 0x{payload[0]:02x}")
    if len(payload) != expected:
        raise ProtocolError(f"expected {expected} payload bytes, got {len(payload)}")
    return payload[1:]


def parse_info(payload: bytes) -> dict:
    data = checked_payload(payload, 13)
    dev_type, dev_id, mode, baud_index = data[:4]
    if dev_type not in (TYPE_ENCODER, TYPE_POSITION):
        raise ProtocolError(f"unknown measurement profile 0x{dev_type:02x}")
    report_ms, fw = struct.unpack_from("<HH", data, 4)
    zero = struct.unpack_from("<h" if dev_type == TYPE_POSITION else "<H", data, 10)[0]
    if not 1 <= dev_id <= 254:
        raise ProtocolError("device information contains an invalid address")
    if mode not in (0, 1) or baud_index >= len(BAUD_TABLE) or not 5 <= report_ms <= 60000:
        raise ProtocolError("device information contains invalid mode, baud or period")
    if data[8] not in (0, 1) or bool(data[8]) != bool(data[9] & 2):
        raise ProtocolError("calibration-valid byte and flag disagree")
    validate_flags(data[9], dev_type)
    if dev_type == TYPE_ENCODER and zero >= 36000:
        raise ProtocolError("angle zero lies outside one revolution")
    return {"dev_type": dev_type, "dev_id": dev_id, "mode": mode,
            "baud_index": baud_index, "report_ms": report_ms, "fw": fw,
            "cal_valid": data[8], "flags": data[9], "zero_raw": zero}


@dataclass(frozen=True)
class Sample:
    dev_type: int
    raw_value: int
    amplitude: int
    adc: tuple[int, int, int]
    flags: int
    host_rx_unix_s: float
    host_rx_monotonic_s: float

    @property
    def si_value(self) -> float:
        if self.dev_type == TYPE_ENCODER:
            return self.raw_value * math.pi / 18000.0
        if self.dev_type == TYPE_POSITION:
            return self.raw_value * 0.00001
        raise ProtocolError("measurement profile is not recognized")

    def to_dict(self) -> dict:
        encoder = self.dev_type == TYPE_ENCODER
        names = [name for bit, name in ((1, "weak_field"), (2, "calibrated"),
                                       (4, "calibrating"), (8, "out_of_array"))
                 if self.flags & bit]
        warnings = [name for bit, name in ((1, "weak_field"), (4, "calibrating"),
                                          (8, "outside_array_or_interpolation"))
                    if self.flags & bit]
        if not self.flags & 2:
            warnings.append("calibration_not_recorded")
        return {"profile": "encoder" if encoder else "line",
                "raw_value": self.raw_value,
                "raw_unit": "0.01_deg" if encoder else "0.01_mm",
                "display_value": self.raw_value / 100.0,
                "display_unit": "deg" if encoder else "mm",
                "si_value": self.si_value, "si_unit": "rad" if encoder else "m",
                "amplitude_adc_counts": self.amplitude,
                "adc_u1_u2_u4": list(self.adc), "flags": self.flags,
                "flag_names": names,
                "calibration_valid_flag": bool(self.flags & 2),
                "measurement_warnings": warnings,
                "host_rx_unix_s": self.host_rx_unix_s,
                "host_rx_monotonic_s": self.host_rx_monotonic_s,
                "timestamp_kind": "host_receive_decode_time_not_hardware_sample_time"}


def validate_flags(flags: int, dev_type: int) -> None:
    allowed = 0x07 if dev_type == TYPE_ENCODER else 0x0F
    if flags & ~allowed:
        raise ProtocolError("sample contains unsupported flags for this profile")


def parse_sample(payload: bytes, dev_type: int, *, with_status: bool = True,
                 host_rx_unix_s: float | None = None,
                 host_rx_monotonic_s: float | None = None) -> Sample:
    if dev_type not in (TYPE_ENCODER, TYPE_POSITION):
        raise ProtocolError("an explicit encoder or line profile is required")
    if with_status:
        payload = checked_payload(payload, 14)
    elif len(payload) != 13:
        raise ProtocolError("a report sample must contain exactly 13 bytes")
    value, amplitude, u1, u2, u4, flags = struct.unpack("<ihHHHB", payload)
    validate_flags(flags, dev_type)
    if amplitude < 0 or max(u1, u2, u4) > 4095:
        raise ProtocolError("sample contains a negative amplitude or non-12-bit ADC code")
    if dev_type == TYPE_ENCODER and not 0 <= value < 36000:
        raise ProtocolError("angle sample lies outside one revolution")
    # Do not clamp line values to +/-15 mm here: firmware subtracts the saved
    # signed mechanical zero after limiting its internal position estimate.
    return Sample(dev_type, value, amplitude, (u1, u2, u4), flags,
                  time.time() if host_rx_unix_s is None else host_rx_unix_s,
                  time.monotonic() if host_rx_monotonic_s is None else host_rx_monotonic_s)
