"""Cached Phase-A replay: rehydrate gyro factors, compare variants, write reports."""

from __future__ import annotations

import json
from collections import defaultdict
from pathlib import Path
from typing import Any

import numpy as np

from .imu_io import load_imu_samples
from .motion_pairs_io import (
    build_motion_pairs_payload,
    load_motion_pairs,
    pair_from_dict,
    save_motion_pairs,
)
from .phase_a import (
    ProgressCallback,
    phase_a_comparison_to_dict,
    phase_a_metadata_complete,
    rehydrate_phase_a_pairs,
    solve_phase_a_comparison,
)
from .vehicle_config import load_vehicle_config, prior_enabled


def _rotation_prior(
    vehicle_config_path: Path,
) -> tuple[np.ndarray | None, float]:
    config = load_vehicle_config(vehicle_config_path)
    if not prior_enabled(config, "rotation_prior"):
        return None, 15.0
    prior = (config.get("initialization") or {}).get("rotation_prior") or {}
    matrix = prior.get("R_IMU_lidar")
    if matrix is None:
        return None, float(prior.get("sigma_deg", 15.0))
    return (
        np.asarray(matrix, dtype=float).reshape(3, 3),
        float(prior.get("sigma_deg", 15.0)),
    )


def _sanitize_json(value: Any) -> Any:
    if isinstance(value, dict):
        return {str(key): _sanitize_json(item) for key, item in value.items()}
    if isinstance(value, (list, tuple)):
        return [_sanitize_json(item) for item in value]
    if isinstance(value, np.ndarray):
        return _sanitize_json(value.tolist())
    if isinstance(value, (np.floating, float)):
        number = float(value)
        return number if np.isfinite(number) else None
    if isinstance(value, (np.integer, np.bool_)):
        return value.item()
    return value


def _write_json(path: Path, payload: Any) -> None:
    path.write_text(
        json.dumps(_sanitize_json(payload), indent=2, ensure_ascii=False) + "\n",
        encoding="utf-8",
    )


def _load_cached_sessions(
    motion_pairs_path: Path,
) -> tuple[
    dict[str, Any],
    list,
    dict[str, np.ndarray],
    dict[str, float],
]:
    payload = load_motion_pairs(motion_pairs_path)
    pairs = []
    biases: dict[str, np.ndarray] = {}
    offsets: dict[str, float] = {}
    for session in payload.get("sessions") or []:
        session_id = str(session["session_id"])
        biases[session_id] = np.asarray(
            session.get("gyro_bias_rad_s", np.zeros(3)),
            dtype=float,
        ).reshape(3)
        offsets[session_id] = float(session.get("delta_t_s", 0.0))
        pairs.extend(
            pair_from_dict(item)
            for item in session.get("pairs") or []
        )
    if not pairs:
        raise ValueError(f"motion-pair cache is empty: {motion_pairs_path}")
    return payload, pairs, biases, offsets


