"""AZ-342 — C2.5 ReRankStrategy Protocol + DTO + error + factory conformance.

Covers AC-1..AC-8 + AC-11 + NFRs. AC-9 (single-thread binding) and
AC-10 (LightGlueRuntime identity-share between C2.5 and C3) are
deferred per the task spec's Risk-4 escape clause — the generic
compose_root thread-binding registry and the cross-factory helper
identity assertion live with AZ-270 and the future C3 protocol task
(AZ-344). Each factory owns its own thread binding today.
"""

from __future__ import annotations

import dataclasses
import logging
import sys
import time
import types

import pytest

from gps_denied_onboard._types.rerank import RerankCandidate, RerankResult
from gps_denied_onboard.components.c2_5_rerank import (
    C2_5RerankConfig,
    ReRankStrategy,
    RerankAllCandidatesFailedError,
    RerankBackboneError,
    RerankError,
)
from gps_denied_onboard.components.c2_5_rerank.config import KNOWN_STRATEGIES
from gps_denied_onboard.config.schema import Config, ConfigError
from gps_denied_onboard.runtime_root.errors import StrategyNotAvailableError
from gps_denied_onboard.runtime_root.rerank_factory import build_rerank_strategy


_STRATEGY_MODULES: dict[str, tuple[str, str, str]] = {
    "inlier_count": (
        "gps_denied_onboard.components.c2_5_rerank.inlier_based_reranker",
        "InlierCountReRanker",
        "BUILD_RERANK_INLIER_COUNT",
    ),
}


# ----------------------------------------------------------------------
# Fakes that structurally satisfy the ReRankStrategy Protocol.


class _FakeTileStore:
    def read_tile_pixels(self, tile_id):
        raise NotImplementedError

    def write_tile(self, tile_blob, metadata):
        raise NotImplementedError

    def tile_exists(self, tile_id):
        return False

    def delete_tile(self, tile_id):
        return False


class _FakeLightGlueRuntime:
    def descriptor_dim(self):
        return 256

    def match(self, features_a, features_b):
        raise NotImplementedError

    def match_batch(self, features_a_list, features_b_list):
        raise NotImplementedError


class _FakeFeatureExtractor:
    def descriptor_dim(self):
        return 256

    def extract(self, image_bgr):
        raise NotImplementedError


class _FakeClock:
    def __init__(self) -> None:
        self._t = 1_000_000_000

    def monotonic_ns(self):
        self._t += 1
        return self._t

    def time_ns(self):
        return self._t

    def sleep_until_ns(self, target_ns):
        return None


class _FullReRankStrategy:
    def __init__(
        self,
        config,
        *,
        tile_store,
        lightglue_runtime,
        feature_extractor=None,
        clock=None,
        fdr_client=None,
    ) -> None:
        self._config = config
        self._tile_store = tile_store
        self._lightglue_runtime = lightglue_runtime
        self._feature_extractor = feature_extractor
        self._clock = clock
        self._fdr_client = fdr_client
        self._label = config.components["c2_5_rerank"].strategy

    def rerank(self, frame, vpr_result, n, calibration):
        return RerankResult(
            frame_id=getattr(frame, "frame_id", 0),
            candidates=tuple(),
            reranked_at=1_000_000_000,
            rerank_label=self._label,
            candidates_input=0,
            candidates_dropped=0,
        )


class _PartialReRankStrategy:
    pass


def _config_with_strategy(strategy: str = "inlier_count") -> Config:
    return Config.with_blocks(c2_5_rerank=C2_5RerankConfig(strategy=strategy))


def _install_fake_strategy(strategy_label: str) -> type:
    module_name, class_name, _flag = _STRATEGY_MODULES[strategy_label]

    class _FakeStrategy(_FullReRankStrategy):
        pass

    _FakeStrategy.__name__ = class_name
    module = types.ModuleType(module_name)
    setattr(module, class_name, _FakeStrategy)
    sys.modules[module_name] = module
    return _FakeStrategy


@pytest.fixture
def strategy_module_cleanup():
    for module_name, _, _ in _STRATEGY_MODULES.values():
        sys.modules.pop(module_name, None)
    yield
    for module_name, _, _ in _STRATEGY_MODULES.values():
        sys.modules.pop(module_name, None)


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


def test_ac1_rerank_strategy_conformance_full() -> None:
    instance = _FullReRankStrategy(
        _config_with_strategy(),
        tile_store=_FakeTileStore(),
        lightglue_runtime=_FakeLightGlueRuntime(),
        clock=_FakeClock(),
    )
    assert isinstance(instance, ReRankStrategy)


