"""Outbound provenance side-channel helpers (AZ-393 / AZ-394 / E-C8).

Two pieces shared by AP and iNav outbound paths:

1. :func:`source_label_to_float` — deterministic
   :class:`PoseSourceLabel` → ``float`` mapping consumed by AP's
   ``NAMED_VALUE_FLOAT(name="src_lbl")`` side-channel. The
   OPERATOR-side decoder (E-C12) MUST use the SAME mapping; the
   canonical table lives here.

2. :class:`StatusTextTransitionRateLimiter` — emits ``STATUSTEXT(...)``
   exactly once per ``source_label`` transition (AC-4 / AZ-393 AC-3 /
   AZ-394 AC-3), with a defensive 1-per-second per-severity hard cap
   against pathological transition spam (constraint § 4 of AZ-393).

The rate limiter has no I/O; the adapter passes ``send_statustext``
in. This keeps the helper pure for unit testing and matches the
per-adapter wire dispatch responsibility.
"""

from __future__ import annotations

import threading
import time
from collections.abc import Callable
from typing import Final

from gps_denied_onboard._types.fc import Severity
from gps_denied_onboard._types.state import PoseSourceLabel

__all__ = [
    "SOURCE_LABEL_TO_FLOAT",
    "StatusTextTransitionRateLimiter",
    "source_label_to_float",
]


# Canonical source-label-to-float mapping (AZ-393 AC-3 / D-C8-7).
# Operator-side decoder in C12 MUST mirror this table. Keys are the
# string values of :class:`PoseSourceLabel`; the dict is materialised
# from the enum so the two cannot drift.
SOURCE_LABEL_TO_FLOAT: Final[dict[str, float]] = {
    PoseSourceLabel.VISUAL_PROPAGATED.value: 1.0,
    PoseSourceLabel.SATELLITE_ANCHORED.value: 2.0,
    PoseSourceLabel.DEAD_RECKONED.value: 3.0,
}
_UNKNOWN_LABEL_FLOAT: Final[float] = 0.0


def source_label_to_float(label: PoseSourceLabel | str) -> float:
    """Return the canonical float encoding for ``label``.

    Accepts :class:`PoseSourceLabel` (production path) or a raw
    string (legacy / replay decoders). Unknown strings map to
    ``0.0``; unknown enum members can never happen because every
    member is in the table by construction.
    """
    key = label.value if isinstance(label, PoseSourceLabel) else label
    return SOURCE_LABEL_TO_FLOAT.get(key, _UNKNOWN_LABEL_FLOAT)


class StatusTextTransitionRateLimiter:
    """Emits a STATUSTEXT exactly once per ``source_label`` transition.

    Defensive secondary cap: at most one emit per second per severity,
    in case a producer flaps source_label every frame at 5 Hz.

    Thread-safe; ``note_label_and_maybe_emit`` may be called from any
    thread. The send callback is invoked OUTSIDE the lock.
    """

    def __init__(
        self,
        send_statustext: Callable[[str, Severity], None],
        *,
        min_interval_s: float = 1.0,
        clock: Callable[[], float] = time.monotonic,
    ) -> None:
        self._send = send_statustext
        self._min_interval_s = min_interval_s
        self._clock = clock
        self._lock = threading.Lock()
        self._last_label: PoseSourceLabel | str | None = None
        self._last_emit_at_by_sev: dict[Severity, float] = {}

    def note_label_and_maybe_emit(
        self,
        new_label: PoseSourceLabel | str,
        *,
        severity: Severity = Severity.INFO,
    ) -> bool:
        """Record ``new_label`` and emit a STATUSTEXT iff this is a transition.

        Returns ``True`` if a STATUSTEXT was emitted, ``False`` otherwise.
        """
        with self._lock:
            if self._last_label == new_label:
                return False
            previous = self._last_label
            self._last_label = new_label
            # Hard cap: at most one emit per second per severity.
            now = self._clock()
            last_emit = self._last_emit_at_by_sev.get(severity, float("-inf"))
            if (now - last_emit) < self._min_interval_s:
                return False
            self._last_emit_at_by_sev[severity] = now
            new_text = _label_text(new_label)
            prev_text = _label_text(previous) if previous is not None else None
            msg = f"src={new_text}" if prev_text is None else f"src {prev_text}->{new_text}"
        # Send OUTSIDE the lock — pymavlink statustext_send may block on UART.
        self._send(msg, severity)
        return True

    @property
    def last_label(self) -> PoseSourceLabel | str | None:
        with self._lock:
            return self._last_label


def _label_text(label: PoseSourceLabel | str) -> str:
    return label.value if isinstance(label, PoseSourceLabel) else label
