"""AZ-384 — GtsamIsam2StateEstimator current_estimate / smoothed_history / health_snapshot.

Ten ACs from ``_docs/02_tasks/done/AZ-384_c5_marginals_outputs.md``:

- AC-1  ``current_estimate`` returns a fresh ``EstimatorOutput`` per
        call with ``smoothed=False``.
- AC-2  SPD covariance — Cholesky must succeed; non-SPD raises
        :class:`EstimatorFatalError`.
- AC-3  WGS84 conversion — uses the shared ``WgsConverter``;
        ENU origin defaults to (0, 0, 0) when none injected.
- AC-4  ``smoothed_history(n)`` is bounded by K=15; every entry has
        ``smoothed=True``.
- AC-5  ``current_estimate`` has ``smoothed=False`` (distinguishes
        from history).
- AC-6  ``health_snapshot.isam2_state`` reflects convergence:
        ``INIT`` before first estimate, ``TRACKING`` after, ``LOST``
        on a fatal SPD failure.
- AC-7  ``keyframe_count`` matches the smoother's pose-key count.
- AC-8  ``cov_norm_growing_for_s`` increments under monotone-rising
        covariance norms; resets to 0 on a non-rising frame.
- AC-9  ``spoof_promotion_blocked`` queries the injected
        ``source_label_state_machine`` (default ``False`` when none).
- AC-10 ``last_satellite_anchor_age_ms`` is a pass-through from
        ``handle.last_anchor_age_ms()``.

The seeded-prior approach in the helpers below mirrors what AZ-388
(AC-5.2 fallback) will do at startup — a single prior factor + an
initial value for x0, so iSAM2 has something to read in
``current_estimate``. Marginal-quality tests use mock handles so we
can synthesise specific covariance matrices without driving the
optimiser into a particular numerical state.
"""

from __future__ import annotations

from unittest import mock

import gtsam
import numpy as np
import pytest

from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard._types.state import (
    EstimatorHealth,
    EstimatorOutput,
    IsamState,
    PoseSourceLabel,
)
from gps_denied_onboard.components.c5_state.config import C5StateConfig
from gps_denied_onboard.components.c5_state.errors import EstimatorFatalError
from gps_denied_onboard.components.c5_state.gtsam_isam2_estimator import (
    GtsamIsam2StateEstimator,
    create,
)
from gps_denied_onboard.runtime_root.state_factory import clear_state_registry


@pytest.fixture(autouse=True)
def _registry_isolation():
    # Arrange
    clear_state_registry()
    yield
    clear_state_registry()


def _build_estimator() -> GtsamIsam2StateEstimator:
    block = C5StateConfig(strategy="gtsam_isam2", keyframe_window_size=15)
    cfg = mock.MagicMock()
    cfg.components = {"c5_state": block}
    estimator, _ = create(
        config=cfg,
        imu_preintegrator=mock.MagicMock(),
        se3_utils=mock.MagicMock(),
        wgs_converter=mock.MagicMock(),
        fdr_client=mock.MagicMock(),
    )
    return estimator


def _seed_prior(
    estimator: GtsamIsam2StateEstimator,
    pose: gtsam.Pose3 | None = None,
    *,
    ts_seconds: float = 0.0,
) -> int:
    """Plant a prior factor + initial value at x0 on the real iSAM2 graph.

    Returns the seeded pose key. This is the minimal scaffolding
    needed to make ``current_estimate`` succeed; AZ-388 will own
    this seeding at startup once the AC-5.2 fallback wiring lands.
    Provides a real ``FixedLagSmootherKeyTimestampMap`` so the
    smoother tracks the key (otherwise ``keyframe_count`` /
    ``smoothed_history`` see an empty window).
    """
    import gtsam_unstable

    pose = pose if pose is not None else gtsam.Pose3()
    key = gtsam.symbol("x", estimator._next_key_counter)
    estimator._next_key_counter += 1
    noise = gtsam.noiseModel.Isotropic.Sigma(6, 0.1)
    prior = gtsam.PriorFactorPose3(key, pose, noise)
    graph = gtsam.NonlinearFactorGraph()
    graph.add(prior)
    values = gtsam.Values()
    values.insert(key, pose)
    ts_map = gtsam_unstable.FixedLagSmootherKeyTimestampMap()
    ts_map.insert((key, ts_seconds))
    estimator._isam2_handle.update(graph, values, timestamps=ts_map)
    estimator._record_committed_pose_key(key)
    return key


