"""Pure decoder for Teltonika AVL data packets — Codec 8 + Codec 8 Extended.

Per ADR-010 the codec is **L3 utility** (pure functions; no state, no
I/O); the byte-level reference is ``docs/teltonika/03-protocols.md``.
This module is **the only place** that imports the Teltonika protocol
constants — the rule the import-linter contract "Vendor SDKs only
inside their provider packages" enforces.

What's implemented in Phase 2:

  * :func:`decode_avl_packet` — top-level TCP-envelope decoder. Verifies
    preamble + data-field-length + CRC; dispatches on the codec id byte.
  * :func:`decode_codec8` — Codec 8 inner-block decoder (NumData1 +
    records + NumData2). 1-byte IO IDs throughout.
  * :func:`decode_codec8e` — Codec 8 Extended inner-block decoder.
    2-byte IO IDs + variable-length NX section.

What's deferred:

  * Codec 12 RX (command responses) — Phase 6+ when v2 unlocks the
    control plane. Decoder raises :class:`UnsupportedCodecError`.
  * Codec 13 / 14 / 15 — uncommon; raise :class:`UnsupportedCodecError`.
  * Codec 16 — awaits an FMB630-class device in the test bench.
  * UDP envelope (``§4`` of the protocol doc) — Phase 2 unit lands with
    the asyncio TCP server; UDP is a separate envelope shape that calls
    ``decode_codec8`` / ``decode_codec8e`` on the inner block.
  * Encoder for Codec 12 commands — Phase 6+.

Output type: :class:`plasmamapper.domain.DecodedAvlRecord` — the
vendor-agnostic intermediate. The L2 ``IngestionService`` resolves
identity (tenant / tracker / asset / record uuid / received_at /
raw_packet_id) and produces the canonical
:class:`plasmamapper.domain.TelemetryRecord`.
"""

from __future__ import annotations

import struct
from datetime import UTC, datetime
from typing import Final

from mgf.tele.core import (
    ChecksumError,
    DecodedAvlRecord,
    GeoPoint,
    MalformedPacketError,
    UnsupportedCodecError,
)

from mgf.tele.teltonika._crc import crc16_ibm

__all__ = [
    "CODEC_8",
    "CODEC_8_EXTENDED",
    "CODEC_12",
    "CODEC_13",
    "CODEC_14",
    "CODEC_15",
    "CODEC_16",
    "decode_avl_packet",
    "decode_codec8",
    "decode_codec8e",
]

# Codec id bytes — ``docs/teltonika/03-protocols.md`` §5.
CODEC_8: Final = 0x08
CODEC_8_EXTENDED: Final = 0x8E
CODEC_12: Final = 0x0C
CODEC_13: Final = 0x0D
CODEC_14: Final = 0x0E
CODEC_15: Final = 0x0F
CODEC_16: Final = 0x10

# SH-05 — explicit decoder count ceilings as defence-in-depth against
# hostile or malformed packets that pair with the SH-04 frame-size cap.
# Without these the buffer-truncation guards (``cursor + n*entry_size >
# len(inner)``) implicitly cap the loop body, but the loop body itself
# is still O(declared count) Python iterations. With these ceilings,
# even a maximally pessimal frame can't drive a single decode past a
# few thousand inner iterations.
#
# Numbers picked to be comfortably above real-device telemetry shapes:
#   * 255 records — Codec 8/8E NumData1 is one byte (wire-imposed cap).
#   * 128 entries per IO section — typical FMB/FMC reports carry
#     10-30 IO values per record; 128 leaves 4x headroom while still
#     biting Codec-8's 1-byte-count wire format (which allows 255).
#     Codec-8E's 2-byte count allows up to 65535 on the wire, so the
#     cap is what bounds the Python iteration cost.
#   * 64 NX entries per Codec-8E record — variable-length payloads
#     (camera frames, RFID, tachograph) are reasonably few per record.
_MAX_RECORDS_PER_PACKET: Final = 255
_MAX_IO_ENTRIES_PER_SECTION: Final = 128
_MAX_NX_ENTRIES_PER_RECORD: Final = 64

