"""``RecordKindPolicy`` — AC-8.5 / RESTRICT-UAV-4 record-kind gates (AZ-295).

Two paired gates with intentionally asymmetric semantics:

- ``enforce_or_raise(record)`` — producer-side synchronous check. Raises
  :class:`RawFrameWriteForbiddenError` when ``record.kind`` is in the
  configured forbidden set; returns silently otherwise. Producers call
  this immediately BEFORE ``fdr_client.enqueue(record)``.

- ``gate_for_writer(record)`` — writer-side soft rate cap on
  ``kind="failed_tile_thumbnail"``. Returns ``GateDecision.ENQUEUE``
  for in-cap records and ``GateDecision.DROP`` for over-cap thumbnails.
  Drops accumulate into a per-window ``dropped_count`` that is emitted
  as a single coalesced ``kind="overrun"`` record at the close of each
  window (matches AZ-274 overrun semantics).

The two gates exist together so a forbidden-kind regression in a
producer is caught at the call site (security failure visible to the
offending caller), and a thumbnail-flood regression is caught on the
write path without exploding error counts (rate-cap with audit
trail).
"""

from __future__ import annotations

import enum
import threading
import time
from collections.abc import Iterable
from datetime import datetime, timezone

from gps_denied_onboard.components.c13_fdr.errors import (
    RawFrameWriteForbiddenError,
)
from gps_denied_onboard.config import RecordKindPolicyConfig
from gps_denied_onboard.fdr_client.records import (
    OVERRUN_KIND,
    OVERRUN_PRODUCER_ID,
    FdrRecord,
)
from gps_denied_onboard.logging import get_logger

__all__ = ["GateDecision", "RecordKindPolicy", "make_record_kind_policy"]

_THUMBNAIL_KIND = "failed_tile_thumbnail"
_LOG_RATE_LIMIT_S = 1.0


class GateDecision(enum.Enum):
    ENQUEUE = "enqueue"
    DROP = "drop"


class _ThumbnailRateCap:
    """Per-window admission counter for `failed_tile_thumbnail` records.

    Maintains a single window starting at the time of the first record;
    the window is ``(1.0 / max_hz)`` seconds wide. Up to one thumbnail
    is admitted per window; subsequent records are counted into
    ``dropped_in_current_window`` until the window closes.

    Window close emits a coalesced overrun record carrying the
    accumulated drop count.
    """

    def __init__(self, max_hz: float) -> None:
        self._window_s = 1.0 / max_hz
        self._window_start_mono: float | None = None
        self._admitted_in_window = 0
        self._dropped_in_window = 0
        self._dropped_producer: str | None = None
        self._lock = threading.Lock()

    def admit(self, producer_id: str) -> bool:
        now = time.monotonic()
        with self._lock:
            if self._window_start_mono is None or now - self._window_start_mono >= self._window_s:
                # Window closed (or first call). Reset.
                self._window_start_mono = now
                self._admitted_in_window = 0
                self._dropped_in_window = 0
                self._dropped_producer = None
            if self._admitted_in_window == 0:
                self._admitted_in_window = 1
                return True
            self._dropped_in_window += 1
            self._dropped_producer = producer_id
            return False

    def drain_dropped(self) -> tuple[int, str | None]:
        """Return ``(dropped_count, producer_id)`` and clear the accumulator."""
        with self._lock:
            count = self._dropped_in_window
            producer = self._dropped_producer
            self._dropped_in_window = 0
            self._dropped_producer = None
            return count, producer


class RecordKindPolicy:
    """Per-flight record-kind policy (AZ-295)."""

    def __init__(self, config: RecordKindPolicyConfig) -> None:
        if not isinstance(config, RecordKindPolicyConfig):
            raise TypeError(
                f"RecordKindPolicy.config must be RecordKindPolicyConfig; "
                f"got {type(config).__name__}"
            )
        self._forbidden_kinds: frozenset[str] = config.forbidden_record_kinds
        self._rate_cap = _ThumbnailRateCap(max_hz=config.failed_tile_thumbnail_max_hz)
        self._last_warn_t = 0.0
        self._log = get_logger("c13_fdr.record_kind_policy")

    @property
    def forbidden_kinds(self) -> frozenset[str]:
        return self._forbidden_kinds

    def enforce_or_raise(self, record: FdrRecord) -> None:
        """Producer-side synchronous gate.

        Raises ``RawFrameWriteForbiddenError`` if ``record.kind`` is in
        the configured forbidden set; returns silently otherwise.
        """
        if record.kind in self._forbidden_kinds:
            raise RawFrameWriteForbiddenError(
                f"FdrRecord kind={record.kind!r} from producer {record.producer_id!r} "
                f"is forbidden by RecordKindPolicy"
            )

    def gate_for_writer(self, record: FdrRecord) -> GateDecision:
        """Writer-side rate-cap gate for ``failed_tile_thumbnail`` records.

        Returns :attr:`GateDecision.ENQUEUE` for non-thumbnail records
        and for the first thumbnail in each window. Returns
        :attr:`GateDecision.DROP` for over-cap thumbnails; the drop is
        recorded into the rate cap's accumulator so a single coalesced
        overrun record is emitted via :meth:`drain_pending_overrun`.
        """
        if record.kind != _THUMBNAIL_KIND:
            return GateDecision.ENQUEUE
        producer_id = record.producer_id or OVERRUN_PRODUCER_ID
        if self._rate_cap.admit(producer_id):
            return GateDecision.ENQUEUE
        self._maybe_warn(producer_id)
        return GateDecision.DROP

    def drain_pending_overrun(self) -> FdrRecord | None:
        """Return a coalesced overrun record for any thumbnails dropped
        since the previous drain, or ``None`` if the window is empty.

        The writer-thread calls this at end-of-batch so over-cap drops
        surface as a canonical overrun trail in the FDR.
        """
        dropped, producer = self._rate_cap.drain_dropped()
        if dropped <= 0:
            return None
        return FdrRecord(
            schema_version=1,
            ts=datetime.now(tz=timezone.utc).isoformat(),
            producer_id=OVERRUN_PRODUCER_ID,
            kind=OVERRUN_KIND,
            payload={
                "producer_id": producer or "shared.fdr_client",
                "dropped_count": dropped,
            },
        )

    def _maybe_warn(self, producer_id: str) -> None:
        now = time.monotonic()
        if now - self._last_warn_t < _LOG_RATE_LIMIT_S:
            return
        self._last_warn_t = now
        self._log.warning(
            f"fdr.thumbnail_rate_cap_exceeded: producer_id={producer_id}",
            extra={
                "kind": "fdr.thumbnail_rate_cap_exceeded",
                "kv": {"producer_id": producer_id},
            },
        )


def make_record_kind_policy(config: RecordKindPolicyConfig) -> RecordKindPolicy:
    """Composition-root factory for :class:`RecordKindPolicy`."""
    return RecordKindPolicy(config)


def is_legitimate_kind(kind: str, *, legitimate_kinds: Iterable[str]) -> bool:
    """Helper used by the AZ-272 contract test: a forbidden-kind set
    must NOT contain any kind from the legitimate v1.x closed enum.
    """
    return kind in set(legitimate_kinds)
