"""Regression tests for calibration quality, continuity, and observability gates."""

from __future__ import annotations

import numpy as np

from imu_lidar.contracts import ImuSeries, LidarFrame, MotionPair
from imu_lidar.geometry import make_transform, so3_exp, so3_log
from imu_lidar.joint_optimizer import solve_joint_extrinsic
from imu_lidar.motion_pairs import build_motion_pairs
from imu_lidar.observability import analyze_observability
from imu_lidar.registration import RegistrationResult
from imu_lidar.rotation_handeye import solve_rotation_handeye


def _motion_pair(index: int, rotation_vector: np.ndarray) -> MotionPair:
    rotation = so3_exp(np.asarray(rotation_vector, dtype=float))
    return MotionPair(
        session_id="synthetic",
        i=index,
        j=index + 1,
        t_i_s=float(index),
        t_j_s=float(index + 1),
        R_A=rotation,
        R_B=rotation,
        t_A_m=np.zeros(3),
        t_B_m=np.array([0.1, -0.03, 0.0]),
        fitness=0.9,
        metadata={
            "J_bg": (-np.eye(3)).tolist(),
            "cov": (np.eye(3) * 1e-4).tolist(),
            "gyro_bias0_rad_s": [0.0, 0.0, 0.0],
        },
    )


def _frame(frame_id: str, mid_s: float) -> LidarFrame:
    return LidarFrame(
        frame_id=frame_id,
        t_start_s=mid_s - 0.01,
        t_end_s=mid_s + 0.01,
        points_xyz=np.zeros((64, 3)),
    )


def _registration(*, fitness: float = 0.9) -> RegistrationResult:
    rotation = so3_exp(np.deg2rad(np.array([0.0, 0.0, 10.0])))
    return RegistrationResult(
        transform=make_transform(np.array([0.4, 0.0, 0.0]), rotation),
        fitness=fitness,
        rotation_deg=10.0,
        translation_m=0.4,
        backend="test",
        ok=True,
    )


def test_planar_yaw_is_not_full_rotation_or_translation_observable():
    pairs = [
        _motion_pair(i, np.deg2rad(np.array([0.0, 0.0, angle_deg])))
        for i, angle_deg in enumerate((5.0, 8.0, 12.0, 17.0, 23.0, 31.0))
    ]

    report = analyze_observability(pairs, np.eye(3))

    assert not report.rotation_observable
    assert not report.translation_observable


def test_multi_axis_motion_is_rotation_and_translation_observable():
    vectors_deg = (
        (12.0, 0.0, 0.0),
        (0.0, 15.0, 0.0),
        (0.0, 0.0, 18.0),
        (10.0, 8.0, 0.0),
        (0.0, 11.0, 9.0),
        (7.0, 0.0, 13.0),
    )
    pairs = [
        _motion_pair(i, np.deg2rad(np.asarray(vector_deg)))
        for i, vector_deg in enumerate(vectors_deg)
    ]

    report = analyze_observability(pairs, np.eye(3))

    assert report.rotation_observable
    assert report.translation_observable


def test_translation_prior_is_reported_but_not_accepted_when_unobservable():
    pairs = [
        _motion_pair(i, np.deg2rad(np.array([0.0, 0.0, angle_deg])))
        for i, angle_deg in enumerate((5.0, 8.0, 12.0, 17.0, 23.0, 31.0))
    ]
    prior = np.array([0.3, -0.2, 0.5])

    result = solve_joint_extrinsic(
        pairs,
        np.eye(3),
        force_rotation_only=False,
        enable_phase_c=False,
        t_prior_m=prior,
    )

    assert not result.translation_accepted
    np.testing.assert_allclose(result.T_IMU_lidar[:3, 3], prior)
    assert any("prior only" in note for note in result.notes)


def test_handeye_rejects_a_small_fraction_of_gross_rotation_outliers():
    rng = np.random.default_rng(7)
    r_true = so3_exp(np.deg2rad(np.array([2.0, -3.0, 20.0])))
    pairs: list[MotionPair] = []
    for index in range(100):
        axis = rng.normal(size=3)
        axis /= np.linalg.norm(axis)
        r_b = so3_exp(axis * np.deg2rad(rng.uniform(8.0, 30.0)))
        r_a = r_true @ r_b @ r_true.T
        if index == 0:
            r_a = so3_exp(np.deg2rad(np.array([18.0, 0.0, 0.0]))) @ r_a
        pairs.append(
            MotionPair(
                session_id="outlier",
                i=index,
                j=index + 1,
                t_i_s=float(index),
                t_j_s=float(index + 1),
                R_A=r_a,
                R_B=r_b,
            )
        )

    result = solve_rotation_handeye(pairs)

    assert not result.ok
    assert result.outlier_fraction_gt_5deg > 0.005