# Outer-envelope structure (§3).
_PREAMBLE: Final = b"\x00\x00\x00\x00"
_PREAMBLE_LEN: Final = 4
_DATA_LEN_FIELD_LEN: Final = 4
_CRC_FIELD_LEN: Final = 4
_MIN_ENVELOPE_LEN: Final = (
    _PREAMBLE_LEN + _DATA_LEN_FIELD_LEN + 1 + 1 + 1 + _CRC_FIELD_LEN
)  # preamble + length + codec + numData1 + numData2 + crc

# String labels for ``DecodedAvlRecord.codec`` (per DOMAIN_MODEL §2.7
# convention: "C8", "C8E", "C12", "Q1", ...).
_CODEC_LABELS: Final[dict[int, str]] = {
    CODEC_8: "C8",
    CODEC_8_EXTENDED: "C8E",
}


# ---------------------------------------------------------------------------
# Top-level envelope
# ---------------------------------------------------------------------------


def decode_avl_packet(data: bytes) -> tuple[int, list[DecodedAvlRecord]]:
    """Decode a TCP AVL packet (full envelope).

    Returns ``(codec_id, records)``. Raises:

      * :class:`MalformedPacketError` — preamble mismatch, length
        disagreement, truncated body, unknown codec id.
      * :class:`ChecksumError` — declared CRC ≠ computed CRC.
      * :class:`UnsupportedCodecError` — codec id recognised but the
        decoder isn't implemented in this phase.
    """
    if len(data) < _MIN_ENVELOPE_LEN:
        msg = f"AVL packet too short: {len(data)} bytes (min {_MIN_ENVELOPE_LEN})"
        raise MalformedPacketError(msg)

    if data[:_PREAMBLE_LEN] != _PREAMBLE:
        msg = "AVL packet preamble mismatch (expected 0x00000000)"
        raise MalformedPacketError(msg)

    data_field_length = int.from_bytes(
        data[_PREAMBLE_LEN : _PREAMBLE_LEN + _DATA_LEN_FIELD_LEN], "big"
    )
    payload_start = _PREAMBLE_LEN + _DATA_LEN_FIELD_LEN
    payload_end = payload_start + data_field_length
    expected_total = payload_end + _CRC_FIELD_LEN

    if expected_total != len(data):
        msg = (
            f"AVL packet length disagreement: data_field_length="
            f"{data_field_length} implies total {expected_total} bytes, "
            f"got {len(data)}"
        )
        raise MalformedPacketError(msg)

    payload = data[payload_start:payload_end]

    # CRC: 4 bytes, low 16 bits = CRC-16/IBM, high 16 bits reserved 0.
    declared_crc = int.from_bytes(data[payload_end:expected_total], "big")
    if declared_crc & 0xFFFF0000:
        msg = f"AVL packet CRC field high 16 bits expected 0x0000, got 0x{declared_crc:08x}"
        raise MalformedPacketError(msg)
    actual_crc = crc16_ibm(payload)
    if (declared_crc & 0xFFFF) != actual_crc:
        msg = (
            f"AVL packet CRC mismatch: declared 0x{declared_crc & 0xFFFF:04x}, "
            f"computed 0x{actual_crc:04x}"
        )
        raise ChecksumError(msg)

    codec_id = payload[0]
    inner = payload[1:]

    if codec_id == CODEC_8:
        records = decode_codec8(inner)
    elif codec_id == CODEC_8_EXTENDED:
        records = decode_codec8e(inner)
    elif codec_id in {CODEC_12, CODEC_13, CODEC_14, CODEC_15, CODEC_16}:
        msg = (
            f"codec 0x{codec_id:02x} recognised but not implemented in Phase 2; "
            f"see docstring for the deferral rationale per codec"
        )
        raise UnsupportedCodecError(msg)
    else:
        msg = f"unknown codec id 0x{codec_id:02x}"
        raise MalformedPacketError(msg)

    return codec_id, records


