#!/usr/bin/env python3
"""Run one prior-free engineering base fit on high-excitation qualified segments.

This diagnostic never adds a mechanical lever factor, never profiles the
mechanical reference, and never runs LOO/bootstrap/rotation sensitivity.
"""
from __future__ import annotations

import argparse
import json
import math
import sys
from dataclasses import asdict
from pathlib import Path

import numpy as np
from scipy.spatial.transform import Rotation

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

from rtk_imu.rtk_imu_engineering import (
    MIN_SEGMENT_DURATION_S,
    MIN_SEGMENT_NODE_COUNT,
    NUISANCE_DOF_PER_SEGMENT,
    _Segment,
    _fit_segments,
    _fit_summary,
    _height_reference,
    _initial_parameters,
    _marginal_lever_information,
    _nodes,
    _residual,
    _segment_residual_size,
    _world_rtk,
)
from imu_lidar.imu_preintegration import preintegrate_imu
from rtk_imu.rtk_imu_multisource import load_unified_sessions

MANUAL_REFERENCE_L_I_M = np.array([-0.45072, -0.25682, 0.73208], dtype=float)


def _jsonable(value):
    if isinstance(value, np.ndarray):
        return _jsonable(value.tolist())
    if isinstance(value, np.generic):
        return _jsonable(value.item())
    if isinstance(value, float):
        return value if math.isfinite(value) else None
    if hasattr(value, "__dataclass_fields__"):
        return {key: _jsonable(item) for key, item in asdict(value).items()}
    if isinstance(value, dict):
        return {str(key): _jsonable(item) for key, item in value.items()}
    if isinstance(value, (tuple, list)):
        return [_jsonable(item) for item in value]
    return value


def _r0_segment_candidates(sessions, period_s: float) -> list[tuple[object, tuple, str]]:
    """Split R0 first; defer expensive preintegration until a candidate is selected."""
    reference = _height_reference(sessions)
    if reference is None:
        return []
    candidates: list[tuple[object, tuple, str]] = []
    for session in sessions:
        nodes = _nodes(session, reference, period_s)
        start = 0
        qualifying_index = 0
        for end in range(1, len(nodes) + 1):
            if end != len(nodes) and nodes[end].continuity_id == nodes[end - 1].continuity_id:
                continue
            run = tuple(nodes[start:end])
            start = end
            if len(run) < MIN_SEGMENT_NODE_COUNT or run[-1].t_s - run[0].t_s < MIN_SEGMENT_DURATION_S:
                continue
            if not any(node.hpr_factor_valid for node in run):
                continue
            candidates.append((session, run, f"{session.session_id}:{qualifying_index:02d}"))
            qualifying_index += 1
    return candidates


def _build_segment(session, run: tuple, segment_id: str) -> _Segment | None:
    pre = tuple(preintegrate_imu(
        session.imu.t_s, session.imu.gyro_rad_s, session.imu.acc_m_s2, left.t_s, right.t_s,
    ) for left, right in zip(run[:-1], run[1:]))
    if any(item.duration_s <= 0.0 for item in pre):
        return None
    initial_hpr = next((node for node in run if node.hpr_factor_valid), None)
    if initial_hpr is None:
        return None
    return _Segment(segment_id, session.session_id, run, pre, _world_rtk(initial_hpr.baseline_enu))

def _gyro_abs_rotation_deg(session, segment) -> np.ndarray:
    start_s, end_s = segment.nodes[0].t_s, segment.nodes[-1].t_s
    mask = (session.imu.t_s >= start_s) & (session.imu.t_s <= end_s)
    t_s = session.imu.t_s[mask]
    gyro = session.imu.gyro_rad_s[mask]
    if t_s.size < 2:
        return np.zeros(3)
    return np.degrees(np.trapezoid(np.abs(gyro), t_s, axis=0))


def _category_score(category: str, gyro_abs_deg: np.ndarray) -> float:
    if category in {"circle", "left_right"}:
        return float(gyro_abs_deg[2])
    return float(np.hypot(gyro_abs_deg[0], gyro_abs_deg[1]))


