"""Joint extrinsic refinement: Phase-A rotation factors + Phase-C SE(3) IMU factors."""

from __future__ import annotations

from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from typing import Any

import numpy as np
from scipy.optimize import least_squares

from .contracts import ImuSeries, MotionPair
from .geometry import make_transform, orthonormalize_rotation, so3_exp, so3_log
from .imu_preintegration import (
    apply_bias_jacobian_correction,
    apply_constant_bias_correction,
    preintegrate_gyro,
    preintegration_rotation_residual,
    residual_whiten_matrix,
)
from .observability import ObservabilityReport, analyze_observability
from .phase_a import phase_a_comparison_to_dict, solve_phase_a_comparison
from .rotation_handeye import select_strong_rotation_pairs

G_NORM = 9.80665


@dataclass(frozen=True)
class PhaseASessionResult:
    session_id: str
    pair_count: int
    gyro_bias0_rad_s: np.ndarray
    gyro_bias_rad_s: np.ndarray
    residual_rms_deg: float
    residual_median_deg: float
    residual_p95_deg: float
    outlier_fraction_gt_5deg: float
    accepted: bool
    included_in_final: bool


@dataclass(frozen=True)
class JointExtrinsicResult:
    T_IMU_lidar: np.ndarray
    translation_accepted: bool
    residual_rms_rot_deg: float
    residual_rms_trans_m: float
    observability: ObservabilityReport
    gyro_bias_rad_s: np.ndarray | None = None
    accel_bias_m_s2: np.ndarray | None = None
    gravity_m_s2: np.ndarray | None = None
    gyro_bias_rad_s_per_session: dict[str, np.ndarray] = field(default_factory=dict)
    phase_a_sessions: tuple[PhaseASessionResult, ...] = ()
    phase_a_accepted: bool = False
    phase_a_comparison: dict[str, Any] = field(default_factory=dict)
    notes: tuple[str, ...] = ()


def _pair_weight(pair: MotionPair) -> float:
    weight = float(pair.metadata.get("weight", 1.0))
    if not np.isfinite(weight) or weight <= 0:
        return 1.0
    return weight


def _pair_j_bg(pair: MotionPair) -> np.ndarray | None:
    raw = pair.metadata.get("J_bg")
    if raw is None:
        return None
    return np.asarray(raw, dtype=float).reshape(3, 3)


def _pair_cov(pair: MotionPair) -> np.ndarray:
    raw = pair.metadata.get("cov")
    if raw is None:
        sigma = float(pair.metadata.get("preint_sigma_rad", 1e-2))
        return np.eye(3) * max(sigma, 1e-4) ** 2
    return np.asarray(raw, dtype=float).reshape(3, 3)


def _corrected_delta_r(
    pair: MotionPair,
    delta_bias: np.ndarray,
    *,
    imu: ImuSeries | None,
    bias0: np.ndarray,
) -> np.ndarray:
    j_bg = _pair_j_bg(pair)
    if j_bg is not None:
        return apply_bias_jacobian_correction(pair.R_A, j_bg, delta_bias)
    if imu is not None and "t_i_imu_s" in pair.metadata and "t_j_imu_s" in pair.metadata:
        preint = preintegrate_gyro(
            imu.t_s,
            imu.gyro_rad_s,
            float(pair.metadata["t_i_imu_s"]),
            float(pair.metadata["t_j_imu_s"]),
            bias0 + delta_bias,
        )
        return preint.delta_R
    duration = float(pair.metadata.get("duration_s", max(pair.t_j_s - pair.t_i_s, 1e-3)))
    return apply_constant_bias_correction(pair.R_A, duration, delta_bias)


