"""Executable LiDAR–IMU calibration pipeline (V1)."""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from time import perf_counter
from typing import Any

import numpy as np

from .contracts import (
    CalibrationMode,
    CalibrationRequest,
    CalibrationResult,
    CalibrationStatus,
    MotionPair,
    SessionInput,
)
from .finalize import finalize_result
from .imu_audit import audit_imu
from .imu_io import load_imu_samples
from .joint_optimizer import solve_joint_extrinsic
from .keyframes import build_keyframes
from .lidar_deskew import deskew_lidar_frames
from .lidar_io import load_lidar_frames
from .motion_pairs import build_motion_pairs
from .motion_pairs_io import build_motion_pairs_payload
from .rotation_handeye import solve_rotation_handeye
from .time_offset import TimeOffsetResult, estimate_time_offset, refine_time_offset_signed
from .timestamp_audit import audit_timestamps
from .vehicle_config import load_vehicle_config, prior_enabled

# Remap keyframe indices so multi-session Phase-C graphs do not collide.
_SESSION_INDEX_OFFSET = 1_000_000


def _merge_time_offset(previous: TimeOffsetResult, refined: TimeOffsetResult) -> TimeOffsetResult:
    return TimeOffsetResult(
        delta_t_s=refined.delta_t_s,
        correlation_peak=refined.correlation_peak,
        search_s=previous.search_s,
        notes=tuple(list(previous.notes) + list(refined.notes)),
        ok=True,
    )


@dataclass(frozen=True)
class PipelineStage:
    name: str
    responsibility: str


STAGES = (
    PipelineStage("vehicle_config", "加载并校验当前车辆安装配置"),
    PipelineStage("timestamp_audit", "审查 IMU 与 LiDAR 时间域"),
    PipelineStage("imu_audit", "审查单位、轴向启发与静止零偏"),
    PipelineStage("time_offset", "各会话独立粗估/精修 δt"),
    PipelineStage("lidar_motion", "各会话关键帧、可选去畸变与 LiDAR 相对运动"),
    PipelineStage("motion_pairs", "各会话构造运动对，再合并"),
    PipelineStage("rotation_handeye", "用全部会话运动对联合求解旋转外参"),
    PipelineStage("joint_optimizer", "Phase-A 会话级零偏联合精修；Phase-B/C 暂时门控"),
    PipelineStage("finalize", "写出结果与质量报告"),
)


ProgressCallback = Callable[[dict[str, Any]], None]


def _emit_progress(
    callback: ProgressCallback | None,
    stage_index: int,
    event: str,
    **fields: Any,
) -> None:
    if callback is None:
        return
    callback(
        {
            "stage_index": stage_index,
            "stage_total": len(STAGES),
            "stage": STAGES[stage_index - 1].name,
            "event": event,
            **fields,
        }
    )


def describe_pipeline(_: CalibrationRequest) -> tuple[PipelineStage, ...]:
    """Return the planned stages."""

    return STAGES


def _build_pairs_and_handeye(
    *,
    session_id: str,
    working_frames,
    imu,
    delta_t_s: float,
    gyro_bias_rad_s: np.ndarray,
    request: CalibrationRequest,
    R_prior: np.ndarray | None = None,
    prior_sigma_deg: float | None = None,
    progress_callback: ProgressCallback | None = None,
):
    keyframes = build_keyframes(
        working_frames,
        min_translation_m=request.min_pair_translation_m,
        min_rotation_deg=request.min_pair_rotation_deg,
        min_registration_fitness=request.min_registration_fitness,
    )
    if progress_callback is not None:
        progress_callback(
            {
                "event": "keyframes_ready",
                "keyframe_count": len(keyframes.indices),
                "lidar_frame_count": len(working_frames),
            }
        )
    pair_set = build_motion_pairs(
        session_id=session_id,
        keyframes=list(keyframes.frames),
        keyframe_indices=keyframes.indices,
        imu=imu,
        delta_t_s=delta_t_s,
        gyro_bias_rad_s=gyro_bias_rad_s,
        min_rotation_deg=request.min_pair_rotation_deg,
        min_translation_m=request.min_pair_translation_m,
        min_registration_fitness=request.min_registration_fitness,
        max_imu_gap_s=request.max_imu_gap_s,
        max_lidar_gap_s=request.max_lidar_gap_s,
        all_frame_times_s=np.asarray([frame.t_mid_s for frame in working_frames], dtype=float),
        progress_callback=progress_callback,
    )
    handeye = solve_rotation_handeye(
        pair_set.pairs,
        R_prior=R_prior,
        prior_sigma_deg=prior_sigma_deg,
    )
    return keyframes, pair_set, handeye