def test_ac1_rerank_strategy_conformance_partial_missing_methods() -> None:
    assert not isinstance(_PartialReRankStrategy(), ReRankStrategy)


# ----------------------------------------------------------------------
# AC-2: frozen+slotted DTOs.


def _make_candidate() -> RerankCandidate:
    return RerankCandidate(
        tile_id=(18, 49.9, 36.3),
        inlier_count=42,
        descriptor_distance=0.123,
        descriptor_dim=512,
        tile_pixels_handle=object(),
    )


def _make_result(frame_id: int = 7) -> RerankResult:
    return RerankResult(
        frame_id=frame_id,
        candidates=(_make_candidate(),),
        reranked_at=1_000_000_000,
        rerank_label="inlier_count",
        candidates_input=10,
        candidates_dropped=9,
    )


@pytest.mark.parametrize(
    "dto, field_name, new_value",
    [
        (_make_candidate(), "inlier_count", 99),
        (_make_result(), "rerank_label", "learned_reranker"),
    ],
)
def test_ac2_frozen_dtos_reject_mutation(dto, field_name: str, new_value) -> None:
    original_value = getattr(dto, field_name)
    with pytest.raises(dataclasses.FrozenInstanceError):
        setattr(dto, field_name, new_value)
    assert getattr(dto, field_name) == original_value


@pytest.mark.parametrize("cls", [RerankCandidate, RerankResult])
def test_ac2_dtos_have_slots(cls) -> None:
    assert hasattr(cls, "__slots__")
    assert cls.__slots__
    instance = _make_candidate() if cls is RerankCandidate else _make_result()
    assert not hasattr(instance, "__dict__"), (
        f"{cls.__name__} carries a __dict__ — slots=True is missing"
    )


# ----------------------------------------------------------------------
# AC-3: factory rejects missing build flag.


def test_ac3_factory_rejects_missing_build_flag(
    monkeypatch, strategy_module_cleanup, caplog
) -> None:
    strategy = "inlier_count"
    _, _, flag = _STRATEGY_MODULES[strategy]
    monkeypatch.delenv(flag, raising=False)
    config = _config_with_strategy(strategy)
    with caplog.at_level(logging.ERROR, logger="gps_denied_onboard.c2_5_rerank"):
        with pytest.raises(StrategyNotAvailableError) as exc_info:
            build_rerank_strategy(
                config,
                tile_store=_FakeTileStore(),
                lightglue_runtime=_FakeLightGlueRuntime(),
                feature_extractor=_FakeFeatureExtractor(),
                clock=_FakeClock(),
            )
    assert "BUILD_RERANK_INLIER_COUNT is OFF" in str(exc_info.value)
    assert any(
        r.message == "c2_5.rerank.build_flag_off" for r in caplog.records
    )


def test_ac3_factory_does_not_load_module_when_flag_off(
    monkeypatch, strategy_module_cleanup
) -> None:
    module_name, _, flag = _STRATEGY_MODULES["inlier_count"]
    monkeypatch.delenv(flag, raising=False)
    config = _config_with_strategy("inlier_count")
    with pytest.raises(StrategyNotAvailableError):
        build_rerank_strategy(
            config,
            tile_store=_FakeTileStore(),
            lightglue_runtime=_FakeLightGlueRuntime(),
            feature_extractor=_FakeFeatureExtractor(),
            clock=_FakeClock(),
        )
    assert module_name not in sys.modules


# ----------------------------------------------------------------------
# AC-4: unknown strategy rejected at config-load time.


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


# ----------------------------------------------------------------------
# AC-5: factory emits INFO log on success.


def test_ac5_factory_emits_info_log_on_success(
    monkeypatch, strategy_module_cleanup, caplog
) -> None:
    strategy = "inlier_count"
    _, _, flag = _STRATEGY_MODULES[strategy]
    monkeypatch.setenv(flag, "ON")
    _install_fake_strategy(strategy)
    config = _config_with_strategy(strategy)
    with caplog.at_level(logging.INFO, logger="gps_denied_onboard.c2_5_rerank"):
        instance = build_rerank_strategy(
            config,
            tile_store=_FakeTileStore(),
            lightglue_runtime=_FakeLightGlueRuntime(),
            feature_extractor=_FakeFeatureExtractor(),
            clock=_FakeClock(),
        )
    assert isinstance(instance, ReRankStrategy)
    records = [
        r for r in caplog.records if r.message == "c2_5.rerank.strategy_loaded"
    ]
    assert len(records) == 1
    record = records[0]
    assert getattr(record, "strategy", None) == "inlier_count"
    assert getattr(record, "top_n", None) == 3


