"""AZ-348 — C3.5 ConditionalRefiner Protocol + DTO + error + factory conformance.

Covers AC-1..AC-14 + NFRs. AC-9 (single-thread binding) is
deferred per the task spec's Risk-4 escape clause — the generic
``compose_root`` thread-binding registry lives with AZ-270 and
the broader runtime-root composition. Each factory owns its own
thread binding today; this protocol task does not add a new
binding registry.

AC-7 (strategy resolution table) is asserted up to the lookup
point for ``"adhop"``: the AdHoP module is the AZ-349 placeholder
that does not exist yet, so the assertion on that path stops at
``ModuleNotFoundError`` — explicitly documented as the "import +
class lookup not __init__" rule in the task spec.
"""

from __future__ import annotations

import dataclasses
import logging
import sys
import time

import numpy as np
import pytest

from gps_denied_onboard._types.matcher import (
    CandidateMatchSet,
    MatchResult,
)
from gps_denied_onboard.components.c3_5_adhop import (
    C3_5RefinerConfig,
    ConditionalRefiner,
)
from gps_denied_onboard.components.c3_5_adhop.config import KNOWN_STRATEGIES
from gps_denied_onboard.components.c3_5_adhop.errors import (
    RefinerBackboneError,
    RefinerConfigError,
    RefinerError,
)
from gps_denied_onboard.components.c3_5_adhop.passthrough_refiner import (
    PassthroughRefiner,
    create,
)
from gps_denied_onboard.config.schema import Config, ConfigError
from gps_denied_onboard.runtime_root.refiner_factory import build_refiner_strategy


# ----------------------------------------------------------------------
# Fakes.


class _FakeRansacFilter:
    def filter(self, *args, **kwargs):
        raise NotImplementedError


class _FakeInferenceRuntime:
    def deserialize_engine(self, *args, **kwargs):
        raise NotImplementedError

    def thermal_state(self):
        raise NotImplementedError


class _FakeFrame:
    frame_id = 7


class _PartialRefinerMissingWasInvoked:
    def refine_if_needed(self, frame, mr, residual_threshold_px):
        raise NotImplementedError


class _PartialRefinerMissingRefine:
    def was_invoked(self):
        raise NotImplementedError


def _config_with_strategy(
    strategy: str = "passthrough",
    *,
    threshold: float = 2.5,
) -> Config:
    return Config.with_blocks(
        c3_5_adhop=C3_5RefinerConfig(
            strategy=strategy,
            residual_threshold_px=threshold,
        )
    )


def _make_candidate(*, inliers: int = 40, residual: float = 1.0) -> CandidateMatchSet:
    return CandidateMatchSet(
        tile_id=(18, 49.9, 36.3),
        inlier_count=inliers,
        inlier_correspondences=np.ones((inliers, 4), dtype=np.float32) * 0.5,
        ransac_outlier_count=3,
        per_candidate_residual_px=residual,
    )


def _make_result(
    *,
    reprojection_residual: float = 1.0,
    refinement_label: str = "passthrough",
    refinement_latency_ms: float = 0.0,
) -> MatchResult:
    candidate = _make_candidate()
    return MatchResult(
        frame_id=7,
        per_candidate=(candidate,),
        best_candidate_idx=0,
        reprojection_residual_px=reprojection_residual,
        matched_at=1_000_000_000,
        matcher_label="disk_lightglue",
        candidates_input=3,
        candidates_dropped=2,
        refinement_label=refinement_label,
        refinement_added_latency_ms=refinement_latency_ms,
    )


# ----------------------------------------------------------------------
# AC-1: Protocol conformance.


def test_ac1_passthrough_refiner_conformance() -> None:
    refiner = PassthroughRefiner(
        ransac_filter=_FakeRansacFilter(),
        inference_runtime=_FakeInferenceRuntime(),
    )
    assert isinstance(refiner, ConditionalRefiner)


@pytest.mark.parametrize(
    "partial_cls",
    [_PartialRefinerMissingWasInvoked, _PartialRefinerMissingRefine],
)
def test_ac1_partial_refiners_fail_conformance(partial_cls) -> None:
    assert not isinstance(partial_cls(), ConditionalRefiner)


# ----------------------------------------------------------------------
# AC-2: MatchResult backward-compatible defaults.