def _translation_prior_from_config(
    vehicle_config: dict[str, Any] | None,
) -> tuple[np.ndarray | None, np.ndarray | float | None]:
    if vehicle_config is None or not prior_enabled(vehicle_config, "translation_prior"):
        return None, None
    init_cfg = vehicle_config.get("initialization") or {}
    tp = init_cfg.get("translation_prior") or {}
    if tp.get("t_IMU_lidar_m") is None:
        return None, None
    return np.asarray(tp["t_IMU_lidar_m"], dtype=float).reshape(3), tp.get("sigma_m", [0.05, 0.05, 0.05])


def _rotation_prior_from_config(
    vehicle_config: dict[str, Any] | None,
) -> tuple[np.ndarray | None, float | None]:
    if vehicle_config is None or not prior_enabled(vehicle_config, "rotation_prior"):
        return None, None
    init_cfg = vehicle_config.get("initialization") or {}
    rp = init_cfg.get("rotation_prior") or {}
    if rp.get("R_IMU_lidar") is None:
        return None, None
    return np.asarray(rp["R_IMU_lidar"], dtype=float).reshape(3, 3), float(rp.get("sigma_deg", 15.0))


def _prepare_session_pairs(
    session: SessionInput,
    request: CalibrationRequest,
    *,
    R_prior: np.ndarray | None = None,
    prior_sigma_deg: float | None = None,
    progress_callback: ProgressCallback | None = None,
    session_index: int = 1,
    session_total: int = 1,
) -> dict[str, Any]:
    """Per-session: audit, δt, keyframes/pairs. No joint extrinsic yet."""

    started_at = perf_counter()

    def emit(stage_index: int, event: str, **fields: Any) -> None:
        _emit_progress(
            progress_callback,
            stage_index,
            event,
            session=session.session_id,
            session_index=session_index,
            session_total=session_total,
            **fields,
        )

    emit(
        2,
        "session_start",
        imu_source=str(session.imu_source),
        lidar_source=str(session.lidar_source),
    )
    imu = load_imu_samples(session.imu_source)
    frames = load_lidar_frames(session.lidar_source)
    emit(
        2,
        "data_loaded",
        imu_samples=int(imu.t_s.size),
        lidar_frames=len(frames),
        imu_span_s=float(imu.t_s[-1] - imu.t_s[0]) if imu.t_s.size >= 2 else 0.0,
        lidar_span_s=(
            float(frames[-1].t_mid_s - frames[0].t_mid_s) if len(frames) >= 2 else 0.0
        ),
        elapsed_s=perf_counter() - started_at,
    )

    ts = audit_timestamps(imu, frames)
    emit(2, "audit_complete", ok=ts.ok)
    if not ts.ok:
        emit(2, "blocked", reason="timestamp_audit")
        return {"ok": False, "stage": "timestamp_audit", "session_id": session.session_id, "report": asdict(ts)}

    imu_report = audit_imu(imu)
    emit(
        3,
        "audit_complete",
        ok=imu_report.ok,
        gyro_bias_norm_rad_s=float(np.linalg.norm(imu_report.gyro_bias_rad_s)),
    )
    if not imu_report.ok:
        emit(3, "blocked", reason="imu_audit")
        return {"ok": False, "stage": "imu_audit", "session_id": session.session_id, "report": asdict(imu_report)}

    fixed_time_offset_s = (
        session.fixed_time_offset_s
        if session.fixed_time_offset_s is not None
        else request.fixed_time_offset_s
    )
    if fixed_time_offset_s is not None:
        offset_source = "fixed"
        offset = TimeOffsetResult(
            delta_t_s=float(fixed_time_offset_s),
            correlation_peak=1.0,
            search_s=0.0,
            notes=(
                f"fixed_time_offset_s={float(fixed_time_offset_s):.6f} "
                "(skip |ω| search; intended for host-UTC-bridged sessions)",
            ),
            ok=True,
        )
    else:
        offset_source = "estimated"
        offset = estimate_time_offset(
            imu,
            frames,
            gyro_bias_rad_s=imu_report.gyro_bias_rad_s,
            search_s=request.time_offset_search_s,
        )
        if not offset.ok:
            emit(
                4,
                "blocked",
                reason="time_offset",
                time_offset_s=float(offset.delta_t_s),
                correlation_peak=float(offset.correlation_peak),
            )
            return {"ok": False, "stage": "time_offset", "session_id": session.session_id, "report": asdict(offset)}

    emit(
        4,
        "offset_ready",
        source=offset_source,
        time_offset_s=float(offset.delta_t_s),
        correlation_peak=float(offset.correlation_peak),
    )
    coarse_delta_t = float(offset.delta_t_s)
    working_frames = frames
    r_x = np.eye(3) if R_prior is None else np.asarray(R_prior, dtype=float).reshape(3, 3)
    handeye = None
    pair_set = None
    keyframes = None
    pairs_notes: list[str] = []
    pair_count = 0

    iterations_total = max(1, request.max_iterations)
    build_pass = "outer"

    def on_build_progress(payload: dict[str, Any]) -> None:
        event = str(payload.get("event", "running"))
        stage_index = 5 if event == "keyframes_ready" else 6
        fields = {key: value for key, value in payload.items() if key != "event"}
        emit(
            stage_index,
            event,
            iteration=iteration + 1,
            iterations_total=iterations_total,
            build_pass=build_pass,
            **fields,
        )

    for iteration in range(iterations_total):
        build_pass = "outer"
        emit(
            5,
            "iteration_start",
            iteration=iteration + 1,
            iterations_total=iterations_total,
            deskew=iteration > 0,
            time_offset_s=float(offset.delta_t_s),
        )
        if iteration > 0:
            deskew_started_at = perf_counter()
            emit(5, "deskew_start", iteration=iteration + 1)
            working_frames = deskew_lidar_frames(
                frames,
                imu,
                delta_t_s=offset.delta_t_s,
                R_IMU_lidar=r_x,
                gyro_bias_rad_s=imu_report.gyro_bias_rad_s,
            )
            emit(
                5,
                "deskew_complete",
                iteration=iteration + 1,
                lidar_frames=len(working_frames),
                elapsed_s=perf_counter() - deskew_started_at,
            )
        keyframes, pair_set, handeye = _build_pairs_and_handeye(
            session_id=session.session_id,
            working_frames=working_frames,
            imu=imu,
            delta_t_s=offset.delta_t_s,
            gyro_bias_rad_s=imu_report.gyro_bias_rad_s,
            request=request,
            R_prior=R_prior,
            prior_sigma_deg=prior_sigma_deg,
            progress_callback=on_build_progress,
        )
        pairs_notes = list(pair_set.notes)
        pair_count = len(pair_set.pairs)
        emit(
            7,
            "local_handeye",
            iteration=iteration + 1,
            build_pass=build_pass,
            keyframes=len(keyframes.indices),
            pair_count=pair_count,
            rms_deg=float(handeye.residual_rms_deg),
            p95_deg=float(handeye.residual_p95_deg),
            outlier_fraction_gt_5deg=float(handeye.outlier_fraction_gt_5deg),
            ok=handeye.ok,
        )
        if pair_count < 3:
            emit(
                6,
                "blocked",
                reason="insufficient_motion_pairs",
                iteration=iteration + 1,
                keyframes=len(keyframes.indices),
                pair_count=pair_count,
            )
            return {
                "ok": False,
                "stage": "motion_pairs",
                "session_id": session.session_id,
                "iteration": iteration,
                "time_offset": asdict(offset),
                "imu_audit": asdict(imu_report),
                "timestamp_audit": asdict(ts),
                "keyframes": 0 if keyframes is None else len(keyframes.indices),
                "pair_notes": pairs_notes,
                "handeye": asdict(handeye),
            }
        r_x = handeye.R_IMU_lidar

        if not request.enable_signed_time_refine:
            continue

        for refine_step in range(1, 3):
            emit(
                4,
                "signed_refine_start",
                iteration=iteration + 1,
                refine_step=refine_step,
                time_offset_s=float(offset.delta_t_s),
            )
            refined = refine_time_offset_signed(
                imu,
                frames,
                delta_t_s=offset.delta_t_s,
                R_IMU_lidar=r_x,
                gyro_bias_rad_s=imu_report.gyro_bias_rad_s,
                search_s=min(0.12, max(0.04, 0.25 * request.time_offset_search_s)),
                max_shift_s=request.max_signed_refine_shift_s,
            )
            # Also bound total walk away from the original coarse estimate.
            if abs(refined.delta_t_s - coarse_delta_t) > request.max_signed_refine_shift_s:
                refined = TimeOffsetResult(
                    delta_t_s=float(offset.delta_t_s),
                    correlation_peak=refined.correlation_peak,
                    search_s=refined.search_s,
                    notes=tuple(
                        list(refined.notes)
                        + [
                            f"signed refine clamped: |δt-coarse| would exceed "
                            f"{request.max_signed_refine_shift_s:.3f}s"
                        ]
                    ),
                    ok=True,
                )
            delta_shift = abs(refined.delta_t_s - offset.delta_t_s)
            offset = _merge_time_offset(offset, refined)
            emit(
                4,
                "signed_refine_complete",
                iteration=iteration + 1,
                refine_step=refine_step,
                time_offset_s=float(offset.delta_t_s),
                shift_s=float(delta_shift),
                correlation_peak=float(refined.correlation_peak),
            )
            if delta_shift < 1e-3:
                break
            build_pass = f"signed_refine_{refine_step}"
            keyframes, pair_set, handeye = _build_pairs_and_handeye(
                session_id=session.session_id,
                working_frames=working_frames,
                imu=imu,
                delta_t_s=offset.delta_t_s,
                gyro_bias_rad_s=imu_report.gyro_bias_rad_s,
                request=request,
                R_prior=R_prior,
                prior_sigma_deg=prior_sigma_deg,
                progress_callback=on_build_progress,
            )
            pairs_notes = list(pair_set.notes)
            pair_count = len(pair_set.pairs)
            emit(
                7,
                "local_handeye",
                iteration=iteration + 1,
                build_pass=build_pass,
                keyframes=len(keyframes.indices),
                pair_count=pair_count,
                rms_deg=float(handeye.residual_rms_deg),
                p95_deg=float(handeye.residual_p95_deg),
                outlier_fraction_gt_5deg=float(handeye.outlier_fraction_gt_5deg),
                ok=handeye.ok,
            )
            if pair_count < 3:
                emit(
                    6,
                    "blocked",
                    reason="insufficient_motion_pairs_after_signed_refine",
                    iteration=iteration + 1,
                    keyframes=len(keyframes.indices),
                    pair_count=pair_count,
                )
                return {
                    "ok": False,
                    "stage": "motion_pairs",
                    "session_id": session.session_id,
                    "iteration": iteration,
                    "time_offset": asdict(offset),
                    "imu_audit": asdict(imu_report),
                    "timestamp_audit": asdict(ts),
                    "keyframes": 0 if keyframes is None else len(keyframes.indices),
                    "pair_notes": pairs_notes,
                    "handeye": asdict(handeye),
                }
            r_x = handeye.R_IMU_lidar
    assert handeye is not None and pair_set is not None and keyframes is not None
    acc_mean = np.asarray(imu_report.static_acc_mean_m_s2, dtype=float).reshape(3)
    acc_n = float(np.linalg.norm(acc_mean))
    if acc_n > 1e-6:
        gravity_init = -acc_mean * (9.80665 / acc_n)
    else:
        gravity_init = np.array([0.0, 0.0, -9.80665])

    emit(
        7,
        "session_complete",
        keyframes=len(keyframes.indices),
        pair_count=pair_count,
        time_offset_s=float(offset.delta_t_s),
        local_handeye_ok=handeye.ok,
        elapsed_s=perf_counter() - started_at,
    )
    return {
        "ok": True,
        "session_id": session.session_id,
        "pairs": tuple(pair_set.pairs),
        "gyro_bias_rad_s": np.asarray(imu_report.gyro_bias_rad_s, dtype=float).reshape(3),
        "gravity_init_m_s2": gravity_init,
        "timestamp_audit": asdict(ts),
        "imu_audit": {
            **asdict(imu_report),
            "gyro_bias_rad_s": imu_report.gyro_bias_rad_s.tolist(),
            "static_acc_mean_m_s2": imu_report.static_acc_mean_m_s2.tolist(),
        },
        "time_offset": asdict(offset),
        "time_offset_s": float(offset.delta_t_s),
        "keyframes": len(keyframes.indices),
        "pair_count": pair_count,
        "pair_notes": pairs_notes,
        "handeye_local": {
            "residual_rms_deg": handeye.residual_rms_deg,
            "residual_median_deg": handeye.residual_median_deg,
            "residual_p95_deg": handeye.residual_p95_deg,
            "outlier_fraction_gt_5deg": handeye.outlier_fraction_gt_5deg,
            "pair_count": handeye.pair_count,
            "ok": handeye.ok,
            "notes": handeye.notes,
            "R_IMU_lidar": handeye.R_IMU_lidar.tolist(),
        },
    }