# ----------------------------------------------------------------------
# AC-6: strategy resolution table.


def test_ac6_strategy_resolution(monkeypatch, strategy_module_cleanup) -> None:
    strategy = "inlier_count"
    module_name, class_name, flag = _STRATEGY_MODULES[strategy]
    monkeypatch.setenv(flag, "ON")
    fake_cls = _install_fake_strategy(strategy)
    config = _config_with_strategy(strategy)
    instance = build_rerank_strategy(
        config,
        tile_store=_FakeTileStore(),
        lightglue_runtime=_FakeLightGlueRuntime(),
        feature_extractor=_FakeFeatureExtractor(),
        clock=_FakeClock(),
    )
    assert isinstance(instance, fake_cls)
    assert isinstance(instance, ReRankStrategy)
    assert sys.modules[module_name] is not None


# ----------------------------------------------------------------------
# AC-7: error hierarchy.


@pytest.mark.parametrize(
    "exc_factory",
    [RerankBackboneError, RerankAllCandidatesFailedError],
)
def test_ac7_all_rerank_errors_caught_as_family(exc_factory) -> None:
    with pytest.raises(RerankError):
        raise exc_factory("boom")


def test_ac7_strategy_not_available_outside_family() -> None:
    with pytest.raises(StrategyNotAvailableError):
        try:
            raise StrategyNotAvailableError("composition-time")
        except RerankError:
            pytest.fail(
                "StrategyNotAvailableError is a composition-root error "
                "and MUST NOT be in the c2.5 RerankError family"
            )


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


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

    assert "ReRankStrategy" in c2_5_rerank.__all__
    assert "RerankResult" in c2_5_rerank.__all__
    assert "RerankCandidate" in c2_5_rerank.__all__


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

    # Concrete strategy must not leak into the package re-exports;
    # consumers see only the Protocol.
    assert "InlierCountReRanker" not in c2_5_rerank.__all__


# ----------------------------------------------------------------------
# AC-11: tile_pixels_handle opaqueness.


def test_ac11_tile_pixels_handle_opaque() -> None:
    handle = object()
    candidate = RerankCandidate(
        tile_id=(18, 49.9, 36.3),
        inlier_count=10,
        descriptor_distance=0.5,
        descriptor_dim=256,
        tile_pixels_handle=handle,
    )
    assert candidate.tile_pixels_handle is handle


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


@pytest.mark.parametrize(
    "exc_type",
    [RerankBackboneError, RerankAllCandidatesFailedError],
)
def test_nfr_reliability_all_rerank_errors_subclass_family(exc_type) -> None:
    assert issubclass(exc_type, RerankError)


def test_nfr_reliability_strategy_not_available_not_in_family() -> None:
    assert not issubclass(StrategyNotAvailableError, RerankError)


def test_nfr_perf_factory_under_50ms_p99(
    monkeypatch, strategy_module_cleanup
) -> None:
    strategy = "inlier_count"
    _, _, flag = _STRATEGY_MODULES[strategy]
    monkeypatch.setenv(flag, "ON")
    _install_fake_strategy(strategy)
    config = _config_with_strategy(strategy)
    tile_store = _FakeTileStore()
    lightglue_runtime = _FakeLightGlueRuntime()
    feature_extractor = _FakeFeatureExtractor()
    clock = _FakeClock()

    durations_ms: list[float] = []
    for _ in range(100):
        t0 = time.perf_counter()
        build_rerank_strategy(
            config,
            tile_store=tile_store,
            lightglue_runtime=lightglue_runtime,
            feature_extractor=feature_extractor,
            clock=clock,
        )
        durations_ms.append((time.perf_counter() - t0) * 1000.0)

    durations_ms.sort()
    p99 = durations_ms[int(0.99 * len(durations_ms))]
    assert p99 <= 50.0


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


def test_c2_5_config_defaults() -> None:
    cfg = C2_5RerankConfig()
    assert cfg.strategy == "inlier_count"
    assert cfg.top_n == 3


def test_c2_5_config_top_n_validation() -> None:
    with pytest.raises(ConfigError):
        C2_5RerankConfig(top_n=0)
    with pytest.raises(ConfigError):
        C2_5RerankConfig(top_n=-3)
