"""Vendor-neutral AVL frame types — the codec-agnostic data vocabulary.

Every telematics vendor's codec (Teltonika, Queclink, Ruptela, …)
normalises its wire bytes into these same shapes, so they live in the
neutral core rather than next to any one codec:

  * :class:`GeoPoint` — a WGS-84 position.
  * :class:`DecodedAvlRecord` — one decoded observation (the codec's
    output; identity is resolved downstream by the consumer).
  * :class:`ReceivedPacket` — one packet as it arrived on the wire
    (raw bytes + decoded records + parse status), the A-01 forensic
    envelope a consumer persists regardless of parse outcome.

Per rule **DP-03** these are frozen dataclasses; per **DP-12** every
datetime is expected UTC at the boundary.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Literal

if TYPE_CHECKING:
    from collections.abc import Mapping
    from datetime import datetime

__all__ = [
    "DecodedAvlRecord",
    "GeoPoint",
    "ParseStatus",
    "ReceivedPacket",
]


ParseStatus = Literal[
    "ok",
    "malformed",
    "checksum_fail",
    "unsupported_codec",
    "unknown_imei",
    "pre_handshake",
]
"""Parse-outcome enum for a received packet.

Stable contract — used in serialised audit logs. Anything beyond
``"ok"`` means the packet was preserved verbatim but produced no
decoded records; ``parse_error`` carries the diagnostic.
"""


@dataclass(frozen=True, slots=True)
class GeoPoint:
    """A point in WGS-84 (SRID 4326).

    ``lon`` / ``lat`` are degrees; ``alt`` is metres above the ellipsoid
    (``None`` when the fix is degraded / altitude absent).
    """

    lon: float
    lat: float
    alt: float | None = None


@dataclass(frozen=True, slots=True)
class DecodedAvlRecord:
    """Vendor-agnostic intermediate produced by a codec.

    Carries exactly what the bytes give us: timestamp, priority,
    GPS + IO observations, and the originating codec id (for
    provenance). Identity fields (tenant / tracker / asset / record id /
    received_at) are filled by the consumer's ingestion layer after
    asset resolution + raw-packet persistence.
    """

    ts: datetime
    priority: int
    position: GeoPoint
    speed_kmh: float
    heading_deg: float
    satellites: int
    event_io_id: int
    inputs: Mapping[int, int]
    codec: str


@dataclass(frozen=True, slots=True)
class ReceivedPacket:
    """One AVL packet as it arrived on the wire (forensic envelope).

    Carries the raw bytes alongside the decoded records — or, when
    decode failed, just the bytes plus the error context. A consumer
    persists a raw-packet row from this regardless of parse outcome,
    then (on success only) the linked decoded records. Always carrying
    the bytes is deliberate: forensic / legal-dispute / replay-through-
    parser paths need the wire bytes even when today's decoder rejects
    them.

    ``codec_id`` is the byte from the wire (or 0 if framing was so bad
    we never reached it) — recorded even on failure ("we saw a packet
    claiming codec X but couldn't decode it" is a useful signal).
    """

    raw_bytes: bytes
    transport: Literal["tcp", "udp"]
    codec_id: int
    parse_status: ParseStatus
    records: list[DecodedAvlRecord] = field(default_factory=list)
    """Decoded records on success; empty list on parse failure."""
    parse_error: str | None = None
    """Free-text error message; ``None`` when ``parse_status == "ok"``."""
