"""AZ-276 — `ImuPreintegrator` AC suite (E-CC-HELPERS).

Covers the 7 ACs from `_docs/02_tasks/todo/AZ-276_imu_preintegrator.md`.
"""

from __future__ import annotations

import ast
from pathlib import Path

import numpy as np
import pytest

from gps_denied_onboard._types.calibration import CameraCalibration
from gps_denied_onboard._types.nav import ImuBias, ImuSample, ImuWindow
from gps_denied_onboard.helpers import (
    CombinedImuFactor,
    ImuPreintegrationError,
    ImuPreintegrator,
    make_imu_preintegrator,
)


def _calibration() -> CameraCalibration:
    return CameraCalibration(
        camera_id="test_cam",
        intrinsics_3x3=np.eye(3, dtype=np.float64),
        distortion=np.zeros(5, dtype=np.float64),
        body_to_camera_se3=np.eye(4, dtype=np.float64),
        acquisition_method="lab_calibration",
        metadata={},
    )


def _make_samples(n: int, start_ts_ns: int = 0, dt_ns: int = 5_000_000) -> tuple[ImuSample, ...]:
    """``n`` strictly-monotonic samples at ``dt_ns`` cadence (default 5 ms = 200 Hz)."""
    accel = (0.0, 0.0, 9.80665)
    gyro = (0.0, 0.0, 0.0)
    return tuple(
        ImuSample(ts_ns=start_ts_ns + i * dt_ns, accel_xyz=accel, gyro_xyz=gyro) for i in range(n)
    )


# ---------------------------------------------------------------------------
# AC-1: round-trip preintegration.


def test_ac1_round_trip_preintegration() -> None:
    # Arrange
    pre = make_imu_preintegrator(_calibration())
    samples = _make_samples(100)

    # Act
    for s in samples:
        pre.integrate_sample(s)
    pim = pre.current_preintegration()

    # Assert — deltaTij matches the span between first and last sample.
    expected_dt_s = (samples[-1].ts_ns - samples[0].ts_ns) * 1e-9
    assert pim.deltaTij() == pytest.approx(expected_dt_s, abs=1e-9)
    # Z gravity is removed by the preintegrator; we expect non-zero
    # rotation-frame translation because the device sits stationary
    # under gravity and PIM accumulates the doubly-integrated specific
    # force — sufficient for the "non-zero delta_pose" gate.
    delta_p = np.asarray(pim.deltaPij())
    assert np.linalg.norm(delta_p) > 0.0


# ---------------------------------------------------------------------------
# AC-2: strict monotonicity rejection leaves state unchanged.


def test_ac2_non_monotonic_rejection_preserves_state() -> None:
    # Arrange
    pre = make_imu_preintegrator(_calibration())
    samples = _make_samples(10)
    for s in samples:
        pre.integrate_sample(s)
    snapshot_dt = pre.current_preintegration().deltaTij()

    bad_sample = ImuSample(
        ts_ns=samples[-1].ts_ns - 1,  # equal/less is rejected
        accel_xyz=(0.0, 0.0, 9.80665),
        gyro_xyz=(0.0, 0.0, 0.0),
    )

    # Act / Assert
    with pytest.raises(ImuPreintegrationError, match="non-monotonic"):
        pre.integrate_sample(bad_sample)

    # State unchanged — deltaTij is the same as before the bad sample.
    assert pre.current_preintegration().deltaTij() == pytest.approx(snapshot_dt)

    # Subsequent valid sample integrates normally.
    next_good = ImuSample(
        ts_ns=samples[-1].ts_ns + 5_000_000,
        accel_xyz=(0.0, 0.0, 9.80665),
        gyro_xyz=(0.0, 0.0, 0.0),
    )
    pre.integrate_sample(next_good)
    assert pre.current_preintegration().deltaTij() > snapshot_dt


# ---------------------------------------------------------------------------
# AC-3: reset_for_new_keyframe is destructive.


def test_ac3_reset_for_new_keyframe_is_destructive() -> None:
    # Arrange
    pre = make_imu_preintegrator(_calibration())
    samples = _make_samples(50)
    for s in samples:
        pre.integrate_sample(s)

    # Act
    closed = pre.reset_for_new_keyframe()

    # Assert — the closed factor carries the integration.
    assert closed.deltaTij() == pytest.approx((samples[-1].ts_ns - samples[0].ts_ns) * 1e-9)
    # Subsequent current_preintegration() raises.
    with pytest.raises(ImuPreintegrationError, match="no samples"):
        pre.current_preintegration()


# ---------------------------------------------------------------------------
# AC-4: re-bias affects subsequent samples only.