def _gravity_basis(g0: np.ndarray) -> np.ndarray:
    """Return 3×2 orthonormal basis spanning the plane orthogonal to ``g0``."""

    g = np.asarray(g0, dtype=float).reshape(3)
    n = np.linalg.norm(g)
    if n < 1e-9:
        g = np.array([0.0, 0.0, -G_NORM])
        n = G_NORM
    g = g / n
    axis = np.array([1.0, 0.0, 0.0]) if abs(g[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
    e1 = np.cross(g, axis)
    e1 /= max(np.linalg.norm(e1), 1e-12)
    e2 = np.cross(g, e1)
    return np.column_stack([e1, e2])


def _gravity_from_params(xy: np.ndarray, g0: np.ndarray, basis: np.ndarray) -> np.ndarray:
    raw = np.asarray(g0, dtype=float).reshape(3) + basis @ np.asarray(xy, dtype=float).reshape(2)
    n = float(np.linalg.norm(raw))
    if n < 1e-9:
        return np.asarray(g0, dtype=float).reshape(3)
    return raw * (G_NORM / n)


def _lidar_to_imu_relative(r_x: np.ndarray, t_x: np.ndarray, r_b: np.ndarray, t_b: np.ndarray):
    """Map LiDAR relative pose to IMU: ``T_A = T_X T_B T_X^{-1}``."""

    r_a = orthonormalize_rotation(r_x @ r_b @ r_x.T)
    t_a = (np.eye(3) - r_a) @ t_x + r_x @ t_b
    return r_a, t_a


def _corrected_preint_quantities(
    pair: MotionPair,
    bg_i: np.ndarray,
    ba_i: np.ndarray,
    bg0: np.ndarray,
    ba0: np.ndarray,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """First-order correct ΔR/Δv/Δp for keyframe biases vs preintegration biases."""

    dbg = np.asarray(bg_i, dtype=float).reshape(3) - np.asarray(bg0, dtype=float).reshape(3)
    dba = np.asarray(ba_i, dtype=float).reshape(3) - np.asarray(ba0, dtype=float).reshape(3)
    j_bg = pair.metadata.get("J_bg9")
    j_ba = pair.metadata.get("J_ba")
    delta_v0 = np.asarray(pair.metadata.get("delta_v", [0.0, 0.0, 0.0]), dtype=float).reshape(3)
    delta_p0 = (
        np.asarray(pair.t_A_m, dtype=float).reshape(3)
        if pair.t_A_m is not None
        else np.asarray(pair.metadata.get("delta_p", [0.0, 0.0, 0.0]), dtype=float).reshape(3)
    )
    if j_bg is None or j_ba is None:
        delta_r = apply_bias_jacobian_correction(
            pair.R_A,
            _pair_j_bg(pair) if _pair_j_bg(pair) is not None else np.zeros((3, 3)),
            dbg,
        )
        return delta_r, delta_v0, delta_p0
    j_bg_m = np.asarray(j_bg, dtype=float).reshape(9, 3)
    j_ba_m = np.asarray(j_ba, dtype=float).reshape(9, 3)
    delta_r = orthonormalize_rotation(pair.R_A @ so3_exp(j_bg_m[0:3] @ dbg))
    delta_v = delta_v0 + j_bg_m[3:6] @ dbg + j_ba_m[3:6] @ dba
    delta_p = delta_p0 + j_bg_m[6:9] @ dbg + j_ba_m[6:9] @ dba
    return delta_r, delta_v, delta_p


def _build_nav_rotations(
    keyframe_ids: list[int],
    id_to_idx: dict[int, int],
    consecutive_pairs: dict[tuple[int, int], MotionPair],
    r_x: np.ndarray,
    t_x: np.ndarray,
) -> list[np.ndarray]:
    """Chain IMU orientations; restart at session/gap boundaries (no cross-link)."""

    del id_to_idx
    rotations = [np.eye(3) for _ in keyframe_ids]
    for k in range(len(keyframe_ids) - 1):
        a = keyframe_ids[k]
        b = keyframe_ids[k + 1]
        pair = consecutive_pairs.get((a, b))
        if pair is None:
            # Missing link or new session: start a fresh nav chain.
            rotations[k + 1] = np.eye(3)
            continue
        t_b = np.zeros(3) if pair.t_B_m is None else np.asarray(pair.t_B_m, dtype=float)
        r_meas, _ = _lidar_to_imu_relative(r_x, t_x, pair.R_B, t_b)
        rotations[k + 1] = orthonormalize_rotation(rotations[k] @ r_meas)
    return rotations


def _solve_phase_c_se3(
    pairs: list[MotionPair],
    r_x: np.ndarray,
    *,
    gyro_bias_linearization: np.ndarray,
    gyro_bias_init: np.ndarray,
    gravity_init: np.ndarray,
    sigma_bg_rw: float = 1.0e-5,
    sigma_ba_rw: float = 1.0e-3,
    t_init: np.ndarray | None = None,
    t_prior: np.ndarray | None = None,
    t_prior_sigma_m: np.ndarray | float | None = None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, float, float, list[str]]:
    """Keyframe IMU factor optimization for full SE(3)."""

    notes: list[str] = []
    usable = [pair for pair in pairs if pair.t_B_m is not None and "delta_v" in pair.metadata]
    if len(usable) < 3:
        notes.append("phase-C skipped: need pairs with full preintegration metadata")
        t0 = np.zeros(3) if t_init is None else np.asarray(t_init, dtype=float).reshape(3)
        return r_x, t0, gravity_init, gyro_bias_init, np.zeros(3), 1e9, 1e9, notes

    # Keyframes: group by session, sort each session by IMU time (no cross-session chain).
    stamp: dict[int, float] = {}
    kf_session: dict[int, str] = {}
    for pair in usable:
        stamp[pair.i] = float(pair.metadata.get("t_i_imu_s", pair.t_i_s))
        stamp[pair.j] = float(pair.metadata.get("t_j_imu_s", pair.t_j_s))
        kf_session[pair.i] = pair.session_id
        kf_session[pair.j] = pair.session_id
    session_ids = sorted(set(kf_session.values()))
    keyframe_ids: list[int] = []
    for sid in session_ids:
        local = [kid for kid, sess in kf_session.items() if sess == sid]
        local.sort(key=lambda kid: stamp[kid])
        keyframe_ids.extend(local)
    k_count = len(keyframe_ids)
    id_to_idx = {kid: idx for idx, kid in enumerate(keyframe_ids)}

    consecutive_pairs: dict[tuple[int, int], MotionPair] = {}
    for pair in usable:
        if kf_session.get(pair.i) != kf_session.get(pair.j):
            continue
        if id_to_idx[pair.j] == id_to_idx[pair.i] + 1:
            consecutive_pairs[(pair.i, pair.j)] = pair
    notes.append(
        f"phase-C multi-session graph: sessions={len(session_ids)}, "
        f"keyframes={k_count}, consecutive_links={len(consecutive_pairs)}"
    )

    g0 = np.asarray(gravity_init, dtype=float).reshape(3)
    if np.linalg.norm(g0) < 1e-6:
        g0 = np.array([0.0, 0.0, -G_NORM])
    g0 = g0 * (G_NORM / max(np.linalg.norm(g0), 1e-9))
    basis = _gravity_basis(g0)
    ba0 = np.zeros(3)
    bg0 = np.asarray(gyro_bias_linearization, dtype=float).reshape(3)
    bg_init = np.asarray(gyro_bias_init, dtype=float).reshape(3)

    # State: dθ(3), t(3), g_xy(2), v(3K), bg(3K), ba(3K)
    n_v = 3 * k_count
    n_b = 3 * k_count
    dim = 3 + 3 + 2 + n_v + n_b + n_b
    x0 = np.zeros(dim)
    t0 = np.zeros(3) if t_init is None else np.asarray(t_init, dtype=float).reshape(3)
    x0[3:6] = t0
    t_prior_vec = None if t_prior is None else np.asarray(t_prior, dtype=float).reshape(3)
    if t_prior_sigma_m is None:
        t_sigma = np.array([0.05, 0.05, 0.05], dtype=float)
    else:
        t_sigma = np.asarray(t_prior_sigma_m, dtype=float).reshape(-1)
        if t_sigma.size == 1:
            t_sigma = np.full(3, float(t_sigma[0]), dtype=float)
    # velocities start at 0; biases at prior
    for idx in range(k_count):
        x0[8 + n_v + 3 * idx : 8 + n_v + 3 * idx + 3] = bg_init

    whitened = []
    for pair in usable:
        cov9 = pair.metadata.get("cov9")
        if cov9 is None:
            cov = _pair_cov(pair)
            cov9_m = np.eye(9)
            cov9_m[0:3, 0:3] = cov
            cov9_m[3:6, 3:6] = np.eye(3) * 0.25
            cov9_m[6:9, 6:9] = np.eye(3) * 1.0
        else:
            cov9_m = np.asarray(cov9, dtype=float).reshape(9, 9)
        whitened.append(residual_whiten_matrix(cov9_m))

    def unpack(vec: np.ndarray):
        r_opt = orthonormalize_rotation(so3_exp(vec[0:3]) @ r_x)
        t_opt = vec[3:6]
        g_opt = _gravity_from_params(vec[6:8], g0, basis)
        base = 8
        vels = vec[base : base + n_v].reshape(k_count, 3)
        base += n_v
        bgs = vec[base : base + n_b].reshape(k_count, 3)
        base += n_b
        bas = vec[base : base + n_b].reshape(k_count, 3)
        return r_opt, t_opt, g_opt, vels, bgs, bas

    def residuals(vec: np.ndarray) -> np.ndarray:
        r_opt, t_opt, g_opt, vels, bgs, bas = unpack(vec)
        nav_r = _build_nav_rotations(keyframe_ids, id_to_idx, consecutive_pairs, r_opt, t_opt)
        out: list[np.ndarray] = []

        for pair, whiten in zip(usable, whitened):
            i_idx = id_to_idx[pair.i]
            j_idx = id_to_idx[pair.j]
            dt = float(pair.metadata.get("duration_s", pair.t_j_s - pair.t_i_s))
            dt = max(dt, 1e-3)
            delta_r, delta_v, delta_p = _corrected_preint_quantities(
                pair, bgs[i_idx], bas[i_idx], bg0, ba0
            )
            t_b = np.asarray(pair.t_B_m, dtype=float).reshape(3)
            r_meas, t_meas = _lidar_to_imu_relative(r_opt, t_opt, pair.R_B, t_b)
            r_i = nav_r[i_idx]
            v_i = vels[i_idx]
            v_j = vels[j_idx]

            err_r = so3_log(delta_r.T @ r_meas)
            err_v = v_j - v_i - g_opt * dt - r_i @ delta_v
            err_p = r_i @ (t_meas - delta_p) - v_i * dt - 0.5 * g_opt * (dt**2)
            err = np.concatenate([err_r, err_v, err_p])
            w = np.sqrt(_pair_weight(pair))
            out.append(w * (whiten @ err))

        # Bias random-walk between consecutive keyframes (same session only).
        for k in range(k_count - 1):
            a = keyframe_ids[k]
            b = keyframe_ids[k + 1]
            if kf_session.get(a) != kf_session.get(b):
                continue
            dt = max(stamp[b] - stamp[a], 1e-3)
            scale_g = 1.0 / (max(sigma_bg_rw, 1e-8) * np.sqrt(dt))
            scale_a = 1.0 / (max(sigma_ba_rw, 1e-8) * np.sqrt(dt))
            out.append(scale_g * (bgs[k + 1] - bgs[k]))
            out.append(scale_a * (bas[k + 1] - bas[k]))

        # Weak priors: first keyframe of each session + CAD/installation translation.
        for sid in session_ids:
            first = next(kid for kid in keyframe_ids if kf_session[kid] == sid)
            idx0 = id_to_idx[first]
            out.append(50.0 * (bgs[idx0] - bg_init))
            out.append(20.0 * bas[idx0])
        if t_prior_vec is not None:
            out.append((t_opt - t_prior_vec) / np.maximum(t_sigma, 1e-3))
        else:
            out.append(0.2 * t_opt)  # soft |t|~0 prior when no CAD prior
        return np.concatenate(out)

    # Cap evaluations: Phase-C is high-dimensional; synthetic ICP already dominates runtime.
    opt = least_squares(residuals, x0, loss="huber", f_scale=0.05, max_nfev=80)
    r_opt, t_opt, g_opt, vels, bgs, bas = unpack(opt.x)

    rot_errs = []
    trans_errs = []
    nav_r = _build_nav_rotations(keyframe_ids, id_to_idx, consecutive_pairs, r_opt, t_opt)
    for pair in usable:
        i_idx = id_to_idx[pair.i]
        j_idx = id_to_idx[pair.j]
        dt = max(float(pair.metadata.get("duration_s", pair.t_j_s - pair.t_i_s)), 1e-3)
        delta_r, delta_v, delta_p = _corrected_preint_quantities(
            pair, bgs[i_idx], bas[i_idx], bg0, ba0
        )
        t_b = np.asarray(pair.t_B_m, dtype=float).reshape(3)
        r_meas, t_meas = _lidar_to_imu_relative(r_opt, t_opt, pair.R_B, t_b)
        r_i = nav_r[i_idx]
        err_r = so3_log(delta_r.T @ r_meas)
        err_p = r_i @ (t_meas - delta_p) - vels[i_idx] * dt - 0.5 * g_opt * (dt**2)
        rot_errs.append(np.degrees(np.linalg.norm(err_r)))
        trans_errs.append(float(np.linalg.norm(err_p)))
        del delta_v, j_idx

    rot_rms = float(np.sqrt(np.mean(np.square(rot_errs)))) if rot_errs else 1e9
    trans_rms = float(np.sqrt(np.mean(np.square(trans_errs)))) if trans_errs else 1e9
    bg_mean = np.mean(bgs, axis=0)
    ba_mean = np.mean(bas, axis=0)
    notes.append(
        "phase-C SE3 (Δv/Δp + g + keyframe v/bias RW): "
        f"keyframes={k_count}, pairs={len(usable)}, "
        f"|t|={float(np.linalg.norm(t_opt)):.3f} m, "
        f"|g|={float(np.linalg.norm(g_opt)):.3f}, "
        f"trans_rms={trans_rms:.3f} m"
    )
    return r_opt, t_opt, g_opt, bg_mean, ba_mean, rot_rms, trans_rms, notes


def _pair_gyro_bias0(pair: MotionPair, fallback: np.ndarray) -> np.ndarray:
    raw = pair.metadata.get("gyro_bias0_rad_s")
    if raw is None:
        return np.asarray(fallback, dtype=float).reshape(3)
    return np.asarray(raw, dtype=float).reshape(3)


def _phase_a_bias_bases(
    pairs: list[MotionPair],
    *,
    gyro_bias_rad_s: np.ndarray | None,
    gyro_bias_rad_s_by_session: Mapping[str, np.ndarray] | None,
) -> dict[str, np.ndarray]:
    session_ids = sorted({pair.session_id for pair in pairs})
    scalar = None
    if gyro_bias_rad_s is not None:
        scalar = np.asarray(gyro_bias_rad_s, dtype=float).reshape(3)
    supplied = {} if gyro_bias_rad_s_by_session is None else gyro_bias_rad_s_by_session
    bases: dict[str, np.ndarray] = {}
    for sid in session_ids:
        if sid in supplied:
            bases[sid] = np.asarray(supplied[sid], dtype=float).reshape(3)
            continue
        pair = next(
            (
                item
                for item in pairs
                if item.session_id == sid and "gyro_bias0_rad_s" in item.metadata
            ),
            None,
        )
        if pair is not None:
            bases[sid] = np.asarray(pair.metadata["gyro_bias0_rad_s"], dtype=float).reshape(3)
        elif scalar is not None:
            bases[sid] = scalar.copy()
        else:
            bases[sid] = np.zeros(3)
    return bases


def _rotation_distribution(errs_deg: list[float]) -> tuple[float, float, float, float, bool]:
    if not errs_deg:
        return 1e9, 1e9, 1e9, 1.0, False
    errs = np.asarray(errs_deg, dtype=float)
    rms = float(np.sqrt(np.mean(errs**2)))
    median = float(np.median(errs))
    p95 = float(np.percentile(errs, 95.0))
    outlier_fraction = float(np.mean(errs > 5.0))
    accepted = (
        len(errs) >= 3
        and rms < 1.5
        and median < 0.5
        and p95 < 1.5
        and outlier_fraction <= 0.005
    )
    return rms, median, p95, outlier_fraction, accepted


def _solve_phase_a_rotation(
    pairs: list[MotionPair],
    r_seed: np.ndarray,
    *,
    bias_bases: Mapping[str, np.ndarray],
    imu: ImuSeries | None,
    bias_prior_sigma_rad_s: float,
    preexcluded_session_ids: set[str] | None = None,
) -> tuple[
    np.ndarray,
    dict[str, np.ndarray],
    tuple[PhaseASessionResult, ...],
    list[MotionPair],
    float,
    bool,
    list[str],
]:
    notes: list[str] = []
    all_session_ids = sorted({pair.session_id for pair in pairs})
    prior_w = 1.0 / max(bias_prior_sigma_rad_s, 1e-4)

    def optimize(
        active_pairs: list[MotionPair],
        r0: np.ndarray,
        bias_seed: Mapping[str, np.ndarray],
    ) -> tuple[np.ndarray, dict[str, np.ndarray]]:
        session_ids = sorted({pair.session_id for pair in active_pairs})
        session_index = {sid: index for index, sid in enumerate(session_ids)}
        whiten = [residual_whiten_matrix(_pair_cov(pair)) for pair in active_pairs]
        x0 = np.zeros(3 + 3 * len(session_ids))
        for sid, index in session_index.items():
            x0[3 + 3 * index : 6 + 3 * index] = np.asarray(bias_seed[sid], dtype=float)

        def residual(vec: np.ndarray) -> np.ndarray:
            r_opt = orthonormalize_rotation(so3_exp(vec[:3]) @ r0)
            out: list[np.ndarray] = []
            for pair, sqrt_info in zip(active_pairs, whiten):
                index = session_index[pair.session_id]
                bias = vec[3 + 3 * index : 6 + 3 * index]
                base = _pair_gyro_bias0(pair, bias_bases[pair.session_id])
                delta_r = _corrected_delta_r(
                    pair, bias - base, imu=imu, bias0=base
                )
                out.append(
                    sqrt_info
                    @ preintegration_rotation_residual(delta_r, r_opt, pair.R_B)
                )
            for sid, index in session_index.items():
                bias = vec[3 + 3 * index : 6 + 3 * index]
                out.append(prior_w * (bias - bias_bases[sid]))
            return np.concatenate(out)

        opt = least_squares(residual, x0, loss="huber", f_scale=1.0, max_nfev=200)
        r_opt = orthonormalize_rotation(so3_exp(opt.x[:3]) @ r0)
        biases = {
            sid: opt.x[3 + 3 * index : 6 + 3 * index].copy()
            for sid, index in session_index.items()
        }
        return r_opt, biases

    def summarize(
        r_opt: np.ndarray,
        biases: Mapping[str, np.ndarray],
        included: set[str],
    ) -> tuple[PhaseASessionResult, ...]:
        results: list[PhaseASessionResult] = []
        for sid in all_session_ids:
            local_pairs = [pair for pair in pairs if pair.session_id == sid]
            bias = np.asarray(biases.get(sid, bias_bases[sid]), dtype=float).reshape(3)
            errs: list[float] = []
            for pair in local_pairs:
                base = _pair_gyro_bias0(pair, bias_bases[sid])
                delta_r = _corrected_delta_r(pair, bias - base, imu=imu, bias0=base)
                err = preintegration_rotation_residual(delta_r, r_opt, pair.R_B)
                errs.append(float(np.degrees(np.linalg.norm(err))))
            rms, median, p95, outlier, accepted = _rotation_distribution(errs)
            results.append(
                PhaseASessionResult(
                    session_id=sid,
                    pair_count=len(local_pairs),
                    gyro_bias0_rad_s=np.asarray(bias_bases[sid], dtype=float),
                    gyro_bias_rad_s=bias,
                    residual_rms_deg=rms,
                    residual_median_deg=median,
                    residual_p95_deg=p95,
                    outlier_fraction_gt_5deg=outlier,
                    accepted=accepted,
                    included_in_final=sid in included,
                )
            )
        return tuple(results)

    if not pairs:
        return r_seed, dict(bias_bases), (), [], 1e9, False, ["no pairs for phase-A"]

    r_first, biases_first = optimize(pairs, r_seed, bias_bases)
    first = summarize(r_first, biases_first, set(all_session_ids))
    accepted_ids = {item.session_id for item in first if item.accepted}
    preexcluded = set() if preexcluded_session_ids is None else set(preexcluded_session_ids)
    accepted_ids -= preexcluded
    active_ids = set(all_session_ids)
    r_final = r_first
    biases_final = dict(biases_first)
    if preexcluded and not accepted_ids:
        active_ids = set()
        notes.append(f"phase-A pre-gate excluded all sessions: {sorted(preexcluded)}")
    elif accepted_ids and accepted_ids != active_ids:
        active_ids = accepted_ids
        active_pairs = [pair for pair in pairs if pair.session_id in active_ids]
        r_final, active_biases = optimize(active_pairs, r_first, biases_first)
        biases_final.update(active_biases)
        excluded = sorted(set(all_session_ids) - active_ids)
        notes.append(f"phase-A excluded sessions after local/pre residual gate: {excluded}")
    active_pairs = [pair for pair in pairs if pair.session_id in active_ids]
    final = summarize(r_final, biases_final, active_ids)
    active_results = [item for item in final if item.included_in_final]
    global_errs: list[float] = []
    for pair in active_pairs:
        bias = biases_final[pair.session_id]
        base = _pair_gyro_bias0(pair, bias_bases[pair.session_id])
        delta_r = _corrected_delta_r(pair, bias - base, imu=imu, bias0=base)
        err = preintegration_rotation_residual(delta_r, r_final, pair.R_B)
        global_errs.append(float(np.degrees(np.linalg.norm(err))))
    rot_rms, _, _, _, global_ok = _rotation_distribution(global_errs)
    accepted = bool(active_results and global_ok and all(item.accepted for item in active_results))
    notes.append(
        f"phase-A session-local bias refine: sessions={len(active_ids)}/{len(all_session_ids)}, "
        f"pairs={len(active_pairs)}, rms={rot_rms:.3f} deg"
    )
    return r_final, biases_final, final, active_pairs, rot_rms, accepted, notes


def _solve_joint_extrinsic_legacy(
    pairs: list[MotionPair] | tuple[MotionPair, ...],
    r_x: np.ndarray,
    *,
    force_rotation_only: bool = False,
    imu: ImuSeries | None = None,
    delta_t_s: float = 0.0,
    gyro_bias_rad_s: np.ndarray | None = None,
    gravity_init_m_s2: np.ndarray | None = None,
    gyro_bias_rad_s_by_session: Mapping[str, np.ndarray] | None = None,
    time_offset_s_by_session: Mapping[str, float] | None = None,
    bias_prior_sigma_rad_s: float = 0.02,
    enable_phase_c: bool | None = None,
    t_init_m: np.ndarray | None = None,
    t_prior_m: np.ndarray | None = None,
    t_prior_sigma_m: np.ndarray | float | None = None,
) -> JointExtrinsicResult:
    """Refine extrinsic using Phase-A whitened rotation factors, optional Phase-C SE(3)."""

    del delta_t_s  # reserved for future SE(3) time coupling
    if enable_phase_c is None:
        enable_phase_c = not force_rotation_only

    usable = [pair for pair in pairs if pair.t_B_m is not None]
    observability = analyze_observability(usable, r_x)
    notes = list(observability.notes)

    r = orthonormalize_rotation(np.asarray(r_x, dtype=float))
    bias0 = np.zeros(3) if gyro_bias_rad_s is None else np.asarray(gyro_bias_rad_s, dtype=float).reshape(3)
    t_seed = None if t_init_m is None else np.asarray(t_init_m, dtype=float).reshape(3)
    weights = np.asarray([_pair_weight(pair) for pair in usable], dtype=float)
    whitens = [residual_whiten_matrix(_pair_cov(pair)) for pair in usable]
    prior_w = 1.0 / max(bias_prior_sigma_rad_s, 1e-4)

    def rotation_residuals(r_opt: np.ndarray, delta_bias: np.ndarray) -> np.ndarray:
        residuals = []
        for pair, whiten in zip(usable, whitens):
            delta_r = _corrected_delta_r(pair, delta_bias, imu=imu, bias0=bias0)
            err = preintegration_rotation_residual(delta_r, r_opt, pair.R_B)
            residuals.append(whiten @ err)
        residuals.append(prior_w * delta_bias)
        return np.concatenate(residuals) if residuals else np.zeros(0)

    def residual_rot_bias(vec: np.ndarray) -> np.ndarray:
        r_opt = orthonormalize_rotation(so3_exp(vec[:3]) @ r)
        return rotation_residuals(r_opt, vec[3:])

    if usable:
        opt = least_squares(
            residual_rot_bias,
            np.zeros(6),
            loss="huber",
            f_scale=1.0,
            max_nfev=200,
        )
        r = orthonormalize_rotation(so3_exp(opt.x[:3]) @ r)
        delta_bias = opt.x[3:]
        bias_out = bias0 + delta_bias
        notes.append(
            "phase-A joint refine (single Σ whitening + J_bg): "
            f"|δb|={float(np.linalg.norm(delta_bias)):.3e} rad/s, "
            f"pairs={len(usable)}"
        )
    else:
        bias_out = bias0
        delta_bias = np.zeros(3)
        notes.append("no pairs for joint refine")

    rot_errs = []
    for pair in usable:
        delta_r = _corrected_delta_r(pair, delta_bias, imu=imu, bias0=bias0)
        err = preintegration_rotation_residual(delta_r, r, pair.R_B)
        rot_errs.append(np.degrees(np.linalg.norm(err)))
    rot_rms = float(np.sqrt(np.mean(np.square(rot_errs)))) if rot_errs else 1e9

    t = np.zeros(3) if t_seed is None else t_seed.copy()
    translation_accepted = False
    trans_rms = 1e9
    gravity_out: np.ndarray | None = None
    accel_bias_out: np.ndarray | None = None

    if gravity_init_m_s2 is None:
        gravity_init = np.array([0.0, 0.0, -G_NORM])
    else:
        gravity_init = np.asarray(gravity_init_m_s2, dtype=float).reshape(3)

    if t_prior_m is not None:
        notes.append(
            "using CAD/installation translation prior "
            f"t={np.asarray(t_prior_m, dtype=float).reshape(3).tolist()}"
        )

    if (
        enable_phase_c
        and not force_rotation_only
        and observability.translation_observable
        and observability.rotation_observable
        and len(usable) >= 5
    ):
        r, t, gravity_out, bias_out, accel_bias_out, rot_rms, trans_rms, c_notes = _solve_phase_c_se3(
            usable,
            r,
            gyro_bias_linearization=bias0,
            gyro_bias_init=bias_out,
            gravity_init=gravity_init,
            t_init=t_seed if t_seed is not None else t_prior_m,
            t_prior=t_prior_m,
            t_prior_sigma_m=t_prior_sigma_m,
        )
        notes.extend(c_notes)
        translation_accepted = bool(trans_rms < 0.75 and np.linalg.norm(t) > 1e-4)
        if not translation_accepted:
            # Prefer CAD prior over silent zero when motion SE3 is rejected.
            if t_prior_m is not None:
                t = np.asarray(t_prior_m, dtype=float).reshape(3)
                notes.append(
                    "phase-C translation residual/gate failed; CAD translation is reported "
                    "as a prior only and is not accepted as calibration"
                )
            else:
                notes.append("phase-C translation residual/gate failed; keeping translation at zero")
                t = np.zeros(3)
    elif (
        not force_rotation_only
        and observability.translation_observable
        and observability.rotation_observable
        and len(usable) >= 5
    ):
        # Legacy hand-eye translation fallback when Phase-C metadata missing.
        def residual_se3(vec: np.ndarray) -> np.ndarray:
            r_opt = orthonormalize_rotation(so3_exp(vec[:3]) @ r)
            t_opt = vec[3:]
            residuals = []
            for pair, weight, whiten in zip(usable, weights, whitens):
                delta_r = _corrected_delta_r(pair, delta_bias, imu=imu, bias0=bias0)
                residuals.append(
                    np.sqrt(weight) * (whiten @ preintegration_rotation_residual(delta_r, r_opt, pair.R_B))
                )
                pred = (pair.R_A - np.eye(3)) @ t_opt
                meas = r_opt @ np.asarray(pair.t_B_m, dtype=float)
                residuals.append(np.sqrt(weight) * (pred - meas))
            if t_prior_m is not None:
                sigma = np.asarray(t_prior_sigma_m if t_prior_sigma_m is not None else 0.05, dtype=float)
                if sigma.size == 1:
                    sigma = np.full(3, float(sigma), dtype=float)
                residuals.append((t_opt - np.asarray(t_prior_m, dtype=float).reshape(3)) / np.maximum(sigma, 1e-3))
            return np.concatenate(residuals)

        x_se3 = np.zeros(6)
        if t_seed is not None:
            x_se3[3:] = t_seed
        elif t_prior_m is not None:
            x_se3[3:] = np.asarray(t_prior_m, dtype=float).reshape(3)
        opt_t = least_squares(residual_se3, x_se3, loss="huber", f_scale=0.05, max_nfev=200)
        r = orthonormalize_rotation(so3_exp(opt_t.x[:3]) @ r)
        t = opt_t.x[3:]
        rot_errs = []
        trans_errs = []
        for pair in usable:
            delta_r = _corrected_delta_r(pair, delta_bias, imu=imu, bias0=bias0)
            rot_errs.append(np.degrees(np.linalg.norm(preintegration_rotation_residual(delta_r, r, pair.R_B))))
            pred = (pair.R_A - np.eye(3)) @ t
            meas = r @ np.asarray(pair.t_B_m, dtype=float)
            trans_errs.append(np.linalg.norm(pred - meas))
        rot_rms = float(np.sqrt(np.mean(np.square(rot_errs))))
        trans_rms = float(np.sqrt(np.mean(np.square(trans_errs))))
        translation_accepted = trans_rms < 0.5
        notes.append(f"legacy translation refine rms={trans_rms:.3f} m")
        if not translation_accepted:
            notes.append("translation residual too large; keeping translation at zero")
            t = np.zeros(3)
    elif not force_rotation_only and t_prior_m is not None:
        t = np.asarray(t_prior_m, dtype=float).reshape(3)
        translation_accepted = False
        notes.append(
            "SE3 motion solve gated off; CAD translation is reported as a prior only "
            "and is not accepted as calibration"
        )
    else:
        notes.append("rotation-only extrinsic returned (phase-A; phase-C SE3 gated off)")

    return JointExtrinsicResult(
        T_IMU_lidar=make_transform(t, r),
        translation_accepted=bool(translation_accepted and np.linalg.norm(t) > 0),
        residual_rms_rot_deg=rot_rms,
        residual_rms_trans_m=trans_rms,
        observability=observability,
        gyro_bias_rad_s=np.asarray(bias_out, dtype=float),
        accel_bias_m_s2=None if accel_bias_out is None else np.asarray(accel_bias_out, dtype=float),
        gravity_m_s2=None if gravity_out is None else np.asarray(gravity_out, dtype=float),
        notes=tuple(notes),
    )


def solve_joint_extrinsic(
    pairs: list[MotionPair] | tuple[MotionPair, ...],
    r_x: np.ndarray,
    *,
    force_rotation_only: bool = False,
    imu: ImuSeries | None = None,
    delta_t_s: float = 0.0,
    gyro_bias_rad_s: np.ndarray | None = None,
    gyro_bias_rad_s_by_session: Mapping[str, np.ndarray] | None = None,
    time_offset_s_by_session: Mapping[str, float] | None = None,
    preexcluded_session_ids: set[str] | None = None,
    gravity_init_m_s2: np.ndarray | None = None,
    bias_prior_sigma_rad_s: float = 0.002,
    rotation_prior: np.ndarray | None = None,
    rotation_prior_sigma_deg: float = 15.0,
    phase_a_yaw_std_max_deg: float = 0.5,
    phase_a_loo_yaw_range_max_deg: float = 1.0,
    phase_a_data_prior_difference_max_deg: float = 1.0,
    run_phase_a_leave_one_out: bool = True,
    phase_a_progress_callback: (
        Callable[[str, dict[str, Any]], None] | None
    ) = None,
    enable_phase_c: bool | None = None,
    t_init_m: np.ndarray | None = None,
    t_prior_m: np.ndarray | None = None,
    t_prior_sigma_m: np.ndarray | float | None = None,
) -> JointExtrinsicResult:
    """Run the corrected session-aware Phase-A and gate unfinished SE(3) stages."""

    del gravity_init_m_s2, t_init_m, t_prior_sigma_m, imu, r_x
    usable_input = [pair for pair in pairs if pair.t_B_m is not None]
    bias_bases = _phase_a_bias_bases(
        usable_input,
        gyro_bias_rad_s=gyro_bias_rad_s,
        gyro_bias_rad_s_by_session=gyro_bias_rad_s_by_session,
    )
    comparison = solve_phase_a_comparison(
        usable_input,
        gyro_bias_rad_s_by_session=bias_bases,
        rotation_prior=rotation_prior,
        rotation_prior_sigma_deg=rotation_prior_sigma_deg,
        preexcluded_session_ids=preexcluded_session_ids,
        bias_prior_sigma_rad_s=bias_prior_sigma_rad_s,
        yaw_std_max_deg=phase_a_yaw_std_max_deg,
        leave_one_out_yaw_range_max_deg=(
            phase_a_loo_yaw_range_max_deg
        ),
        data_prior_difference_max_deg=(
            phase_a_data_prior_difference_max_deg
        ),
        run_leave_one_out=run_phase_a_leave_one_out,
        progress_callback=phase_a_progress_callback,
    )
    primary = comparison.session_bg_data_only
    r = primary.R_IMU_lidar
    biases = primary.gyro_bias_rad_s_per_session
    rot_rms = primary.residual_rms_deg
    phase_a_accepted = comparison.accepted
    notes = list(comparison.notes)
    notes.append(
        "phase-A primary=A1_session_bg_data_only; "
        f"A0 RPY={comparison.fixed_bg_data_only.rpy_deg_xyz.tolist()}, "
        f"A1 RPY={primary.rpy_deg_xyz.tolist()}, "
        "A2 RPY="
        f"{comparison.session_bg_with_rotation_prior.rpy_deg_xyz.tolist()}"
    )
    notes.append(
        f"phase-A marginal yaw_std={comparison.marginal_observability.yaw_std_deg:.3f} deg, "
        f"LOO yaw range={comparison.leave_one_out_yaw_range_deg:.3f} deg"
    )

    session_results_list: list[PhaseASessionResult] = [
        PhaseASessionResult(
            session_id=item.session_id,
            pair_count=item.pair_count,
            gyro_bias0_rad_s=item.gyro_bias0_rad_s,
            gyro_bias_rad_s=item.gyro_bias_rad_s,
            residual_rms_deg=item.residual_rms_deg,
            residual_median_deg=item.residual_median_deg,
            residual_p95_deg=item.residual_p95_deg,
            outlier_fraction_gt_5deg=item.outlier_fraction_gt_5deg,
            accepted=item.accepted,
            included_in_final=True,
        )
        for item in primary.sessions
    ]
    preexcluded = (
        set()
        if preexcluded_session_ids is None
        else set(preexcluded_session_ids)
    )
    strong_all = select_strong_rotation_pairs(usable_input)
    for session_id in sorted(preexcluded):
        local_pairs = [
            pair for pair in strong_all if pair.session_id == session_id
        ]
        errors = [
            float(
                np.degrees(
                    np.linalg.norm(
                        preintegration_rotation_residual(
                            pair.R_A, r, pair.R_B
                        )
                    )
                )
            )
            for pair in local_pairs
        ]
        rms, median, p95, outlier, accepted = _rotation_distribution(
            errors
        )
        base = np.asarray(
            bias_bases.get(session_id, np.zeros(3)), dtype=float
        ).reshape(3)
        session_results_list.append(
            PhaseASessionResult(
                session_id=session_id,
                pair_count=len(local_pairs),
                gyro_bias0_rad_s=base,
                gyro_bias_rad_s=base,
                residual_rms_deg=rms,
                residual_median_deg=median,
                residual_p95_deg=p95,
                outlier_fraction_gt_5deg=outlier,
                accepted=accepted,
                included_in_final=False,
            )
        )
    session_results = tuple(
        sorted(session_results_list, key=lambda item: item.session_id)
    )
    usable = [
        pair
        for pair in strong_all
        if pair.session_id not in preexcluded
    ]
    base_observability = analyze_observability(usable, r)
    marginal = comparison.marginal_observability
    observability = ObservabilityReport(
        rotation_observable=bool(
            marginal.rank == 3
            and marginal.yaw_std_deg <= phase_a_yaw_std_max_deg
        ),
        translation_observable=base_observability.translation_observable,
        condition_rotation=marginal.condition,
        condition_translation=base_observability.condition_translation,
        notes=tuple(
            list(marginal.notes)
            + list(base_observability.notes)
        ),
    )
    notes.extend(observability.notes)
    if time_offset_s_by_session is None:
        notes.append(
            f"legacy scalar time offset fixed during pair construction: {float(delta_t_s):.6f}s"
        )
    else:
        fixed_offsets = {
            str(sid): float(value) for sid, value in time_offset_s_by_session.items()
        }
        notes.append(
            f"time offsets fixed during pair construction (not optimized): {fixed_offsets}"
        )

    for item in session_results:
        notes.append(
            f"phase-A session {item.session_id}: included={item.included_in_final}, "
            f"pairs={item.pair_count}, rms={item.residual_rms_deg:.3f} deg, "
            f"p95={item.residual_p95_deg:.3f} deg, "
            f"|bias-bias0|={float(np.linalg.norm(item.gyro_bias_rad_s - item.gyro_bias0_rad_s)):.3e}"
        )

    phase_c_requested = (not force_rotation_only) if enable_phase_c is None else bool(enable_phase_c)
    t = np.zeros(3)
    if not force_rotation_only:
        if phase_c_requested:
            notes.append(
                "phase-B/C gated off: session-aware translation/gravity/navigation "
                "states are not implemented yet"
            )
        else:
            notes.append("phase-C disabled; translation is not accepted")
        if t_prior_m is not None:
            t = np.asarray(t_prior_m, dtype=float).reshape(3)
            notes.append(
                "CAD translation is reported as a prior only and is not accepted as calibration"
            )
    else:
        notes.append("rotation-only extrinsic returned after corrected phase-A")

    single_bias = None
    if len(biases) == 1:
        single_bias = np.asarray(next(iter(biases.values())), dtype=float)
    return JointExtrinsicResult(
        T_IMU_lidar=make_transform(t, r),
        translation_accepted=False,
        residual_rms_rot_deg=rot_rms,
        residual_rms_trans_m=1e9,
        observability=observability,
        gyro_bias_rad_s=single_bias,
        accel_bias_m_s2=None,
        gravity_m_s2=None,
        gyro_bias_rad_s_per_session={
            sid: np.asarray(value, dtype=float) for sid, value in biases.items()
        },
        phase_a_sessions=session_results,
        phase_a_accepted=phase_a_accepted,
        phase_a_comparison=phase_a_comparison_to_dict(comparison),
        notes=tuple(notes),
    )