# ---------------------------------------------------------------------
# AC-1 + AC-5: current_estimate fresh EstimatorOutput; smoothed=False


def test_ac1_current_estimate_returns_fresh_estimator_output() -> None:
    estimator = _build_estimator()
    _seed_prior(estimator)

    out_a = estimator.current_estimate()
    out_b = estimator.current_estimate()

    assert isinstance(out_a, EstimatorOutput)
    assert isinstance(out_b, EstimatorOutput)
    assert out_a is not out_b
    assert out_a.frame_id != out_b.frame_id


def test_ac1_no_committed_pose_key_raises_fatal() -> None:
    estimator = _build_estimator()

    with pytest.raises(EstimatorFatalError, match="no committed pose key"):
        estimator.current_estimate()


def test_ac5_current_estimate_smoothed_false() -> None:
    estimator = _build_estimator()
    _seed_prior(estimator)

    out = estimator.current_estimate()

    assert out.smoothed is False


# ---------------------------------------------------------------------
# AC-2: SPD covariance enforcement


def test_ac2_spd_invariant_holds_for_real_marginals() -> None:
    estimator = _build_estimator()
    _seed_prior(estimator)

    out = estimator.current_estimate()

    # Should not raise — well-conditioned posterior is SPD.
    np.linalg.cholesky(out.covariance_6x6)


def test_ac2_non_spd_marginals_raises_fatal() -> None:
    estimator = _build_estimator()
    _seed_prior(estimator)
    fake_handle = mock.MagicMock()
    fake_marginals = mock.MagicMock()
    fake_marginals.marginalCovariance.return_value = np.zeros((6, 6))
    fake_handle.compute_marginals.return_value = fake_marginals
    fake_handle.last_anchor_age_ms.return_value = 0
    estimator._isam2_handle = fake_handle

    with pytest.raises(EstimatorFatalError, match="SPD"):
        estimator.current_estimate()
    assert estimator._isam2_state == IsamState.LOST


# ---------------------------------------------------------------------
# AC-3: WGS84 conversion uses WgsConverter


def test_ac3_default_origin_is_equator() -> None:
    estimator = _build_estimator()
    _seed_prior(estimator, pose=gtsam.Pose3())

    out = estimator.current_estimate()

    # Identity pose at default origin (0, 0, 0) → output is exactly the origin.
    assert out.position_wgs84.lat_deg == pytest.approx(0.0, abs=1e-6)
    assert out.position_wgs84.lon_deg == pytest.approx(0.0, abs=1e-6)
    assert out.position_wgs84.alt_m == pytest.approx(0.0, abs=1e-3)


def test_ac3_explicit_origin_round_trips() -> None:
    estimator = _build_estimator()
    origin = LatLonAlt(lat_deg=50.0, lon_deg=36.0, alt_m=120.0)
    estimator.set_enu_origin(origin)
    _seed_prior(estimator, pose=gtsam.Pose3())

    out = estimator.current_estimate()

    assert out.position_wgs84.lat_deg == pytest.approx(50.0, abs=1e-6)
    assert out.position_wgs84.lon_deg == pytest.approx(36.0, abs=1e-6)
    assert out.position_wgs84.alt_m == pytest.approx(120.0, abs=1e-3)


def test_ac3_translated_pose_offsets_from_origin() -> None:
    estimator = _build_estimator()
    origin = LatLonAlt(lat_deg=50.0, lon_deg=36.0, alt_m=120.0)
    estimator.set_enu_origin(origin)
    pose_100m_east = gtsam.Pose3(gtsam.Rot3(), gtsam.Point3(100.0, 0.0, 0.0))
    _seed_prior(estimator, pose=pose_100m_east)

    out = estimator.current_estimate()

    # ~100m east at 50° lat is ~0.0014° lon; just confirm the
    # direction + same lat.
    assert out.position_wgs84.lat_deg == pytest.approx(50.0, abs=1e-4)
    assert out.position_wgs84.lon_deg > 36.0
    assert out.position_wgs84.lon_deg < 36.01