# ---------------------------------------------------------------------------
# Codec 8 — 1-byte IO IDs throughout
# ---------------------------------------------------------------------------


def decode_codec8(inner: bytes) -> list[DecodedAvlRecord]:
    """Decode the Codec-8 inner block: ``NumData1 + records + NumData2``."""
    if len(inner) < 2:
        msg = f"codec-8 inner block too short: {len(inner)} bytes"
        raise MalformedPacketError(msg)

    num_data_1 = inner[0]
    # SH-05 — defence-in-depth ceiling. Wire format caps num_data_1 at
    # 255 (one byte), so this is mostly a documented invariant — but
    # the explicit check makes the future-codec case (Codec 16+) clean.
    if num_data_1 > _MAX_RECORDS_PER_PACKET:
        msg = f"codec-8 NumData1={num_data_1} exceeds max_records={_MAX_RECORDS_PER_PACKET}"
        raise MalformedPacketError(msg)
    cursor = 1
    records: list[DecodedAvlRecord] = []
    for _ in range(num_data_1):
        record, cursor = _decode_codec8_record(inner, cursor)
        records.append(record)

    if cursor != len(inner) - 1:
        msg = (
            f"codec-8 inner block has {len(inner) - 1 - cursor} unread "
            f"bytes after {num_data_1} records"
        )
        raise MalformedPacketError(msg)
    num_data_2 = inner[cursor]
    if num_data_1 != num_data_2:
        msg = f"NumData1 ({num_data_1}) != NumData2 ({num_data_2})"
        raise MalformedPacketError(msg)
    return records


def _decode_codec8_record(inner: bytes, cursor: int) -> tuple[DecodedAvlRecord, int]:
    """Decode one Codec-8 record at ``cursor``; return ``(record, new_cursor)``."""
    # Header: 8 bytes timestamp + 1 byte priority + 15 bytes GPS = 24 bytes minimum.
    if cursor + 24 > len(inner):
        msg = "truncated Codec-8 record header / GPS element"
        raise MalformedPacketError(msg)

    timestamp_ms = int.from_bytes(inner[cursor : cursor + 8], "big")
    cursor += 8
    priority = inner[cursor]
    cursor += 1

    # GPS Element: int32 lon, int32 lat, int16 alt, uint16 angle, uint8 sat, uint16 speed.
    lon_raw, lat_raw, alt, angle, satellites, speed = struct.unpack_from(">iihHBH", inner, cursor)
    cursor += 15

    # IO Element: 1-byte event id, 1-byte total, then four sections (N1/N2/N4/N8)
    # each prefixed by a 1-byte count of {1-byte id + value} entries of that width.
    if cursor + 2 > len(inner):
        msg = "truncated Codec-8 IO header"
        raise MalformedPacketError(msg)
    event_io_id = inner[cursor]
    cursor += 1
    n_total = inner[cursor]
    cursor += 1

    inputs: dict[int, int] = {}
    counted = 0
    for value_size in (1, 2, 4, 8):
        if cursor + 1 > len(inner):
            msg = f"truncated Codec-8 N{value_size} count"
            raise MalformedPacketError(msg)
        n = inner[cursor]
        cursor += 1
        # SH-05 — refuse pathologically large counts before the loop.
        # 1-byte field caps at 255 (wire-imposed) but we cap a bit
        # below for parity with Codec-8E's explicit ceiling.
        if n > _MAX_IO_ENTRIES_PER_SECTION:
            msg = (
                f"codec-8 N{value_size} count {n} exceeds "
                f"max_io_entries={_MAX_IO_ENTRIES_PER_SECTION}"
            )
            raise MalformedPacketError(msg)
        entry_size = 1 + value_size  # 1-byte id + value
        if cursor + n * entry_size > len(inner):
            msg = f"truncated Codec-8 N{value_size} entries (need {n})"
            raise MalformedPacketError(msg)
        for _ in range(n):
            io_id = inner[cursor]
            cursor += 1
            value = int.from_bytes(inner[cursor : cursor + value_size], "big", signed=False)
            cursor += value_size
            inputs[io_id] = value
        counted += n

    if counted != n_total:
        msg = f"Codec-8 N-of-Total-IO ({n_total}) != sum N1+N2+N4+N8 ({counted})"
        raise MalformedPacketError(msg)

    return _build_decoded(
        timestamp_ms=timestamp_ms,
        priority=priority,
        lon_raw=lon_raw,
        lat_raw=lat_raw,
        alt_signed=alt,
        angle=angle,
        satellites=satellites,
        speed=speed,
        event_io_id=event_io_id,
        inputs=inputs,
        codec_label=_CODEC_LABELS[CODEC_8],
    ), cursor