def _marginal_for_segment_indices(jacobian: np.ndarray, residual: np.ndarray,
                                  segments, indices: list[int]) -> tuple[np.ndarray, np.ndarray, float, int, np.ndarray]:
    row = 0
    rows: list[np.ndarray] = []
    for index, segment in enumerate(segments):
        count = _segment_residual_size(segment)
        if index in indices:
            rows.append(np.arange(row, row + count))
        row += count
    selected_rows = np.concatenate(rows) if rows else np.empty(0, dtype=int)
    columns = [0, 1, 2]
    for index in indices:
        offset = 3 + NUISANCE_DOF_PER_SEGMENT * index
        columns.extend(range(offset, offset + NUISANCE_DOF_PER_SEGMENT))
    marginal, singular, condition, rank, weakest, _ = _marginal_lever_information(
        jacobian[np.ix_(selected_rows, columns)], residual[selected_rows]
    )
    return marginal, singular, condition, rank, weakest


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--manifest", type=Path, required=True)
    parser.add_argument("--output", type=Path, required=True)
    parser.add_argument("--circle-session", required=True)
    parser.add_argument("--left-right-session", required=True)
    parser.add_argument("--slope-session", required=True)
    parser.add_argument("--top-per-category", type=int, default=10)
    parser.add_argument("--sample-period-s", type=float, default=1.0)
    parser.add_argument("--rotation-rpy-deg", nargs=3, type=float,
                        default=[0.4543066225, -0.0026392019, 0.0122384129])
    args = parser.parse_args()
    category_by_session = {
        args.circle_session: "circle",
        args.left_right_session: "left_right",
        args.slope_session: "slope",
    }
    sessions = load_unified_sessions(args.manifest, selected_session_ids=set(category_by_session))
    candidates: dict[str, list[tuple[float, object, tuple, str, np.ndarray]]] = {
        key: [] for key in category_by_session.values()
    }
    all_candidates = _r0_segment_candidates(sessions, args.sample_period_s)
    for session, run, segment_id in all_candidates:
        category = category_by_session[session.session_id]
        gyro_abs = _gyro_abs_rotation_deg(session, type("Run", (), {"nodes": run})())
        candidates[category].append((_category_score(category, gyro_abs), session, run, segment_id, gyro_abs))
    selected: list[object] = []
    selected_entries: list[dict[str, object]] = []
    category_indices: dict[str, list[int]] = {}
    for category, entries in candidates.items():
        selected_before = len(selected)
        for score, session, run, segment_id, gyro_abs in sorted(entries, key=lambda item: item[0], reverse=True):
            segment = _build_segment(session, run, segment_id)
            if segment is None:
                continue
            selected.append(segment)
            selected_entries.append({
                "category": category, "segment_id": segment.segment_id,
                "session_id": segment.session_id, "duration_s": segment.nodes[-1].t_s - segment.nodes[0].t_s,
                "node_count": len(segment.nodes), "excitation_score_deg": score,
                "cumulative_absolute_gyro_rotation_xyz_deg": gyro_abs,
            })
            if len(selected) - selected_before >= args.top_per_category:
                break
        category_indices[category] = list(range(selected_before, len(selected)))
    rotation = Rotation.from_euler("xyz", args.rotation_rpy_deg, degrees=True).as_matrix()
    initial_parameters = _initial_parameters(selected)
    initial_residual = _residual(initial_parameters, selected, rotation)
    fit, residual, detail = _fit_segments(selected, rotation)
    summary = _fit_summary(fit, residual, detail)
    if fit is None or summary is None:
        raise RuntimeError("no selected qualified segment could be fit")
    contributions: dict[str, dict[str, object]] = {}
    contribution_sum = np.zeros((3, 3))
    for category, indices in category_indices.items():
        marginal, singular, condition, rank, weakest = _marginal_for_segment_indices(
            fit.jac, residual, selected, indices
        )
        contribution_sum += marginal
        contributions[category] = {
            "selected_segment_count": len(indices),
            "lever_marginal_information": marginal,
            "lever_information_singular_values": singular,
            "condition_number": condition,
            "precision_rank": rank,
            "weakest_direction_I": weakest,
            "axis_information_diagonal_I": np.diag(marginal),
        }
    total_diag = np.diag(summary.lever_marginal_information)
    for category, item in contributions.items():
        item["axis_information_fraction_of_total_I"] = np.divide(
            item["axis_information_diagonal_I"], total_diag,
            out=np.full(3, np.nan), where=np.abs(total_diag) > 1e-12,
        )
    payload = {
        'solver_diagnostics': {
            'initial_cost': 0.5 * float(np.dot(initial_residual, initial_residual)),
            'final_cost': 0.5 * float(np.dot(residual, residual)),
            'cost_reduction': 0.5 * float(
                np.dot(initial_residual, initial_residual) - np.dot(residual, residual)
            ),
            'cost_definition': '0.5 * unmodified residual squared norm, comparable initial/final',
            'scipy_final_huber_cost': float(fit.cost),
            'nfev': int(fit.nfev), 'optimality': float(fit.optimality),
            'gradient_norm': float(np.linalg.norm(fit.grad)),
            'initial_l_I_m': initial_parameters[:3], 'final_l_I_m': fit.x[:3],
            'l_step_norm_m': float(np.linalg.norm(fit.x[:3] - initial_parameters[:3])),
        },
        "scope": "prior-free free base fit only; no mechanical factor/LOO/bootstrap/rotation sensitivity",
        "rotation_source": "R2G_gravity_level_prior",
        "translation_conditional_on_rotation": True,
        "manual_reference_comparison_only": {
            "manual_l_I_m": MANUAL_REFERENCE_L_I_M,
            "free_minus_manual_l_I_m": summary.l_I_m - MANUAL_REFERENCE_L_I_M,
            "euclidean_delta_m": float(np.linalg.norm(summary.l_I_m - MANUAL_REFERENCE_L_I_M)),
        },
        "sample_period_s": args.sample_period_s,
        "all_qualified_segment_count": len(all_candidates),
        "selected_segment_count": len(selected),
        "selected_segments": selected_entries,
        "free_solution": _jsonable(summary),
        "motion_category_information_contributions": _jsonable(contributions),
        "axis_information_total_I": np.diag(summary.lever_marginal_information),
        "marginal_additivity_error_fro": float(np.linalg.norm(
            contribution_sum - summary.lever_marginal_information, ord="fro"
        )),
    }
    args.output.parent.mkdir(parents=True, exist_ok=True)
    args.output.write_text(json.dumps(_jsonable(payload), ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
    print(json.dumps({
        "selected_segment_count": len(selected),
        "free_l_I_m": _jsonable(summary.l_I_m),
        "free_l_I_std_m": _jsonable(summary.l_I_std_m),
        "lever_information_singular_values": _jsonable(summary.lever_information_singular_values),
        "condition_number": summary.lever_information_condition_number,
        "precision_rank": summary.lever_precision_rank,
        "bestnava_xyz_vector_rms_p95_m": [summary.bestnava_xyz_residual.vector_rms, summary.bestnava_xyz_residual.vector_p95],
        "doppler_vector_rms_p95_m_s": [summary.doppler_velocity_residual.vector_rms, summary.doppler_velocity_residual.vector_p95],
    }, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