def test_ac2_match_result_construction_without_new_fields() -> None:
    candidate = _make_candidate()
    mr = MatchResult(
        frame_id=7,
        per_candidate=(candidate,),
        best_candidate_idx=0,
        reprojection_residual_px=candidate.per_candidate_residual_px,
        matched_at=1_000_000_000,
        matcher_label="disk_lightglue",
        candidates_input=3,
        candidates_dropped=2,
    )
    assert mr.refinement_label == "passthrough"
    assert mr.refinement_added_latency_ms == 0.0


# ----------------------------------------------------------------------
# AC-3: MatchResult immutability + slots preserved.


def test_ac3_match_result_slots_include_new_fields() -> None:
    assert "refinement_label" in MatchResult.__slots__
    assert "refinement_added_latency_ms" in MatchResult.__slots__


def test_ac3_match_result_remains_frozen() -> None:
    mr = _make_result()
    with pytest.raises(dataclasses.FrozenInstanceError):
        mr.refinement_label = "adhop"
    with pytest.raises(dataclasses.FrozenInstanceError):
        mr.refinement_added_latency_ms = 12.5


# ----------------------------------------------------------------------
# AC-4: Factory rejects unknown strategy. (Validated at config-load.)


@pytest.mark.parametrize(
    "bad_label",
    ["ADHOP", "passthrough_v2", "garbage", "", "default"],
)
def test_ac4_unknown_strategy_rejected_at_config_load(bad_label: str) -> None:
    with pytest.raises(ConfigError) as exc_info:
        C3_5RefinerConfig(strategy=bad_label)
    msg = str(exc_info.value)
    for valid in KNOWN_STRATEGIES:
        assert valid in msg


def test_ac4_factory_emits_error_log_on_unknown_strategy(caplog) -> None:
    # Build a Config that bypasses __post_init__ validation by
    # constructing the block via dataclasses.replace on an already-valid
    # block — this is what catches the defensive factory path.
    valid_block = C3_5RefinerConfig(strategy="passthrough")
    object.__setattr__(valid_block, "strategy", "garbage")
    config = Config.with_blocks(c3_5_adhop=valid_block)
    with caplog.at_level(logging.ERROR, logger="gps_denied_onboard.c3_5_adhop"):
        with pytest.raises(RefinerConfigError) as exc_info:
            build_refiner_strategy(
                config,
                ransac_filter=_FakeRansacFilter(),
                inference_runtime=_FakeInferenceRuntime(),
            )
    assert "Unknown refiner strategy: garbage" in str(exc_info.value)
    assert any(
        r.message == "c3_5.refiner.strategy_unknown" for r in caplog.records
    )


# ----------------------------------------------------------------------
# AC-5: Factory rejects invalid threshold.


@pytest.mark.parametrize("bad_threshold", [0.0, -0.1, -10.0])
def test_ac5_invalid_threshold_rejected_at_config_load(bad_threshold: float) -> None:
    with pytest.raises(ConfigError):
        C3_5RefinerConfig(residual_threshold_px=bad_threshold)


def test_ac5_factory_emits_error_log_on_invalid_threshold(caplog) -> None:
    valid_block = C3_5RefinerConfig(strategy="passthrough")
    object.__setattr__(valid_block, "residual_threshold_px", 0.0)
    config = Config.with_blocks(c3_5_adhop=valid_block)
    with caplog.at_level(logging.ERROR, logger="gps_denied_onboard.c3_5_adhop"):
        with pytest.raises(RefinerConfigError):
            build_refiner_strategy(
                config,
                ransac_filter=_FakeRansacFilter(),
                inference_runtime=_FakeInferenceRuntime(),
            )
    assert any(
        r.message == "c3_5.refiner.invalid_threshold" for r in caplog.records
    )


# ----------------------------------------------------------------------
# AC-6: Successful factory load emits INFO log.