# ---------------------------------------------------------------------------
# Codec 8 Extended — 2-byte IO IDs + 2-byte counts + NX variable-length section
# ---------------------------------------------------------------------------


def decode_codec8e(inner: bytes) -> list[DecodedAvlRecord]:
    """Decode the Codec-8 Extended inner block."""
    if len(inner) < 2:
        msg = f"codec-8E inner block too short: {len(inner)} bytes"
        raise MalformedPacketError(msg)

    num_data_1 = inner[0]
    # SH-05 — see decode_codec8 for the rationale.
    if num_data_1 > _MAX_RECORDS_PER_PACKET:
        msg = f"codec-8E NumData1={num_data_1} exceeds max_records={_MAX_RECORDS_PER_PACKET}"
        raise MalformedPacketError(msg)
    cursor = 1
    records: list[DecodedAvlRecord] = []
    for _ in range(num_data_1):
        record, cursor = _decode_codec8e_record(inner, cursor)
        records.append(record)

    if cursor != len(inner) - 1:
        msg = (
            f"codec-8E inner block has {len(inner) - 1 - cursor} unread "
            f"bytes after {num_data_1} records"
        )
        raise MalformedPacketError(msg)
    num_data_2 = inner[cursor]
    if num_data_1 != num_data_2:
        msg = f"NumData1 ({num_data_1}) != NumData2 ({num_data_2})"
        raise MalformedPacketError(msg)
    return records


