"""C3 cross-domain matcher DTOs (L1 cross-component layer; AZ-344).

Frozen by ``contracts/c3_matcher/cross_domain_matcher_protocol.md``
v1.0.0: three slotted, immutable dataclasses
(:class:`CandidateMatchSet`, :class:`MatchResult`,
:class:`MatcherHealth`).

:class:`CandidateMatchSet.tile_id` is a plain
``tuple[int, float, float]`` of ``(zoom_level, lat, lon)`` —
identical encoding to :class:`VprCandidate.tile_id` /
:class:`RerankCandidate.tile_id` — keeping the L1 layer free of an
L1→L3 import per ``module-layout.md`` (consumers reconstruct
:class:`gps_denied_onboard.components.c6_tile_cache.TileId` at the
C6 boundary).

:class:`MatchResult.per_candidate` is a ``tuple`` (not a ``list``)
so the ``frozen=True, slots=True`` invariant truly holds — a frozen
dataclass holding a mutable list lets consumers mutate it; the
tuple closes that door.
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np

__all__ = ["CandidateMatchSet", "MatchResult", "MatcherHealth"]


@dataclass(frozen=True, slots=True)
class CandidateMatchSet:
    """Per-candidate matching outcome inside a :class:`MatchResult`.

    ``inlier_correspondences`` is shape ``(I, 4)`` ``float32`` with
    columns ``(px_query, py_query, px_tile, py_tile)``; rows are
    RANSAC inliers only so ``I == inlier_count``.

    ``per_candidate_residual_px`` is the MEDIAN reprojection
    residual on inliers — not the mean, not the max. C3.5's
    threshold gate compares against this value (INV-8).
    """

    tile_id: tuple[int, float, float]
    inlier_count: int
    inlier_correspondences: np.ndarray
    ransac_outlier_count: int
    per_candidate_residual_px: float


@dataclass(frozen=True, slots=True)
class MatchResult:
    """Cross-domain match outcome for one frame.

    Consumed by C3.5 :class:`ConditionalRefiner` (AZ-348) — which
    may either pass the result through unchanged or emit a new
    instance via :func:`dataclasses.replace` with the refinement
    fields set.

    The ``per_candidate`` tuple is sorted descending by
    ``inlier_count`` with ties broken ascending by
    ``per_candidate_residual_px`` (INV-3) so
    ``best_candidate_idx == 0`` by construction.

    ``reprojection_residual_px`` is the best candidate's median
    residual (mirrors ``per_candidate[0].per_candidate_residual_px``)
    surfaced separately so consumers do not have to know the
    ranking encoding.

    The two refinement fields are populated by C3.5; they default
    to the passthrough values so a C3 producer that never goes
    through C3.5 still yields a valid downstream-readable
    :class:`MatchResult` (AZ-348 AC-2). ``refinement_label`` is
    one of ``"adhop"`` or ``"passthrough"``;
    ``refinement_added_latency_ms`` covers exactly the work done
    inside :meth:`ConditionalRefiner.refine_if_needed` and is
    ``0.0`` on pure passthrough.
    """

    frame_id: int
    per_candidate: tuple[CandidateMatchSet, ...]
    best_candidate_idx: int
    reprojection_residual_px: float
    matched_at: int
    matcher_label: str
    candidates_input: int
    candidates_dropped: int
    refinement_label: str = "passthrough"
    refinement_added_latency_ms: float = 0.0


@dataclass(frozen=True, slots=True)
class MatcherHealth:
    """Rolling-window matcher health snapshot.

    Produced by :meth:`CrossDomainMatcher.health_snapshot`. Drives
    C5's spoof-promotion gate (AC-NEW-2 / AC-NEW-7) and post-flight
    forensics.

    ``consecutive_low_inlier`` is the count of CONSECUTIVE recent
    frames whose best-candidate inlier count fell below the
    configured ``min_inliers_threshold`` floor; it resets to zero on
    any frame whose inlier count meets or exceeds the floor
    (INV-12).

    ``mean_inliers_60s`` is the rolling 60 s mean of best-candidate
    inlier counts. ``backbone_error_count_60s`` counts per-candidate
    :class:`MatcherBackboneError` occurrences in the same window.
    """

    consecutive_low_inlier: int
    mean_inliers_60s: float
    backbone_error_count_60s: int