def test_motion_pairs_reject_low_fitness(monkeypatch):
    monkeypatch.setattr(
        "imu_lidar.motion_pairs.register_lidar_pair",
        lambda *_args, **_kwargs: _registration(fitness=0.3),
    )
    imu = ImuSeries(
        t_s=np.linspace(0.0, 1.2, 121),
        gyro_rad_s=np.zeros((121, 3)),
        acc_m_s2=np.zeros((121, 3)),
    )

    result = build_motion_pairs(
        session_id="fitness",
        keyframes=[_frame("0", 0.1), _frame("1", 1.1)],
        keyframe_indices=[0, 1],
        imu=imu,
        delta_t_s=0.0,
        min_registration_fitness=0.5,
    )

    assert not result.pairs
    assert any("fitness<0.50: 1" in note for note in result.notes)


def test_motion_pairs_reject_imu_and_lidar_discontinuities(monkeypatch):
    monkeypatch.setattr(
        "imu_lidar.motion_pairs.register_lidar_pair",
        lambda *_args, **_kwargs: _registration(),
    )
    imu_with_gap = ImuSeries(
        t_s=np.array([0.0, 0.1, 0.2, 0.3, 0.4, 0.8, 0.9, 1.0, 1.1, 1.2]),
        gyro_rad_s=np.zeros((10, 3)),
        acc_m_s2=np.zeros((10, 3)),
    )
    imu_result = build_motion_pairs(
        session_id="imu-gap",
        keyframes=[_frame("0", 0.1), _frame("1", 1.1)],
        keyframe_indices=[0, 1],
        imu=imu_with_gap,
        delta_t_s=0.0,
        max_imu_gap_s=0.2,
    )

    assert not imu_result.pairs
    assert any("IMU gap>0.200s: 1" in note for note in imu_result.notes)

    continuous_imu = ImuSeries(
        t_s=np.linspace(0.0, 2.2, 221),
        gyro_rad_s=np.zeros((221, 3)),
        acc_m_s2=np.zeros((221, 3)),
    )
    lidar_result = build_motion_pairs(
        session_id="lidar-gap",
        keyframes=[_frame("0", 0.1), _frame("2", 2.1)],
        keyframe_indices=[0, 2],
        imu=continuous_imu,
        delta_t_s=0.0,
        all_frame_times_s=np.array([0.1, 0.2, 2.1]),
        max_lidar_gap_s=0.5,
    )

    assert not lidar_result.pairs
    assert any("LiDAR gap>0.500s: 1" in note for note in lidar_result.notes)

def test_phase_a_keeps_session_bias_linearization_points_independent():
    r_true = so3_exp(np.deg2rad(np.array([2.0, -3.0, 20.0])))
    bias0_by_session = {
        "s0": np.array([0.010, -0.004, 0.002]),
        "s1": np.array([-0.006, 0.008, -0.003]),
    }
    vectors_deg = (
        (12.0, 0.0, 0.0),
        (0.0, 15.0, 0.0),
        (0.0, 0.0, 18.0),
        (10.0, 8.0, 0.0),
        (0.0, 11.0, 9.0),
        (7.0, 0.0, 13.0),
    )
    pairs: list[MotionPair] = []
    for session_index, (session_id, bias0) in enumerate(bias0_by_session.items()):
        for pair_index, vector_deg in enumerate(vectors_deg):
            r_b = so3_exp(np.deg2rad(np.asarray(vector_deg)))
            r_a = r_true @ r_b @ r_true.T
            index = session_index * 100 + pair_index
            pairs.append(
                MotionPair(
                    session_id=session_id,
                    i=index,
                    j=index + 1,
                    t_i_s=float(pair_index),
                    t_j_s=float(pair_index + 1),
                    R_A=r_a,
                    R_B=r_b,
                    t_A_m=np.zeros(3),
                    t_B_m=np.zeros(3),
                    metadata={
                        "J_bg": np.eye(3).tolist(),
                        "cov": (np.eye(3) * 1e-4).tolist(),
                        "gyro_bias0_rad_s": bias0.tolist(),
                    },
                )
            )

    result = solve_joint_extrinsic(
        pairs,
        r_true,
        force_rotation_only=True,
        gyro_bias_rad_s_by_session=bias0_by_session,
    )

    assert result.phase_a_accepted
    assert set(result.phase_a_comparison["variants"]) == {
        "A0_fixed_bg_data_only",
        "A1_session_bg_data_only",
        "A2_session_bg_with_rotation_prior",
    }
    assert set(result.gyro_bias_rad_s_per_session) == {"s0", "s1"}
    for session_id, bias0 in bias0_by_session.items():
        np.testing.assert_allclose(
            result.gyro_bias_rad_s_per_session[session_id], bias0, atol=1e-8
        )
    assert np.linalg.norm(so3_log(r_true.T @ result.T_IMU_lidar[:3, :3])) < 1e-8