def test_ac6_factory_emits_info_log_on_success(caplog) -> None:
    config = _config_with_strategy("passthrough", threshold=2.5)
    with caplog.at_level(logging.INFO, logger="gps_denied_onboard.c3_5_adhop"):
        instance = build_refiner_strategy(
            config,
            ransac_filter=_FakeRansacFilter(),
            inference_runtime=_FakeInferenceRuntime(),
        )
    assert isinstance(instance, ConditionalRefiner)
    records = [
        r for r in caplog.records if r.message == "c3_5.refiner.strategy_loaded"
    ]
    assert len(records) == 1
    record = records[0]
    assert getattr(record, "strategy", None) == "passthrough"
    assert getattr(record, "residual_threshold_px", None) == 2.5


# ----------------------------------------------------------------------
# AC-7: Strategy resolution table.


def test_ac7_passthrough_resolution() -> None:
    config = _config_with_strategy("passthrough")
    instance = build_refiner_strategy(
        config,
        ransac_filter=_FakeRansacFilter(),
        inference_runtime=_FakeInferenceRuntime(),
    )
    assert isinstance(instance, PassthroughRefiner)
    assert isinstance(instance, ConditionalRefiner)
    assert (
        "gps_denied_onboard.components.c3_5_adhop.passthrough_refiner"
        in sys.modules
    )


def test_ac7_adhop_resolution_stops_at_module_lookup() -> None:
    """Task spec: the full-success path for "adhop" belongs to the
    AdHoP task (AZ-349); this assertion verifies the factory reaches
    the import step but the module does not exist yet.
    """
    config = _config_with_strategy("adhop")
    with pytest.raises(ModuleNotFoundError):
        build_refiner_strategy(
            config,
            ransac_filter=_FakeRansacFilter(),
            inference_runtime=_FakeInferenceRuntime(),
        )


# ----------------------------------------------------------------------
# AC-8: Public API.


def test_ac8_public_api_re_exports() -> None:
    from gps_denied_onboard.components import c3_5_adhop

    assert "ConditionalRefiner" in c3_5_adhop.__all__
    assert "C3_5RefinerConfig" in c3_5_adhop.__all__


def test_ac8_internals_not_in_public_api() -> None:
    from gps_denied_onboard.components import c3_5_adhop

    for internal in (
        "PassthroughRefiner",
        "AdHoPRefiner",
        "RefinerError",
        "RefinerBackboneError",
        "RefinerConfigError",
    ):
        assert internal not in c3_5_adhop.__all__, (
            f"internal name leaked into public API: {internal}"
        )


# ----------------------------------------------------------------------
# AC-9: deferred.


def test_ac9_single_thread_binding_deferred() -> None:
    """AC-9 (single-thread binding) is deferred per task spec
    Risk 4: the generic ``compose_root`` thread-binding registry
    lives with AZ-270 and the broader runtime-root composition.
    Each factory owns its own thread binding today; this protocol
    task does not add a new binding registry."""


# ----------------------------------------------------------------------
# AC-10: PassthroughRefiner byte-identical correspondences.


def test_ac10_passthrough_returns_same_match_result_reference() -> None:
    refiner = create(
        _config_with_strategy(),
        ransac_filter=_FakeRansacFilter(),
        inference_runtime=_FakeInferenceRuntime(),
    )
    mr = _make_result()
    out = refiner.refine_if_needed(_FakeFrame(), mr, residual_threshold_px=2.5)
    assert out is mr
    assert out.refinement_label == "passthrough"
    assert out.refinement_added_latency_ms == 0.0


def test_ac10_passthrough_correspondences_bit_identical() -> None:
    refiner = PassthroughRefiner(
        ransac_filter=_FakeRansacFilter(),
        inference_runtime=_FakeInferenceRuntime(),
    )
    mr = _make_result()
    out = refiner.refine_if_needed(_FakeFrame(), mr, residual_threshold_px=2.5)
    for in_cand, out_cand in zip(mr.per_candidate, out.per_candidate, strict=True):
        assert np.array_equal(
            out_cand.inlier_correspondences, in_cand.inlier_correspondences
        )
        assert out_cand.inlier_correspondences.dtype == in_cand.inlier_correspondences.dtype
        # Same object reference — INV-5 forbids silent copies on the
        # passthrough path.
        assert out_cand.inlier_correspondences is in_cand.inlier_correspondences


# ----------------------------------------------------------------------
# AC-11: was_invoked always False on PassthroughRefiner.