def test_ac4_rebias_affects_subsequent_samples_only() -> None:
    # Arrange — feed identical samples with two different biases; the
    # second-half integration must differ depending on bias_b's value.
    samples = _make_samples(50)
    bias_a = ImuBias(accel_bias=(0.0, 0.0, 0.0), gyro_bias=(0.0, 0.0, 0.0))
    bias_b = ImuBias(accel_bias=(1.0, 0.0, 0.0), gyro_bias=(0.0, 0.0, 0.0))

    pre_a_only = make_imu_preintegrator(_calibration())
    pre_a_only.reset_with_bias(bias_a)
    for s in samples:
        pre_a_only.integrate_sample(s)
    delta_p_a = np.asarray(pre_a_only.current_preintegration().deltaPij())

    pre_b_only = make_imu_preintegrator(_calibration())
    pre_b_only.reset_with_bias(bias_b)
    for s in samples:
        pre_b_only.integrate_sample(s)
    delta_p_b = np.asarray(pre_b_only.current_preintegration().deltaPij())

    # Act / Assert — different bias → different integrated translation.
    # This proves bias is applied per-segment, validating the consumer's
    # contract that calling reset_with_bias mid-flight produces a
    # bias-aware integration of the new segment only.
    assert not np.allclose(delta_p_a, delta_p_b)


# ---------------------------------------------------------------------------
# AC-5: determinism — two instances, same input → deep-equal factors.


def test_ac5_determinism_across_instances() -> None:
    # Arrange
    calibration = _calibration()
    samples = _make_samples(80, start_ts_ns=1_000_000_000)

    pre_1 = make_imu_preintegrator(calibration)
    pre_2 = make_imu_preintegrator(calibration)

    # Act
    for s in samples:
        pre_1.integrate_sample(s)
        pre_2.integrate_sample(s)

    pim_1 = pre_1.current_preintegration()
    pim_2 = pre_2.current_preintegration()

    # Assert
    assert pim_1.deltaTij() == pim_2.deltaTij()
    np.testing.assert_array_equal(np.asarray(pim_1.deltaPij()), np.asarray(pim_2.deltaPij()))
    np.testing.assert_array_equal(np.asarray(pim_1.deltaVij()), np.asarray(pim_2.deltaVij()))


# ---------------------------------------------------------------------------
# AC-6: no lock acquisition on the integration path.


def test_ac6_no_internal_locks() -> None:
    # Arrange
    module_path = (
        Path(__file__).resolve().parents[2]
        / "src"
        / "gps_denied_onboard"
        / "helpers"
        / "imu_preintegrator.py"
    )
    source = module_path.read_text()

    # Act / Assert — no Lock / RLock / Semaphore / mutex appears in source.
    for symbol in ("threading.Lock", "threading.RLock", "Semaphore", "mutex"):
        assert symbol not in source, f"imu_preintegrator must be lock-free (found {symbol!r})"


# ---------------------------------------------------------------------------
# AC-7: no upward imports.


def test_ac7_no_upward_imports() -> None:
    # Arrange
    module_path = (
        Path(__file__).resolve().parents[2]
        / "src"
        / "gps_denied_onboard"
        / "helpers"
        / "imu_preintegrator.py"
    )
    tree = ast.parse(module_path.read_text())

    # Act
    forbidden: list[str] = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            forbidden.extend(
                alias.name for alias in node.names if "gps_denied_onboard.components" in alias.name
            )
        elif isinstance(node, ast.ImportFrom):
            if node.module and "gps_denied_onboard.components" in node.module:
                forbidden.append(node.module)

    # Assert
    assert not forbidden, f"imu_preintegrator must not import components.*: {forbidden}"


# ---------------------------------------------------------------------------
# Additional guards.


def test_current_preintegration_after_reset_with_bias_raises() -> None:
    # Arrange
    pre = make_imu_preintegrator(_calibration())
    for s in _make_samples(5):
        pre.integrate_sample(s)
    pre.reset_with_bias(ImuBias(accel_bias=(0.0, 0.0, 0.0), gyro_bias=(0.0, 0.0, 0.0)))

    # Act / Assert — reset_with_bias also clears the accumulator.
    with pytest.raises(ImuPreintegrationError):
        pre.current_preintegration()


def test_integrate_window_propagates_through_samples() -> None:
    # Arrange
    pre = make_imu_preintegrator(_calibration())
    samples = _make_samples(25)
    window = ImuWindow(samples=samples, ts_start_ns=samples[0].ts_ns, ts_end_ns=samples[-1].ts_ns)

    # Act
    pre.integrate_window(window)

    # Assert
    pim = pre.current_preintegration()
    assert pim.deltaTij() == pytest.approx((samples[-1].ts_ns - samples[0].ts_ns) * 1e-9)


def test_imu_preintegrator_is_an_instance_type() -> None:
    # Arrange / Act
    pre = make_imu_preintegrator(_calibration())

    # Assert — factory returns the documented public type.
    assert isinstance(pre, ImuPreintegrator)


def test_combined_imu_factor_re_export_is_callable() -> None:
    # Assert — re-export resolves to GTSAM's CombinedImuFactor class.
    assert CombinedImuFactor.__name__ == "CombinedImuFactor"