def _remap_pairs_for_joint(prepared: list[dict[str, Any]]) -> list[MotionPair]:
    merged: list[MotionPair] = []
    for index, prep in enumerate(prepared):
        id_offset = (index + 1) * _SESSION_INDEX_OFFSET
        for pair in prep["pairs"]:
            merged.append(
                replace(
                    pair,
                    i=int(pair.i) + id_offset,
                    j=int(pair.j) + id_offset,
                )
            )
    return merged


def run_calibration(
    request: CalibrationRequest,
    *,
    progress_callback: ProgressCallback | None = None,
) -> CalibrationResult:
    """Run the V1 calibration pipeline for one or more sessions.

    Multi-session: each session estimates its own δt and builds motion pairs;
    rotation hand-eye and joint SE3 are solved once on the merged pair set.
    """

    overall_started_at = perf_counter()

    def finish(
        *,
        status: CalibrationStatus,
        message: str,
        details: dict[str, Any],
        T_IMU_lidar: np.ndarray | None = None,
        time_offset_s: float | None = None,
        motion_pairs_payload: dict[str, Any] | None = None,
    ) -> CalibrationResult:
        _emit_progress(
            progress_callback,
            9,
            "writing_result",
            status=status.value,
            output_directory=str(request.output_directory),
        )
        result = finalize_result(
            status=status,
            message=message,
            details=details,
            T_IMU_lidar=T_IMU_lidar,
            time_offset_s=time_offset_s,
            output_directory=request.output_directory,
            motion_pairs_payload=motion_pairs_payload,
        )
        _emit_progress(
            progress_callback,
            9,
            "complete",
            status=result.status.value,
            elapsed_s=perf_counter() - overall_started_at,
        )
        return result

    _emit_progress(
        progress_callback,
        1,
        "pipeline_start",
        mode=request.requested_mode.value,
        session_count=len(request.sessions),
        max_iterations=max(1, request.max_iterations),
        output_directory=str(request.output_directory),
    )
    if not request.sessions:
        return finish(
            status=CalibrationStatus.BLOCKED,
            message="no sessions provided",
            details={},
        )

    vehicle_config = None
    if request.vehicle_config is not None:
        _emit_progress(
            progress_callback,
            1,
            "loading_vehicle_config",
            path=str(request.vehicle_config),
        )
        try:
            vehicle_config = load_vehicle_config(request.vehicle_config)
        except Exception as exc:  # noqa: BLE001 - surface config problems as blocked
            _emit_progress(
                progress_callback,
                1,
                "blocked",
                reason="vehicle_config",
                error=str(exc),
            )
            return finish(
                status=CalibrationStatus.BLOCKED,
                message=f"vehicle config failed: {exc}",
                details={},
            )
    _emit_progress(
        progress_callback,
        1,
        "vehicle_config_ready",
        loaded=vehicle_config is not None,
    )

    r_prior, prior_sigma_deg = _rotation_prior_from_config(vehicle_config)

    prepared: list[dict[str, Any]] = []
    session_total = len(request.sessions)
    for session_index, session in enumerate(request.sessions, start=1):
        prep = _prepare_session_pairs(
            session,
            request,
            R_prior=r_prior,
            prior_sigma_deg=prior_sigma_deg,
            progress_callback=progress_callback,
            session_index=session_index,
            session_total=session_total,
        )
        if not prep.get("ok"):
            return finish(
                status=CalibrationStatus.BLOCKED,
                message=f"blocked at stage {prep.get('stage')} ({prep.get('session_id')})",
                details={"sessions": [prep]},
            )
        prepared.append(prep)

    all_pairs = _remap_pairs_for_joint(prepared)
    pair_counts_per_session = {
        p["session_id"]: int(p["pair_count"]) for p in prepared
    }
    _emit_progress(
        progress_callback,
        7,
        "joint_handeye_start",
        session_count=len(prepared),
        merged_pair_count=len(all_pairs),
        pair_counts_per_session=pair_counts_per_session,
    )
    handeye_started_at = perf_counter()
    handeye = solve_rotation_handeye(
        all_pairs,
        R_prior=r_prior,
        prior_sigma_deg=prior_sigma_deg,
    )
    _emit_progress(
        progress_callback,
        7,
        "joint_handeye_complete",
        pair_count=handeye.pair_count,
        rms_deg=float(handeye.residual_rms_deg),
        p95_deg=float(handeye.residual_p95_deg),
        outlier_fraction_gt_5deg=float(handeye.outlier_fraction_gt_5deg),
        ok=handeye.ok,
        elapsed_s=perf_counter() - handeye_started_at,
    )
    if handeye.pair_count < 3:
        return finish(
            status=CalibrationStatus.BLOCKED,
            message="blocked at stage rotation_handeye (joint)",
            details={
                "sessions": [_public_session(p) for p in prepared],
                "joint_handeye": asdict(handeye),
                "merged_pair_count": len(all_pairs),
            },
        )

    force_rotation_only = request.requested_mode == CalibrationMode.ROTATION_ONLY
    t_prior, t_prior_sigma = _translation_prior_from_config(vehicle_config)
    gyro_bias_by_session = {
        p["session_id"]: np.asarray(p["gyro_bias_rad_s"], dtype=float) for p in prepared
    }
    time_offset_by_session = {
        p["session_id"]: float(p["time_offset_s"]) for p in prepared
    }

    preexcluded_session_ids = {
        p["session_id"] for p in prepared if not p["handeye_local"]["ok"]
    }
    if len(preexcluded_session_ids) == len(prepared):
        _emit_progress(
            progress_callback,
            8,
            "phase_a_complete",
            accepted=False,
            reason="all_sessions_failed_local_handeye_gate",
            excluded_sessions=sorted(preexcluded_session_ids),
        )
        return finish(
            status=CalibrationStatus.BLOCKED,
            message=(
                "Phase-A blocked: all sessions failed the local "
                "rotation residual gate"
            ),
            details={
                "sessions": [_public_session(p) for p in prepared],
                "joint_handeye": asdict(handeye),
                "merged_pair_count": len(all_pairs),
                "excluded_sessions": sorted(
                    preexcluded_session_ids
                ),
            },
        )
    _emit_progress(
        progress_callback,
        8,
        "phase_a_start",
        session_count=len(prepared),
        merged_pair_count=len(all_pairs),
        preexcluded_sessions=sorted(preexcluded_session_ids),
    )
    phase_a_started_at = perf_counter()

    def on_phase_a_progress(
        event: str,
        fields: dict[str, Any],
    ) -> None:
        _emit_progress(
            progress_callback,
            8,
            event,
            **fields,
        )

    joint = solve_joint_extrinsic(
        all_pairs,
        handeye.R_IMU_lidar,
        force_rotation_only=force_rotation_only,
        imu=None,
        gyro_bias_rad_s_by_session=gyro_bias_by_session,
        time_offset_s_by_session=time_offset_by_session,
        preexcluded_session_ids=preexcluded_session_ids,
        rotation_prior=r_prior,
        rotation_prior_sigma_deg=(
            15.0 if prior_sigma_deg is None else prior_sigma_deg
        ),
        phase_a_progress_callback=on_phase_a_progress,
        enable_phase_c=not force_rotation_only,
        t_init_m=t_prior,
        t_prior_m=t_prior,
        t_prior_sigma_m=t_prior_sigma,
    )
    included_sessions = [
        item.session_id for item in joint.phase_a_sessions if item.included_in_final
    ]
    excluded_sessions = [
        item.session_id for item in joint.phase_a_sessions if not item.included_in_final
    ]
    _emit_progress(
        progress_callback,
        8,
        "phase_a_complete",
        accepted=joint.phase_a_accepted,
        joint_rms_deg=float(joint.residual_rms_rot_deg),
        rotation_observable=joint.observability.rotation_observable,
        included_sessions=included_sessions,
        excluded_sessions=excluded_sessions,
        elapsed_s=perf_counter() - phase_a_started_at,
    )
    for item in joint.phase_a_sessions:
        _emit_progress(
            progress_callback,
            8,
            "phase_a_session",
            session=item.session_id,
            included=item.included_in_final,
            accepted=item.accepted,
            pair_count=item.pair_count,
            rms_deg=float(item.residual_rms_deg),
            p95_deg=float(item.residual_p95_deg),
            bias_delta_norm_rad_s=float(
                np.linalg.norm(item.gyro_bias_rad_s - item.gyro_bias0_rad_s)
            ),
            gyro_bias_rad_s=np.asarray(item.gyro_bias_rad_s, dtype=float).round(8).tolist(),
        )

    phase_a_by_session = {
        item.session_id: item for item in joint.phase_a_sessions
    }
    session_results = []
    for prep in prepared:
        phase_a = phase_a_by_session.get(prep["session_id"])
        session_bias = joint.gyro_bias_rad_s_per_session.get(prep["session_id"])
        session_results.append(
            {
                **_public_session(prep),
                "vehicle_config_loaded": vehicle_config is not None,
                "handeye": {
                    "residual_rms_deg": handeye.residual_rms_deg,
                    "residual_median_deg": handeye.residual_median_deg,
                    "residual_p95_deg": handeye.residual_p95_deg,
                    "outlier_fraction_gt_5deg": handeye.outlier_fraction_gt_5deg,
                    "pair_count": handeye.pair_count,
                    "ok": handeye.ok,
                    "notes": tuple(list(handeye.notes) + [f"joint over {len(request.sessions)} sessions"]),
                    "R_IMU_lidar": handeye.R_IMU_lidar.tolist(),
                },
                "joint": {
                    "translation_accepted": joint.translation_accepted,
                    "residual_rms_rot_deg": joint.residual_rms_rot_deg,
                    "residual_rms_trans_m": joint.residual_rms_trans_m,
                    "observability": asdict(joint.observability),
                    "notes": joint.notes,
                    "T_IMU_lidar": joint.T_IMU_lidar.tolist(),
                    "phase_a": None if phase_a is None else asdict(phase_a),
                    "gyro_bias_rad_s": None
                    if session_bias is None
                    else np.asarray(session_bias, dtype=float).tolist(),
                    "accel_bias_m_s2": None
                    if joint.accel_bias_m_s2 is None
                    else np.asarray(joint.accel_bias_m_s2, dtype=float).tolist(),
                    "gravity_m_s2": None
                    if joint.gravity_m_s2 is None
                    else np.asarray(joint.gravity_m_s2, dtype=float).tolist(),
                },
                "translation_accepted": joint.translation_accepted,
                "rotation_ok": (
                    phase_a is not None
                    and phase_a.included_in_final
                    and phase_a.accepted
                    and joint.phase_a_accepted
                    and joint.observability.rotation_observable
                ),
                "rotation_prior_constrained": (
                    phase_a is not None
                    and phase_a.included_in_final
                    and phase_a.accepted
                    and joint.phase_a_accepted
                    and not joint.observability.rotation_observable
                    and r_prior is not None
                ),
            }
        )

    T = np.asarray(joint.T_IMU_lidar, dtype=float)
    if request.requested_mode == CalibrationMode.ROTATION_ONLY:
        # A rotation-only result must never expose a seed/prior translation,
        # including when the rotation itself is rejected by a later gate.
        T = T.copy()
        T[:3, 3] = 0.0
    # Multi-session offsets stay in details; the legacy scalar is single-session only.
    delta_t = float(prepared[0]["time_offset_s"]) if len(prepared) == 1 else None
    joint_rotation_ok = joint.phase_a_accepted
    if not joint_rotation_ok:
        status = CalibrationStatus.BLOCKED
        message = (
            f"joint rotation rejected: RMS={joint.residual_rms_rot_deg:.3f} deg "
            "or a retained session failed the Phase-A residual gates"
        )
    elif request.requested_mode == CalibrationMode.FULL_SE3:
        if joint.translation_accepted:
            status = CalibrationStatus.FULL_SE3_ACCEPTED
            message = f"full SE3 accepted (joint {len(prepared)} sessions, {len(all_pairs)} pairs)"
        else:
            status = CalibrationStatus.FULL_SE3_REJECTED
            message = (
                f"rotation accepted jointly ({len(prepared)} sessions); "
                "translation deferred until Phase-B/C session-state redesign"
            )
    elif joint.observability.rotation_observable:
        status = CalibrationStatus.ROTATION_ONLY_ACCEPTED
        message = (
            f"rotation-only calibration accepted "
            f"(joint {len(prepared)} sessions, {len(all_pairs)} pairs)"
        )
        T = T.copy()
        T[:3, 3] = 0.0
    elif r_prior is not None:
        status = CalibrationStatus.ROTATION_ONLY_PRIOR_CONSTRAINED
        message = (
            "rotation residuals passed, but motion does not independently observe all "
            "rotation axes; result remains constrained by the installation prior"
        )
        T = T.copy()
        T[:3, 3] = 0.0
    else:
        status = CalibrationStatus.BLOCKED
        message = "rotation residuals passed but rotation observability failed without a prior"
        T = T.copy()
        T[:3, 3] = 0.0

    return finish(
        status=status,
        message=message,
        details={
            "sessions": session_results,
            "joint": {
                "session_count": len(prepared),
                "merged_pair_count": len(all_pairs),
                "pair_counts_per_session": {p["session_id"]: p["pair_count"] for p in prepared},
                "time_offset_s_per_session": {p["session_id"]: p["time_offset_s"] for p in prepared},
                "handeye_rms_deg": handeye.residual_rms_deg,
                "handeye_p95_deg": handeye.residual_p95_deg,
                "handeye_outlier_fraction_gt_5deg": handeye.outlier_fraction_gt_5deg,
                "phase_a_accepted": joint.phase_a_accepted,
                "phase_a_comparison": joint.phase_a_comparison,
                "phase_a_sessions": [asdict(item) for item in joint.phase_a_sessions],
                "gyro_bias_rad_s_per_session": {
                    sid: np.asarray(value, dtype=float).tolist()
                    for sid, value in joint.gyro_bias_rad_s_per_session.items()
                },
                "excluded_sessions": [
                    item.session_id for item in joint.phase_a_sessions if not item.included_in_final
                ],
                "joint_rotation_rms_deg": joint.residual_rms_rot_deg,
                "rotation_observable": joint.observability.rotation_observable,
                "translation_accepted": joint.translation_accepted,
            },
            "joint_handeye": asdict(handeye),
        },
        T_IMU_lidar=None if status == CalibrationStatus.BLOCKED else T,
        time_offset_s=delta_t,
        motion_pairs_payload=build_motion_pairs_payload(prepared_sessions=prepared),
    )


def _public_session(session_result: dict[str, Any]) -> dict[str, Any]:
    payload = dict(session_result)
    payload.pop("T_IMU_lidar", None)
    payload.pop("pairs", None)
    payload.pop("gyro_bias_rad_s", None)
    payload.pop("gravity_init_m_s2", None)
    return payload