def test_ac11_was_invoked_always_false_on_passthrough() -> None:
    refiner = PassthroughRefiner(
        ransac_filter=_FakeRansacFilter(),
        inference_runtime=_FakeInferenceRuntime(),
    )
    assert refiner.was_invoked() is False
    mr = _make_result()
    for _ in range(10):
        refiner.refine_if_needed(_FakeFrame(), mr, residual_threshold_px=2.5)
        assert refiner.was_invoked() is False


# ----------------------------------------------------------------------
# AC-12: Threshold validation in refine_if_needed.


@pytest.mark.parametrize("bad_threshold", [0.0, -0.1, -50.0])
def test_ac12_threshold_validation_in_method(bad_threshold: float) -> None:
    refiner = PassthroughRefiner(
        ransac_filter=_FakeRansacFilter(),
        inference_runtime=_FakeInferenceRuntime(),
    )
    mr = _make_result()
    with pytest.raises(ValueError):
        refiner.refine_if_needed(
            _FakeFrame(), mr, residual_threshold_px=bad_threshold
        )


# ----------------------------------------------------------------------
# AC-13: Error hierarchy.


@pytest.mark.parametrize(
    "exc_factory",
    [RefinerBackboneError, RefinerConfigError],
)
def test_ac13_all_refiner_errors_caught_as_family(exc_factory) -> None:
    with pytest.raises(RefinerError):
        raise exc_factory("boom")


# ----------------------------------------------------------------------
# AC-14: module-layout.md symbol rename.


def test_ac14_module_layout_references_conditional_refiner() -> None:
    from pathlib import Path

    doc = Path("_docs/02_document/module-layout.md").read_text()
    assert "ConditionalRefiner" in doc, (
        "module-layout.md must reference the canonical Public API symbol"
    )
    # Old symbol name must NOT be present (per AC-14).
    assert "AdHoPRefinementStrategy" not in doc, (
        "module-layout.md still references the legacy AdHoPRefinementStrategy "
        "symbol; rename per AZ-348 AC-14"
    )


# ----------------------------------------------------------------------
# NFRs.


def test_nfr_perf_factory_under_20ms_p99(caplog) -> None:
    config = _config_with_strategy("passthrough")
    ransac_filter = _FakeRansacFilter()
    inference_runtime = _FakeInferenceRuntime()
    durations_ms: list[float] = []
    for _ in range(100):
        t0 = time.perf_counter()
        build_refiner_strategy(
            config,
            ransac_filter=ransac_filter,
            inference_runtime=inference_runtime,
        )
        durations_ms.append((time.perf_counter() - t0) * 1000.0)
    durations_ms.sort()
    p99 = durations_ms[int(0.99 * len(durations_ms))]
    assert p99 <= 20.0, f"factory p99={p99:.2f} ms exceeded 20 ms budget"


def test_nfr_perf_passthrough_under_0_5ms_p99() -> None:
    refiner = PassthroughRefiner(
        ransac_filter=_FakeRansacFilter(),
        inference_runtime=_FakeInferenceRuntime(),
    )
    mr = _make_result()
    frame = _FakeFrame()
    durations_us: list[float] = []
    for _ in range(10_000):
        t0 = time.perf_counter()
        refiner.refine_if_needed(frame, mr, residual_threshold_px=2.5)
        durations_us.append((time.perf_counter() - t0) * 1_000_000.0)
    durations_us.sort()
    p99_us = durations_us[int(0.99 * len(durations_us))]
    assert p99_us <= 500.0, f"refine p99={p99_us:.2f} us exceeded 500 us budget"


# ----------------------------------------------------------------------
# Surface coverage — config defaults.


def test_c3_5_config_defaults() -> None:
    cfg = C3_5RefinerConfig()
    assert cfg.strategy == "adhop"
    assert cfg.residual_threshold_px == 2.5
    assert cfg.invocation_rate_warn_threshold == 0.25


def test_c3_5_config_invocation_rate_validation() -> None:
    with pytest.raises(ConfigError):
        C3_5RefinerConfig(invocation_rate_warn_threshold=0.0)
    with pytest.raises(ConfigError):
        C3_5RefinerConfig(invocation_rate_warn_threshold=1.0)
    with pytest.raises(ConfigError):
        C3_5RefinerConfig(invocation_rate_warn_threshold=-0.5)