def _decode_codec8e_record(inner: bytes, cursor: int) -> tuple[DecodedAvlRecord, int]:
    """Decode one Codec-8E record at ``cursor``."""
    # Same header + GPS as Codec 8 (24 bytes).
    if cursor + 24 > len(inner):
        msg = "truncated Codec-8E record header / GPS element"
        raise MalformedPacketError(msg)

    timestamp_ms = int.from_bytes(inner[cursor : cursor + 8], "big")
    cursor += 8
    priority = inner[cursor]
    cursor += 1

    lon_raw, lat_raw, alt, angle, satellites, speed = struct.unpack_from(">iihHBH", inner, cursor)
    cursor += 15

    # IO Element widens to: 2-byte event id, 2-byte total, then four sections
    # (N1/N2/N4/N8) each prefixed by a 2-byte count of {2-byte id + value}
    # entries, plus a fifth NX section for variable-length payloads.
    if cursor + 4 > len(inner):
        msg = "truncated Codec-8E IO header"
        raise MalformedPacketError(msg)
    event_io_id = int.from_bytes(inner[cursor : cursor + 2], "big")
    cursor += 2
    n_total = int.from_bytes(inner[cursor : cursor + 2], "big")
    cursor += 2

    inputs: dict[int, int] = {}
    counted = 0
    for value_size in (1, 2, 4, 8):
        if cursor + 2 > len(inner):
            msg = f"truncated Codec-8E N{value_size} count"
            raise MalformedPacketError(msg)
        n = int.from_bytes(inner[cursor : cursor + 2], "big")
        cursor += 2
        # SH-05 — refuse pathologically large counts before the loop.
        # Wire format allows up to 65535 here; cap to keep Python-side
        # iteration cost bounded under hostile input.
        if n > _MAX_IO_ENTRIES_PER_SECTION:
            msg = (
                f"codec-8E N{value_size} count {n} exceeds "
                f"max_io_entries={_MAX_IO_ENTRIES_PER_SECTION}"
            )
            raise MalformedPacketError(msg)
        entry_size = 2 + value_size
        if cursor + n * entry_size > len(inner):
            msg = f"truncated Codec-8E N{value_size} entries (need {n})"
            raise MalformedPacketError(msg)
        for _ in range(n):
            io_id = int.from_bytes(inner[cursor : cursor + 2], "big")
            cursor += 2
            value = int.from_bytes(inner[cursor : cursor + value_size], "big", signed=False)
            cursor += value_size
            inputs[io_id] = value
        counted += n

    # NX (variable-length) section. New in 8E. We discard the actual bytes —
    # they don't fit the (id → int) shape of ``inputs``; storing them is the
    # IngestionService's job (raw_packet bytes preserved verbatim, downstream
    # decoders for camera / RFID / tachograph parse from there). For now we
    # validate the layout and skip past.
    if cursor + 2 > len(inner):
        msg = "truncated Codec-8E NX count"
        raise MalformedPacketError(msg)
    nx = int.from_bytes(inner[cursor : cursor + 2], "big")
    cursor += 2
    # SH-05 — variable-length NX entries are reasonably few per record;
    # cap to prevent CPU + allocation amplification on hostile input.
    if nx > _MAX_NX_ENTRIES_PER_RECORD:
        msg = f"codec-8E NX count {nx} exceeds max_nx_entries={_MAX_NX_ENTRIES_PER_RECORD}"
        raise MalformedPacketError(msg)
    for _ in range(nx):
        if cursor + 4 > len(inner):
            msg = "truncated Codec-8E NX entry header"
            raise MalformedPacketError(msg)
        # 2-byte IO ID + 2-byte length + length bytes of payload.
        cursor += 2  # io id
        var_len = int.from_bytes(inner[cursor : cursor + 2], "big")
        cursor += 2
        if cursor + var_len > len(inner):
            msg = f"truncated Codec-8E NX entry payload (need {var_len} bytes)"
            raise MalformedPacketError(msg)
        cursor += var_len

    # NX entries do NOT count toward N-of-Total-IO per the protocol doc
    # (the table in §7 lists N-of-Total as the sum of N1+N2+N4+N8 widths only;
    # NX is its own section). counted must therefore equal n_total.
    if counted != n_total:
        msg = f"Codec-8E N-of-Total-IO ({n_total}) != sum N1+N2+N4+N8 ({counted})"
        raise MalformedPacketError(msg)

    return _build_decoded(
        timestamp_ms=timestamp_ms,
        priority=priority,
        lon_raw=lon_raw,
        lat_raw=lat_raw,
        alt_signed=alt,
        angle=angle,
        satellites=satellites,
        speed=speed,
        event_io_id=event_io_id,
        inputs=inputs,
        codec_label=_CODEC_LABELS[CODEC_8_EXTENDED],
    ), cursor


# ---------------------------------------------------------------------------
# Shared record-construction
# ---------------------------------------------------------------------------


def _build_decoded(
    *,
    timestamp_ms: int,
    priority: int,
    lon_raw: int,
    lat_raw: int,
    alt_signed: int,
    angle: int,
    satellites: int,
    speed: int,
    event_io_id: int,
    inputs: dict[int, int],
    codec_label: str,
) -> DecodedAvlRecord:
    """Translate codec-extracted ints into a :class:`DecodedAvlRecord`.

    Coord scale and tz-awareness apply here; rule **DP-12** mandates
    UTC at every boundary.
    """
    return DecodedAvlRecord(
        ts=datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC),
        priority=priority,
        position=GeoPoint(
            lon=lon_raw / 1e7,
            lat=lat_raw / 1e7,
            alt=float(alt_signed),
        ),
        speed_kmh=float(speed),
        heading_deg=float(angle),
        satellites=satellites,
        event_io_id=event_io_id,
        inputs=inputs,
        codec=codec_label,
    )