def run_phase_a_replay(
    *,
    motion_pairs_path: Path,
    vehicle_config_path: Path,
    output_directory: Path,
    imu_paths_by_session: dict[str, Path] | None = None,
    excluded_sessions: set[str] | None = None,
    strong_rotation_min_deg: float = 1.0,
    decorrelation_block_s: float = 3.0,
    max_pairs_per_block: int = 1,
    bias_prior_sigma_rad_s: float = 0.002,
    yaw_std_max_deg: float = 0.5,
    leave_one_out_yaw_range_max_deg: float = 1.0,
    data_prior_difference_max_deg: float = 1.0,
    max_nfev: int = 200,
    progress_callback: ProgressCallback | None = None,
) -> dict[str, Any]:
    """Run Phase-A only. Existing LiDAR relative motions are never recomputed."""

    output_directory.mkdir(parents=True, exist_ok=True)
    source_payload, pairs, bias0, offsets = _load_cached_sessions(
        motion_pairs_path
    )
    session_ids = sorted(bias0)
    if progress_callback is not None:
        progress_callback(
            "cache_loaded",
            {
                "schema_version": source_payload.get("schema_version"),
                "sessions": len(session_ids),
                "pairs": len(pairs),
            },
        )

    rehydration_report: dict[str, Any] = {
        "required": not phase_a_metadata_complete(pairs),
        "pair_count": len(pairs),
    }
    if not phase_a_metadata_complete(pairs):
        supplied_paths = {} if imu_paths_by_session is None else imu_paths_by_session
        missing = [sid for sid in session_ids if sid not in supplied_paths]
        if missing:
            raise ValueError(
                "v1 cache lacks J_bg/cov; provide --session-imu for: "
                + ", ".join(missing)
            )
        imu_by_session = {
            sid: load_imu_samples(supplied_paths[sid])
            for sid in session_ids
        }
        pairs, details = rehydrate_phase_a_pairs(
            pairs,
            imu_by_session=imu_by_session,
            bias0_by_session=bias0,
            progress_callback=progress_callback,
        )
        rehydration_report.update(details)
        if float(details["max_R_A_error_deg"]) > 0.05:
            raise ValueError(
                "rehydrated IMU rotations do not match cached R_A: "
                f"max error={details['max_R_A_error_deg']:.6f} deg; "
                "check session-to-IMU path mapping"
            )

    grouped: dict[str, list] = defaultdict(list)
    for pair in pairs:
        grouped[pair.session_id].append(pair)
    enriched_payload = build_motion_pairs_payload(
        prepared_sessions=[
            {
                "session_id": sid,
                "time_offset_s": offsets[sid],
                "gyro_bias_rad_s": bias0[sid],
                "pairs": tuple(grouped[sid]),
            }
            for sid in session_ids
        ]
    )
    enriched_cache_path = save_motion_pairs(
        output_directory / "motion_pairs_phase_a_v2.json",
        enriched_payload,
    )

    rotation_prior, rotation_prior_sigma_deg = _rotation_prior(
        vehicle_config_path
    )
    comparison = solve_phase_a_comparison(
        pairs,
        gyro_bias_rad_s_by_session=bias0,
        rotation_prior=rotation_prior,
        rotation_prior_sigma_deg=rotation_prior_sigma_deg,
        preexcluded_session_ids=excluded_sessions,
        strong_rotation_min_deg=strong_rotation_min_deg,
        decorrelation_block_s=decorrelation_block_s,
        max_pairs_per_block=max_pairs_per_block,
        bias_prior_sigma_rad_s=bias_prior_sigma_rad_s,
        yaw_std_max_deg=yaw_std_max_deg,
        leave_one_out_yaw_range_max_deg=(
            leave_one_out_yaw_range_max_deg
        ),
        data_prior_difference_max_deg=data_prior_difference_max_deg,
        run_leave_one_out=True,
        max_nfev=max_nfev,
        progress_callback=progress_callback,
    )
    full = phase_a_comparison_to_dict(comparison)
    full["input"] = {
        "motion_pairs": str(motion_pairs_path),
        "source_schema_version": source_payload.get("schema_version"),
        "vehicle_config": str(vehicle_config_path),
        "session_imu_paths": {
            sid: str(path)
            for sid, path in (imu_paths_by_session or {}).items()
        },
        "excluded_sessions": sorted(excluded_sessions or set()),
    }
    full["rehydration"] = rehydration_report
    full["enriched_cache"] = str(enriched_cache_path)
    full["parameters"] = {
        "strong_rotation_min_deg": strong_rotation_min_deg,
        "decorrelation_block_s": decorrelation_block_s,
        "max_pairs_per_block": max_pairs_per_block,
        "bias_prior_sigma_rad_s": bias_prior_sigma_rad_s,
        "rotation_prior_sigma_deg": rotation_prior_sigma_deg,
        "yaw_std_max_deg": yaw_std_max_deg,
        "leave_one_out_yaw_range_max_deg": (
            leave_one_out_yaw_range_max_deg
        ),
        "data_prior_difference_max_deg": (
            data_prior_difference_max_deg
        ),
        "max_nfev": max_nfev,
    }

    variants = full["variants"]
    summary = {
        "status": comparison.solution_status,
        "accepted": comparison.accepted,
        "partial_accepted": comparison.partial_accepted,
        "acceptance_checks": comparison.acceptance_checks,
        "primary_result": comparison.recommended_result,
        "variants": {
            name: {
                "rpy_deg_xyz": item["rpy_deg_xyz"],
                "R_IMU_lidar": item["R_IMU_lidar"],
                "residual_rms_deg": item["residual_rms_deg"],
                "residual_p95_deg": item["residual_p95_deg"],
                "accepted": item["accepted"],
                "gyro_bias_rad_s_per_session": item[
                    "gyro_bias_rad_s_per_session"
                ],
            }
            for name, item in variants.items()
            if item is not None
        },
        "marginal_observability_A1": full[
            "marginal_observability_A1"
        ],
        "data_vs_prior_yaw_diff_deg": (
            comparison.data_vs_prior_yaw_diff_deg
        ),
        "data_vs_prior_geodesic_deg": (
            comparison.data_vs_prior_geodesic_deg
        ),
        "leave_one_out_yaw_range_deg": (
            comparison.leave_one_out_yaw_range_deg
        ),
        "leave_one_out_observable_max_deg": (
            comparison.leave_one_out_observable_max_deg
        ),
        "strong_pair_candidate_count": (
            comparison.strong_pair_candidate_count
        ),
        "decorrelated_pair_count": comparison.decorrelated_pair_count,
        "strong_pair_counts_per_session": (
            comparison.strong_pair_counts_per_session
        ),
        "excluded_sessions": list(comparison.excluded_sessions),
        "rehydration": rehydration_report,
        "comparison_file": "phase_a_comparison.json",
        "observability_file": "phase_a_observability.json",
        "leave_one_out_file": "phase_a_leave_one_out.json",
        "enriched_cache_file": enriched_cache_path.name,
    }

    _write_json(output_directory / "phase_a_comparison.json", full)
    _write_json(
        output_directory / "phase_a_observability.json",
        full["marginal_observability_A1"],
    )
    _write_json(
        output_directory / "phase_a_leave_one_out.json",
        full["leave_one_out"],
    )
    _write_json(output_directory / "phase_a_summary.json", summary)
    return summary