# ---------------------------------------------------------------------
# AC-4: smoothed_history bounded by K


def test_ac4_smoothed_history_bounded_by_k() -> None:
    estimator = _build_estimator()
    _seed_prior(estimator)

    history = estimator.smoothed_history(100)

    assert len(history) <= 15


def test_ac4_smoothed_history_entries_have_smoothed_true() -> None:
    estimator = _build_estimator()
    _seed_prior(estimator)

    history = estimator.smoothed_history(100)

    assert len(history) >= 1
    for entry in history:
        assert entry.smoothed is True


def test_ac4_smoothed_history_empty_when_n_zero() -> None:
    estimator = _build_estimator()
    _seed_prior(estimator)

    history = estimator.smoothed_history(0)

    assert history == []


def test_ac4_smoothed_history_empty_before_seed() -> None:
    estimator = _build_estimator()

    history = estimator.smoothed_history(10)

    assert history == []


# ---------------------------------------------------------------------
# AC-6: IsamState lifecycle


def test_ac6_isam2_state_init_before_first_estimate() -> None:
    estimator = _build_estimator()

    snap = estimator.health_snapshot()

    assert snap.isam2_state == IsamState.INIT


def test_ac6_isam2_state_tracking_after_estimate() -> None:
    estimator = _build_estimator()
    _seed_prior(estimator)

    estimator.current_estimate()
    snap = estimator.health_snapshot()

    assert snap.isam2_state == IsamState.TRACKING


def test_ac6_isam2_state_lost_after_fatal() -> None:
    estimator = _build_estimator()
    _seed_prior(estimator)
    fake_handle = mock.MagicMock()
    fake_marginals = mock.MagicMock()
    fake_marginals.marginalCovariance.return_value = np.zeros((6, 6))
    fake_handle.compute_marginals.return_value = fake_marginals
    fake_handle.last_anchor_age_ms.return_value = 0
    estimator._isam2_handle = fake_handle

    with pytest.raises(EstimatorFatalError):
        estimator.current_estimate()
    snap = estimator.health_snapshot()

    assert snap.isam2_state == IsamState.LOST


# ---------------------------------------------------------------------
# AC-7: keyframe_count accuracy


def test_ac7_keyframe_count_initially_zero() -> None:
    estimator = _build_estimator()

    snap = estimator.health_snapshot()

    assert snap.keyframe_count == 0


def test_ac7_keyframe_count_grows_with_seeded_keys() -> None:
    estimator = _build_estimator()
    _seed_prior(estimator)

    snap = estimator.health_snapshot()

    assert snap.keyframe_count == 1


# ---------------------------------------------------------------------
# AC-8: cov_norm_growing_for_s monotonicity


def test_ac8_cov_norm_growing_zero_with_constant_norm() -> None:
    estimator = _build_estimator()
    _seed_prior(estimator)
    fake_handle = mock.MagicMock()
    fake_marginals = mock.MagicMock()
    fake_marginals.marginalCovariance.return_value = np.eye(6, dtype=np.float64) * 0.1
    fake_handle.compute_marginals.return_value = fake_marginals
    fake_handle.last_anchor_age_ms.return_value = 0
    estimator._isam2_handle = fake_handle

    for _ in range(5):
        estimator.current_estimate()
    snap = estimator.health_snapshot()

    assert snap.cov_norm_growing_for_s == 0.0


def test_ac8_cov_norm_growing_increments_under_rising_norm() -> None:
    estimator = _build_estimator()
    _seed_prior(estimator)
    fake_handle = mock.MagicMock()
    fake_marginals = mock.MagicMock()
    fake_handle.compute_marginals.return_value = fake_marginals
    fake_handle.last_anchor_age_ms.return_value = 0
    estimator._isam2_handle = fake_handle

    for sigma in [0.05, 0.1, 0.2, 0.3, 0.5]:
        fake_marginals.marginalCovariance.return_value = np.eye(6, dtype=np.float64) * sigma
        estimator.current_estimate()
    growing_s = estimator._cov_norm_growing_for_s()

    assert growing_s > 0.0


