"""C3 rolling matcher-health accumulator (AZ-344).

:class:`RollingHealthWindow` maintains the three accumulators that
back :class:`MatcherHealth` snapshots: ``consecutive_low_inlier``,
``mean_inliers_60s``, ``backbone_error_count_60s``. The 60 s window
is configurable for tests via the constructor.

The structure is intentionally **single-thread** — no locks. The
composition root binds every :class:`CrossDomainMatcher` to one
ingest thread (AC-9) so adding a lock here would only mask binding
bugs.

Data structure choice: a ``collections.deque`` of
``(timestamp_ns, inlier_count, had_backbone_error)`` tuples plus
two running sums (``_inlier_sum``, ``_error_sum``). Every
:meth:`update` call evicts expired entries from the left while
maintaining the sums — amortised O(1). :meth:`snapshot` is strict
O(1): it reads the sums and the current ``len(self._window)``
without touching the deque body, so the NFR-perf-window microbench
(p99 ≤ 50 µs) holds even with 6000 entries (100 Hz × 60 s).
"""

from __future__ import annotations

from collections import deque
from typing import Final

from gps_denied_onboard._types.matcher import MatcherHealth

__all__ = ["RollingHealthWindow"]


_DEFAULT_WINDOW_NS: Final[int] = 60 * 1_000_000_000


class RollingHealthWindow:
    """Sliding 60 s window over best-candidate inlier counts.

    Constructor-injected into every concrete :class:`CrossDomainMatcher`
    so all strategies share semantics (the alternative — every
    matcher reimplements the window — drifts between backbones and
    breaks C5's spoof-promotion gate consistency).
    """

    def __init__(
        self,
        *,
        min_inliers_threshold: int,
        window_ns: int = _DEFAULT_WINDOW_NS,
    ) -> None:
        if min_inliers_threshold < 1:
            raise ValueError(
                "min_inliers_threshold must be >= 1; "
                f"got {min_inliers_threshold}"
            )
        if window_ns < 1:
            raise ValueError(f"window_ns must be >= 1; got {window_ns}")
        self._min_inliers_threshold: int = min_inliers_threshold
        self._window_ns: int = window_ns
        # Each entry: (timestamp_ns, inlier_count, had_backbone_error)
        self._window: deque[tuple[int, int, bool]] = deque()
        self._inlier_sum: int = 0
        self._error_sum: int = 0
        self._consecutive_low_inlier: int = 0

    @property
    def window_ns(self) -> int:
        return self._window_ns

    def update(
        self,
        *,
        timestamp_ns: int,
        best_inlier_count: int,
        had_backbone_error: bool,
    ) -> None:
        """Record one frame's outcome and evict any expired entries.

        ``best_inlier_count`` is the BEST candidate's inlier count
        for the frame (zero if every candidate was dropped /
        below-threshold). ``had_backbone_error`` is ``True`` if at
        least one per-candidate
        :class:`MatcherBackboneError` fired in this frame.

        AC-12: ``consecutive_low_inlier`` increments when
        ``best_inlier_count < min_inliers_threshold``; resets to
        zero on any frame whose count meets or exceeds the floor.
        """
        if best_inlier_count < 0:
            raise ValueError(
                f"best_inlier_count must be >= 0; got {best_inlier_count}"
            )
        cutoff = timestamp_ns - self._window_ns
        window = self._window
        while window and window[0][0] <= cutoff:
            _ts, expired_inliers, expired_error = window.popleft()
            self._inlier_sum -= expired_inliers
            if expired_error:
                self._error_sum -= 1
        window.append((timestamp_ns, best_inlier_count, had_backbone_error))
        self._inlier_sum += best_inlier_count
        if had_backbone_error:
            self._error_sum += 1
        if best_inlier_count < self._min_inliers_threshold:
            self._consecutive_low_inlier += 1
        else:
            self._consecutive_low_inlier = 0

    def snapshot(self) -> MatcherHealth:
        """Return the current :class:`MatcherHealth` snapshot.

        O(1): reads the running sums + ``len(self._window)`` only.
        Empty window → ``mean_inliers_60s == 0.0`` (consumers
        treat zero as "insufficient data" rather than "zero matches").
        """
        count = len(self._window)
        mean = (self._inlier_sum / count) if count else 0.0
        return MatcherHealth(
            consecutive_low_inlier=self._consecutive_low_inlier,
            mean_inliers_60s=mean,
            backbone_error_count_60s=self._error_sum,
        )
