"""FDR record schema + versioned (de)serialiser (AZ-272 / E-CC-FDR-CLIENT).

Public surface frozen by
`_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md` v1.0.0.

The library backing ``serialise`` / ``parse`` (``orjson``) is pinned in
``pyproject.toml`` and intentionally hidden from the public API — callers
trade ``FdrRecord <-> bytes`` only.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Final

import orjson

__all__ = [
    "CURRENT_SCHEMA_VERSION",
    "KNOWN_KINDS",
    "MAX_INLINE_BLOB_BYTES",
    "OVERRUN_KIND",
    "OVERRUN_PRODUCER_ID",
    "FdrRecord",
    "FdrSchemaError",
    "parse",
    "serialise",
]

CURRENT_SCHEMA_VERSION: Final[int] = 1

OVERRUN_KIND: Final[str] = "overrun"
OVERRUN_PRODUCER_ID: Final[str] = "shared.fdr_client"

# Per-kind allowed payload keys. The parser uses this to route unknown future
# fields into ``payload["extra"]`` (forward-compat AC-2). Unknown ``kind`` values
# bypass the table and are returned opaquely (AC-3).
KNOWN_PAYLOAD_KEYS: Final[dict[str, frozenset[str]]] = {
    "log": frozenset({"level", "component", "frame_id", "kind", "msg", "kv", "exc"}),
    "vio.tick": frozenset(
        {"frame_id", "R", "t", "P", "last_anchor_age_ms", "mre_px", "imu_bias_norm"}
    ),
    # AZ-332 / E-C1: emitted on every VioStrategy state transition
    # (INIT->TRACKING->DEGRADED->LOST etc.). One record per transition;
    # steady-state frames emit nothing on this kind. `frame_id` is the
    # frame the transition was decided on (may be empty for INIT->...).
    "vio.health": frozenset(
        {"state", "consecutive_lost", "bias_norm", "strategy_label", "frame_id"}
    ),
    "state.tick": frozenset({"frame_id", "fused_pose", "covariance_2x2", "estimator_label"}),
    "tile_match": frozenset({"frame_id", "tile_id", "score", "match_count", "ransac_inliers"}),
    "overrun": frozenset({"producer_id", "dropped_count"}),
    "segment_rollover": frozenset({"old_segment", "new_segment", "total_bytes_after"}),
    "failed_tile_thumbnail": frozenset({"frame_id", "tile_id", "jpeg_bytes_b64"}),
    "mid_flight_tile_snapshot": frozenset({"snapshot_path", "captured_at", "frame_id"}),
    "flight_header": frozenset(
        {
            "flight_id",
            "flight_started_at_iso",
            "flight_started_at_monotonic_ns",
            "config_snapshot",
            "signing_key_rotation_event",
            "manifest_content_hashes",
            "build_info",
        }
    ),
    "flight_footer": frozenset(
        {
            "flight_id",
            "flight_ended_at_iso",
            "flight_ended_at_monotonic_ns",
            "records_written",
            "records_dropped_overrun",
            "bytes_written",
            "rollover_count",
            "clean_shutdown",
        }
    ),
    # AZ-490 / E-C5: operator-origin cold-start ladder + bounded-delta GPS gate.
    "c5.cold_start_origin.set": frozenset(
        {"source", "lat_deg", "lon_deg", "alt_m", "sigma_horiz_m", "sigma_vert_m"}
    ),
    "c5.cold_start_origin.unavailable": frozenset({"reason"}),
    "c5.gps_bounded_delta.accept": frozenset(
        {
            "sample_lat",
            "sample_lon",
            "smoother_lat",
            "smoother_lon",
            "distance_m",
            "threshold_m",
        }
    ),
    "c5.gps_bounded_delta.reject": frozenset(
        {
            "sample_lat",
            "sample_lon",
            "smoother_lat",
            "smoother_lon",
            "distance_m",
            "threshold_m",
        }
    ),
    # AZ-302 / E-C7: emitted on every thermal-throttle transition (False->True or
    # True->False). One record per flip; steady-state polls emit nothing on this
    # kind. `previous_state` and `new_state` are the throttle booleans pre/post
    # transition; temperatures and clock are taken from the same poll cycle.
    "c7.thermal_transition": frozenset(
        {
            "previous_state",
            "new_state",
            "gpu_temp_c",
            "cpu_temp_c",
            "measured_clock_mhz",
            "measured_at_ns",
        }
    ),
}

KNOWN_KINDS: Final[frozenset[str]] = frozenset(KNOWN_PAYLOAD_KEYS.keys())

# Inline binary blob cap; bigger payloads must reference a sidecar path.
MAX_INLINE_BLOB_BYTES: Final[int] = 4 * 1024


class FdrSchemaError(ValueError):
    """Raised on schema-violation in ``serialise`` / ``parse`` (AZ-272).

    The ONLY exception type either function raises on schema-violation; orjson
    / library-specific errors are wrapped at the public boundary.
    """


@dataclass(frozen=True)
class FdrRecord:
    """Frozen FDR record envelope per contract v1.0.0."""

    schema_version: int
    ts: str
    producer_id: str
    kind: str
    payload: dict[str, Any] = field(default_factory=dict)
    extra: dict[str, Any] = field(default_factory=dict)


_ENVELOPE_REQUIRED: Final[tuple[str, ...]] = (
    "schema_version",
    "ts",
    "producer_id",
    "kind",
    "payload",
)


def _validate_envelope_outgoing(record: FdrRecord) -> None:
    if not isinstance(record.schema_version, int) or isinstance(record.schema_version, bool):
        raise FdrSchemaError(
            f"FdrRecord.schema_version must be a non-bool integer; got {record.schema_version!r}"
        )
    if record.schema_version < 1:
        raise FdrSchemaError(f"FdrRecord.schema_version must be >= 1; got {record.schema_version}")
    if not isinstance(record.ts, str) or not record.ts:
        raise FdrSchemaError(f"FdrRecord.ts must be a non-empty string; got {record.ts!r}")
    if not isinstance(record.producer_id, str) or not record.producer_id:
        raise FdrSchemaError(
            f"FdrRecord.producer_id must be a non-empty string; got {record.producer_id!r}"
        )
    if not isinstance(record.kind, str) or not record.kind:
        raise FdrSchemaError(f"FdrRecord.kind must be a non-empty string; got {record.kind!r}")
    if not isinstance(record.payload, dict):
        raise FdrSchemaError(
            f"FdrRecord.payload must be a dict; got {type(record.payload).__name__}"
        )
    if record.extra:
        raise FdrSchemaError(
            "FdrRecord.extra is populated only by the parser; producers must leave it empty"
        )
    _validate_payload_size(record.payload)
    if record.kind == OVERRUN_KIND:
        _validate_overrun_payload(record.payload)


def _validate_payload_size(payload: dict[str, Any]) -> None:
    """Reject any single binary blob >MAX_INLINE_BLOB_BYTES inside the payload."""
    for key, value in payload.items():
        if isinstance(value, (bytes, bytearray)) and len(value) > MAX_INLINE_BLOB_BYTES:
            raise FdrSchemaError(
                f"FdrRecord.payload[{key!r}] is {len(value)} bytes; "
                f"max inline blob is {MAX_INLINE_BLOB_BYTES} bytes — use a sidecar path"
            )


def _validate_overrun_payload(payload: dict[str, Any]) -> None:
    inner_producer_id = payload.get("producer_id")
    if not isinstance(inner_producer_id, str) or not inner_producer_id:
        raise FdrSchemaError(
            "overrun record: payload.producer_id must be a non-empty string identifying the "
            "originating producer"
        )
    dropped_count = payload.get("dropped_count")
    if not isinstance(dropped_count, int) or isinstance(dropped_count, bool):
        raise FdrSchemaError(
            "overrun record: payload.dropped_count must be a non-bool integer; "
            f"got {dropped_count!r}"
        )
    if dropped_count <= 0:
        raise FdrSchemaError(
            f"overrun record: payload.dropped_count must be > 0; got {dropped_count}"
        )


def serialise(record: FdrRecord) -> bytes:
    """Encode ``record`` to wire bytes (single-line UTF-8 JSON, ``orjson``-backed)."""
    _validate_envelope_outgoing(record)
    envelope: dict[str, Any] = {
        "schema_version": record.schema_version,
        "ts": record.ts,
        "producer_id": record.producer_id,
        "kind": record.kind,
        "payload": record.payload,
    }
    try:
        return orjson.dumps(envelope)
    except (TypeError, orjson.JSONEncodeError) as exc:
        raise FdrSchemaError(f"failed to serialise FdrRecord: {exc}") from exc


def parse(buf: bytes) -> FdrRecord:
    """Decode wire bytes to an ``FdrRecord``; forward-compatible per contract AC-2/3."""
    if not isinstance(buf, (bytes, bytearray)):
        raise FdrSchemaError(f"parse expects bytes; got {type(buf).__name__}")
    try:
        decoded = orjson.loads(buf)
    except orjson.JSONDecodeError as exc:
        raise FdrSchemaError(f"failed to decode FdrRecord bytes: {exc}") from exc

    if not isinstance(decoded, dict):
        raise FdrSchemaError(
            f"FdrRecord wire payload must decode to a dict; got {type(decoded).__name__}"
        )
    missing = [k for k in _ENVELOPE_REQUIRED if k not in decoded]
    if missing:
        raise FdrSchemaError(f"FdrRecord missing required field(s): {', '.join(missing)}")

    schema_version = decoded.pop("schema_version")
    if not isinstance(schema_version, int) or isinstance(schema_version, bool):
        raise FdrSchemaError(
            f"FdrRecord.schema_version must be a non-bool integer; got {schema_version!r}"
        )
    if schema_version < 1:
        raise FdrSchemaError(f"FdrRecord.schema_version must be >= 1; got {schema_version}")

    ts = decoded.pop("ts")
    if not isinstance(ts, str) or not ts:
        raise FdrSchemaError(f"FdrRecord.ts must be a non-empty string; got {ts!r}")
    producer_id = decoded.pop("producer_id")
    if not isinstance(producer_id, str) or not producer_id:
        raise FdrSchemaError(
            f"FdrRecord.producer_id must be a non-empty string; got {producer_id!r}"
        )
    kind = decoded.pop("kind")
    if not isinstance(kind, str) or not kind:
        raise FdrSchemaError(f"FdrRecord.kind must be a non-empty string; got {kind!r}")
    payload = decoded.pop("payload")
    if not isinstance(payload, dict):
        raise FdrSchemaError(f"FdrRecord.payload must be a dict; got {type(payload).__name__}")

    # Anything left at the top level after popping required + payload is forward-compat extra.
    extra = dict(decoded)

    # Forward-compat payload sweep: for known kinds, keys outside the v1.0.0
    # set are stashed in payload["extra"] so future v1.x producers can add new
    # fields without breaking v1.0 readers.
    known_keys = KNOWN_PAYLOAD_KEYS.get(kind)
    if known_keys is not None:
        unknown_keys = [k for k in payload.keys() if k not in known_keys and k != "extra"]
        if unknown_keys:
            extra_bucket = dict(payload.get("extra") or {})
            for key in unknown_keys:
                extra_bucket[key] = payload.pop(key)
            payload["extra"] = extra_bucket

    if kind == OVERRUN_KIND:
        _validate_overrun_payload(payload)

    return FdrRecord(
        schema_version=schema_version,
        ts=ts,
        producer_id=producer_id,
        kind=kind,
        payload=payload,
        extra=extra,
    )