def test_ac8_cov_norm_growing_resets_on_drop() -> None:
    estimator = _build_estimator()
    _seed_prior(estimator)
    fake_handle = mock.MagicMock()
    fake_marginals = mock.MagicMock()
    fake_handle.compute_marginals.return_value = fake_marginals
    fake_handle.last_anchor_age_ms.return_value = 0
    estimator._isam2_handle = fake_handle

    for sigma in [0.05, 0.1, 0.2, 0.3, 0.5]:
        fake_marginals.marginalCovariance.return_value = np.eye(6, dtype=np.float64) * sigma
        estimator.current_estimate()
    # Now drop the norm — the rising chain breaks.
    fake_marginals.marginalCovariance.return_value = np.eye(6, dtype=np.float64) * 0.05
    estimator.current_estimate()

    assert estimator._cov_norm_growing_for_s() == 0.0


# ---------------------------------------------------------------------
# AC-9: spoof_promotion_blocked via injected state machine


def test_ac9_default_spoof_promotion_blocked_false() -> None:
    estimator = _build_estimator()

    snap = estimator.health_snapshot()

    assert snap.spoof_promotion_blocked is False


def test_ac9_spoof_promotion_blocked_from_state_machine() -> None:
    estimator = _build_estimator()
    machine = mock.MagicMock()
    machine.is_spoof_promotion_blocked.return_value = True
    machine.current_label.return_value = PoseSourceLabel.DEAD_RECKONED
    estimator.attach_source_label_state_machine(machine)

    snap = estimator.health_snapshot()

    assert snap.spoof_promotion_blocked is True


def test_ac9_state_machine_drives_source_label() -> None:
    estimator = _build_estimator()
    _seed_prior(estimator)
    machine = mock.MagicMock()
    machine.is_spoof_promotion_blocked.return_value = False
    machine.current_label.return_value = PoseSourceLabel.SATELLITE_ANCHORED
    estimator.attach_source_label_state_machine(machine)

    out = estimator.current_estimate()

    assert out.source_label == PoseSourceLabel.SATELLITE_ANCHORED


def test_ac9_default_source_label_is_dead_reckoned_before_any_anchor() -> None:
    # AZ-385 superseded the AZ-384 default: the auto-constructed
    # SourceLabelStateMachine returns DEAD_RECKONED until the first
    # satellite anchor is observed (AC-1 of AZ-385 + Invariant 5).
    estimator = _build_estimator()
    _seed_prior(estimator)

    out = estimator.current_estimate()

    assert out.source_label == PoseSourceLabel.DEAD_RECKONED


# ---------------------------------------------------------------------
# AC-10: last_satellite_anchor_age_ms pass-through


def test_ac10_last_satellite_anchor_age_ms_passthrough() -> None:
    estimator = _build_estimator()
    _seed_prior(estimator)
    fake_handle = mock.MagicMock()
    fake_marginals = mock.MagicMock()
    fake_marginals.marginalCovariance.return_value = np.eye(6, dtype=np.float64) * 0.1
    fake_handle.compute_marginals.return_value = fake_marginals
    fake_handle.last_anchor_age_ms.return_value = 1234
    estimator._isam2_handle = fake_handle

    out = estimator.current_estimate()

    assert out.last_satellite_anchor_age_ms == 1234


def test_ac10_emitted_at_is_monotonic_ns() -> None:
    estimator = _build_estimator()
    _seed_prior(estimator)

    out_a = estimator.current_estimate()
    out_b = estimator.current_estimate()

    assert isinstance(out_a.emitted_at, int)
    assert out_b.emitted_at >= out_a.emitted_at


# ---------------------------------------------------------------------
# Defensive — health_snapshot is cheap (no marginals call)


def test_health_snapshot_does_not_call_marginals() -> None:
    estimator = _build_estimator()
    fake_handle = mock.MagicMock()
    estimator._isam2_handle = fake_handle

    snap = estimator.health_snapshot()

    fake_handle.compute_marginals.assert_not_called()
    assert isinstance(snap, EstimatorHealth)
