"""Tests for cached, session-balanced Phase-A comparison."""

from __future__ import annotations

import numpy as np

from imu_lidar.contracts import ImuSeries, MotionPair
from imu_lidar.geometry import so3_exp, so3_log
from imu_lidar.imu_preintegration import preintegrate_gyro
from imu_lidar.phase_a import (
    rehydrate_phase_a_pairs,
    select_decorrelated_phase_a_pairs,
    solve_phase_a_comparison,
)


def _phase_a_pair(
    session_id: str,
    index: int,
    r_true: np.ndarray,
    vector_deg: tuple[float, float, float],
    bias0: np.ndarray,
) -> MotionPair:
    r_b = so3_exp(np.deg2rad(np.asarray(vector_deg, dtype=float)))
    return MotionPair(
        session_id=session_id,
        i=index,
        j=index + 1,
        t_i_s=float(index),
        t_j_s=float(index + 1),
        R_A=r_true @ r_b @ r_true.T,
        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-5).tolist(),
            "gyro_bias0_rad_s": bias0.tolist(),
        },
    )


def test_phase_a_reports_three_variants_and_leave_one_session_out() -> None:
    r_true = so3_exp(np.deg2rad(np.array([3.0, -2.0, 25.0])))
    prior = so3_exp(np.deg2rad(np.array([0.0, 0.0, 0.2]))) @ r_true
    vectors = (
        (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),
        (9.0, -5.0, 6.0),
        (-6.0, 8.0, 11.0),
        (5.0, 7.0, -9.0),
    )
    biases = {
        "s0": np.array([0.001, -0.0005, 0.0002]),
        "s1": np.array([-0.0004, 0.0008, -0.0001]),
        "s2": np.array([0.0002, 0.0001, -0.0006]),
    }
    pairs: list[MotionPair] = []
    index = 0
    for sid, count in (("s0", 18), ("s1", 9), ("s2", 6)):
        for local_index in range(count):
            pairs.append(
                _phase_a_pair(
                    sid,
                    index,
                    r_true,
                    vectors[local_index % len(vectors)],
                    biases[sid],
                )
            )
            index += 1

    result = solve_phase_a_comparison(
        pairs,
        gyro_bias_rad_s_by_session=biases,
        rotation_prior=prior,
        rotation_prior_sigma_deg=15.0,
        yaw_std_max_deg=1.0,
        leave_one_out_yaw_range_max_deg=1.0,
        data_prior_difference_max_deg=1.0,
        decorrelation_block_s=0.0,
        max_nfev=80,
    )

    assert result.accepted
    assert result.strong_pair_counts_per_session == {
        "s0": 18,
        "s1": 9,
        "s2": 6,
    }
    assert len(result.leave_one_out) == 3
    assert result.marginal_observability.rank == 3
    assert result.leave_one_out_yaw_range_deg < 0.1
    for variant in (
        result.fixed_bg_data_only,
        result.session_bg_data_only,
        result.session_bg_with_rotation_prior,
    ):
        error_deg = np.degrees(
            np.linalg.norm(
                so3_log(r_true.T @ variant.R_IMU_lidar)
            )
        )
        assert error_deg < 0.1


def test_rehydrate_phase_a_pairs_recovers_jacobian_without_lidar() -> None:
    t_s = np.linspace(0.0, 1.0, 201)
    gyro = np.tile(np.array([0.12, -0.04, 0.2]), (t_s.size, 1))
    bias0 = np.array([0.01, -0.005, 0.002])
    imu = ImuSeries(
        t_s=t_s,
        gyro_rad_s=gyro,
        acc_m_s2=np.zeros((t_s.size, 3)),
    )
    preint = preintegrate_gyro(t_s, gyro, 0.1, 0.8, bias0)
    pair = MotionPair(
        session_id="s0",
        i=0,
        j=1,
        t_i_s=0.1,
        t_j_s=0.8,
        R_A=preint.delta_R,
        R_B=preint.delta_R,
        metadata={
            "t_i_imu_s": 0.1,
            "t_j_imu_s": 0.8,
            "gyro_bias0_rad_s": bias0.tolist(),
            "preint_sigma_rad": preint.sigma_rad,
        },
    )

    enriched, report = rehydrate_phase_a_pairs(
        [pair],
        imu_by_session={"s0": imu},
        bias0_by_session={"s0": bias0},
    )

    assert "J_bg" in enriched[0].metadata
    assert "cov" in enriched[0].metadata
    assert report["max_R_A_error_deg"] < 1e-8


def test_phase_a_time_blocks_do_not_count_overlapping_pairs_as_independent() -> None:
    r_true = so3_exp(np.deg2rad(np.array([1.0, -2.0, 20.0])))
    bias = np.zeros(3)
    pairs = [
        _phase_a_pair("s0", index, r_true, (5.0 + index, 2.0, 1.0), bias)
        for index in range(9)
    ]
    selected = select_decorrelated_phase_a_pairs(
        pairs,
        block_s=3.0,
        max_pairs_per_block=1,
    )
    assert len(selected) == 3
    assert all(pair in pairs for pair in selected)


def test_phase_a_planar_motion_is_partial_and_keeps_weak_direction_from_prior() -> None:
    r_true = so3_exp(np.deg2rad(np.array([4.0, -3.0, 31.0])))
    prior = so3_exp(np.deg2rad(np.array([0.2, -0.1, 0.4]))) @ r_true
    biases = {"s0": np.zeros(3), "s1": np.zeros(3)}
    pairs: list[MotionPair] = []
    for session_index, sid in enumerate(biases):
        for index in range(12):
            pairs.append(
                _phase_a_pair(
                    sid,
                    session_index * 100 + index,
                    r_true,
                    (0.0, 0.0, 8.0 + index),
                    biases[sid],
                )
            )
    result = solve_phase_a_comparison(
        pairs,
        gyro_bias_rad_s_by_session=biases,
        rotation_prior=prior,
        decorrelation_block_s=0.0,
        yaw_std_max_deg=0.5,
        run_leave_one_out=False,
        max_nfev=80,
    )
    assert not result.accepted
    assert result.partial_accepted
    assert result.solution_status == "phase_a_partial_accepted"
    assert result.marginal_observability.precision_rank == 2
    assert result.observable_subspace_with_prior is not None
    assert np.isinf(result.marginal_observability.direction_std_deg[0])
