"""Observable RTK--IMU rotation stages using native asynchronous measurements."""

from __future__ import annotations

import csv
import json
from dataclasses import asdict, dataclass
from pathlib import Path

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

from imu_lidar.contracts import ImuSeries
from imu_lidar.geometry import so3_exp, so3_log
from imu_lidar.imu_preintegration import apply_bias_jacobian_correction, preintegrate_gyro
from .rtk_attitude import gnhpr_to_baseline_enu


@dataclass(frozen=True)
class UnifiedSession:
    session_id: str
    batch_id: str
    imu: ImuSeries
    imu_rpy_deg: np.ndarray
    imu_quaternion_wxyz: np.ndarray
    imu_host_receive_utc_s: np.ndarray
    rtk_by_type: dict[str, list[dict[str, str]]]


@dataclass(frozen=True)
class R1bResult:
    baseline_axis_imu: np.ndarray
    tilt_yz_deg: np.ndarray
    pair_count: int
    residual_rms_deg: float
    residual_p95_deg: float
    covariance_deg2: np.ndarray
    std_deg: np.ndarray
    information_singular_values: np.ndarray
    per_session_rms_deg: dict[str, float]
    gyro_bias_by_session_rad_s: dict[str, np.ndarray]
    ok: bool
    notes: tuple[str, ...]


@dataclass(frozen=True)
class CompletedRotationResult:
    method: str
    R_RTK_IMU: np.ndarray | None
    rpy_deg: np.ndarray | None
    sample_count: int
    session_count: int
    residual_rms_deg: float
    residual_p95_deg: float
    covariance_deg2: np.ndarray
    std_deg: np.ndarray
    per_session_rms_deg: dict[str, float]
    leave_one_session_delta_deg: dict[str, float]
    block_out_delta_deg: dict[str, float]
    convention: str
    ok: bool
    notes: tuple[str, ...]


@dataclass(frozen=True)
class R3Result:
    r1b: R1bResult
    r2v: CompletedRotationResult
    r2g: CompletedRotationResult
    r2v_r2g_delta_deg: float
    full_rotation_accepted: bool
    translation_unlocked: bool
    blockers: tuple[str, ...]


@dataclass(frozen=True)
class _BaselinePair:
    session_index: int
    session_id: str
    world_angle_rad: float
    delta_R: np.ndarray
    J_bg: np.ndarray


def _f(row: dict[str, str], key: str, default: float = np.nan) -> float:
    try:
        return float(row.get(key, ""))
    except (TypeError, ValueError):
        return default


def _truth(row: dict[str, str], key: str) -> bool:
    return str(row.get(key, "")).strip().lower() in {"1", "true", "yes"}


def load_unified_sessions(
    manifest_path: Path | str,
    *,
    selected_session_ids: set[str] | None = None,
) -> list[UnifiedSession]:
    manifest_source = Path(manifest_path)
    manifest = json.loads(manifest_source.read_text(encoding="utf-8"))
    sessions: list[UnifiedSession] = []
    for entry in manifest["sessions"]:
        session_id = str(entry["session_id"])
        if selected_session_ids and session_id not in selected_session_ids:
            continue
        directory = Path(entry["directory"])
        if not directory.is_absolute():
            directory = manifest_source.parent / directory
        with np.load(directory / "imu.npz") as payload:
            t = np.asarray(payload["system_time_s"], dtype=float)
            gyro = np.asarray(payload["gyro_rad_s"], dtype=float)
            accel = np.asarray(payload["accel_m_s2"], dtype=float)
            rpy = np.asarray(payload["rpy_deg"], dtype=float)
            quaternion = np.asarray(payload["quaternion_wxyz"], dtype=float)
            host = np.asarray(payload["host_receive_utc_s"], dtype=float)
        by_type: dict[str, list[dict[str, str]]] = {}
        with (directory / "rtk.csv").open("r", encoding="utf-8", newline="") as stream:
            for row in csv.DictReader(stream):
                by_type.setdefault(row["message_type"], []).append(row)
        sessions.append(
            UnifiedSession(
                session_id=session_id,
                batch_id=str(entry["batch_id"]),
                imu=ImuSeries(t_s=t, gyro_rad_s=gyro, acc_m_s2=accel),
                imu_rpy_deg=rpy,
                imu_quaternion_wxyz=quaternion,
                imu_host_receive_utc_s=host,
                rtk_by_type=by_type,
            )
        )
    return sessions


def _valid_hpr(session: UnifiedSession) -> tuple[np.ndarray, np.ndarray]:
    rows = [
        row for row in session.rtk_by_type.get("GNHPR", [])
        if _truth(row, "checksum_valid") and int(_f(row, "heading_quality", -1)) == 4
    ]
    if not rows:
        return np.zeros(0), np.zeros((0, 3))
    t = np.asarray([_f(row, "t_device_s") for row in rows])
    baseline = gnhpr_to_baseline_enu(
        np.asarray([_f(row, "heading_deg") for row in rows]),
        np.asarray([_f(row, "pitch_deg") for row in rows]),
    )
    finite = np.isfinite(t) & np.all(np.isfinite(baseline), axis=1)
    t, baseline = t[finite], baseline[finite]
    order = np.argsort(t)
    t, baseline = t[order], baseline[order]
    unique, indices = np.unique(t, return_index=True)
    return unique, baseline[indices]


def _baseline_pairs(sessions: list[UnifiedSession]) -> list[_BaselinePair]:
    pairs: list[_BaselinePair] = []
    for session_index, session in enumerate(sessions):
        t, baseline = _valid_hpr(session)
        if t.size < 3:
            continue
        dt = np.diff(t)
        jump = np.degrees(
            np.arccos(np.clip(np.sum(baseline[:-1] * baseline[1:], axis=1), -1.0, 1.0))
        )
        continuous = (dt >= 0.03) & (dt <= 0.25) & (jump / np.maximum(dt, 1e-6) <= 45.0)
        last_anchor = -np.inf
        for index, t0 in enumerate(t[:-1]):
            if t0 - last_anchor < 1.0:
                continue
            last_anchor = t0
            for duration in (0.75, 1.5, 3.0):
                target = t0 + duration
                end = int(np.searchsorted(t, target))
                candidates = [candidate for candidate in (end - 1, end) if index < candidate < t.size]
                if not candidates:
                    continue
                j = min(candidates, key=lambda candidate: abs(t[candidate] - target))
                if abs((t[j] - t0) - duration) > 0.12 or not np.all(continuous[index:j]):
                    continue
                try:
                    pre = preintegrate_gyro(
                        session.imu.t_s, session.imu.gyro_rad_s, float(t0), float(t[j])
                    )
                except ValueError:
                    continue
                world_angle = float(
                    np.arccos(np.clip(np.dot(baseline[index], baseline[j]), -1.0, 1.0))
                )
                if np.degrees(world_angle) < 0.4:
                    continue
                pairs.append(
                    _BaselinePair(
                        session_index=session_index,
                        session_id=session.session_id,
                        world_angle_rad=world_angle,
                        delta_R=pre.delta_R,
                        J_bg=pre.J_bg,
                    )
                )
    return pairs


def _axis_from_parameters(parameters: np.ndarray) -> np.ndarray:
    axis = np.asarray([1.0, parameters[0], parameters[1]], dtype=float)
    return axis / np.linalg.norm(axis)


def solve_r1b(sessions: list[UnifiedSession]) -> R1bResult:
    pairs = _baseline_pairs(sessions)
    if len(pairs) < 20:
        raise ValueError("R1b needs at least 20 continuous baseline/gyro motion pairs")
    session_count = len(sessions)
    pair_counts = {
        session.session_id: sum(pair.session_id == session.session_id for pair in pairs)
        for session in sessions
    }
    active_sessions = sum(count > 0 for count in pair_counts.values())
    target_count = len(pairs) / max(active_sessions, 1)

    def residual(parameters: np.ndarray) -> np.ndarray:
        axis = _axis_from_parameters(parameters[:2])
        biases = parameters[2:].reshape(session_count, 3)
        values = []
        for pair in pairs:
            corrected = apply_bias_jacobian_correction(
                pair.delta_R, pair.J_bg, biases[pair.session_index]
            )
            body_angle = np.arccos(
                np.clip(np.dot(axis, corrected @ axis), -1.0, 1.0)
            )
            weight = np.sqrt(target_count / pair_counts[pair.session_id])
            values.append(weight * (body_angle - pair.world_angle_rad))
        values.extend((biases / 0.01).reshape(-1))
        # Weak 20-degree installation prior selects the physically known +X hemisphere.
        values.extend(np.asarray(parameters[:2]) / np.tan(np.deg2rad(20.0)))
        return np.asarray(values)

    initial = np.zeros(2 + 3 * session_count)
    optimum = least_squares(
        residual, initial, loss="huber", f_scale=np.deg2rad(0.25), max_nfev=120
    )
    axis = _axis_from_parameters(optimum.x[:2])
    biases = optimum.x[2:].reshape(session_count, 3)
    errors = []
    per_session_values: dict[str, list[float]] = {}
    for pair in pairs:
        corrected = apply_bias_jacobian_correction(
            pair.delta_R, pair.J_bg, biases[pair.session_index]
        )
        body_angle = np.arccos(np.clip(np.dot(axis, corrected @ axis), -1.0, 1.0))
        error = float(np.degrees(body_angle - pair.world_angle_rad))
        errors.append(error)
        per_session_values.setdefault(pair.session_id, []).append(error)
    errors_array = np.asarray(errors)
    data_rows = len(pairs)
    jacobian = optimum.jac[:data_rows, :2]
    information = jacobian.T @ jacobian
    residual_variance = float(np.mean(np.deg2rad(errors_array) ** 2))
    covariance = residual_variance * np.linalg.pinv(information, rcond=1e-12)
    covariance_deg2 = np.degrees(1.0) ** 2 * covariance
    std_deg = np.sqrt(np.maximum(np.diag(covariance_deg2), 0.0))
    singular = np.linalg.svd(information, compute_uv=False)
    rms = float(np.sqrt(np.mean(errors_array**2)))
    p95 = float(np.percentile(np.abs(errors_array), 95.0))
    return R1bResult(
        baseline_axis_imu=axis,
        tilt_yz_deg=np.degrees(np.arctan(optimum.x[:2])),
        pair_count=len(pairs),
        residual_rms_deg=rms,
        residual_p95_deg=p95,
        covariance_deg2=covariance_deg2,
        std_deg=std_deg,
        information_singular_values=singular,
        per_session_rms_deg={
            key: float(np.sqrt(np.mean(np.asarray(value) ** 2)))
            for key, value in per_session_values.items()
        },
        gyro_bias_by_session_rad_s={
            session.session_id: biases[index] for index, session in enumerate(sessions)
        },
        ok=bool(rms <= 1.0 and p95 <= 2.0 and np.min(singular) >= 1e-3),
        notes=(
            "2DoF ANT1-to-ANT2 direction; +X hemisphere selected by installation knowledge",
            "weak 20 deg prior is reported and prevents sign/gauge branch switching",
        ),
    )


def _rotation_mean(matrices: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    mean = Rotation.from_matrix(matrices).mean().as_matrix()
    errors = np.asarray(
        [np.degrees(np.linalg.norm(so3_log(mean.T @ matrix))) for matrix in matrices]
    )
    return mean, errors


def _result_from_samples(
    method: str,
    samples: list[tuple[str, str, np.ndarray]],
    convention: str,
    notes: tuple[str, ...],
) -> CompletedRotationResult:
    if not samples:
        return CompletedRotationResult(
            method, None, None, 0, 0, np.nan, np.nan, np.full((3, 3), np.nan),
            np.full(3, np.nan),
            {}, {}, {}, convention, False, notes + ("no qualifying samples",)
        )
    matrices = np.asarray([item[2] for item in samples])
    mean, errors = _rotation_mean(matrices)
    rotvec = np.asarray([so3_log(mean.T @ matrix) for matrix in matrices])
    rotvec_deg = np.degrees(rotvec)
    std = np.std(rotvec_deg, axis=0, ddof=1) if len(samples) > 1 else np.full(3, np.nan)
    covariance = (
        np.cov(rotvec_deg, rowvar=False, ddof=1) / len(samples)
        if len(samples) > 1 else np.full((3, 3), np.nan)
    )
    ids = sorted({item[0] for item in samples})
    per_session = {}
    loo = {}
    for session_id in ids:
        selected = [item[2] for item in samples if item[0] == session_id]
        _, local_errors = _rotation_mean(np.asarray(selected))
        per_session[session_id] = float(np.sqrt(np.mean(local_errors**2)))
        kept = np.asarray([item[2] for item in samples if item[0] != session_id])
        if kept.size:
            kept_mean, _ = _rotation_mean(kept)
            loo[session_id] = float(np.degrees(np.linalg.norm(so3_log(mean.T @ kept_mean))))
    block_groups = sorted({(item[0], item[1]) for item in samples})
    block_out = {}
    for session_id, block_id in block_groups:
        kept = np.asarray(
            [item[2] for item in samples if (item[0], item[1]) != (session_id, block_id)]
        )
        if kept.size:
            kept_mean, _ = _rotation_mean(kept)
            block_out[f"{session_id}:{block_id}"] = float(
                np.degrees(np.linalg.norm(so3_log(mean.T @ kept_mean)))
            )
    rms = float(np.sqrt(np.mean(errors**2)))
    p95 = float(np.percentile(errors, 95.0))
    max_loo = max(loo.values(), default=np.inf)
    ok = bool(
        len(samples) >= 10 and len(ids) >= 2 and rms <= 2.0 and p95 <= 3.0
        and np.nanmax(std) <= 1.0 and max_loo <= 1.0
    )
    return CompletedRotationResult(
        method=method,
        R_RTK_IMU=mean,
        rpy_deg=Rotation.from_matrix(mean).as_euler("xyz", degrees=True),
        sample_count=len(samples),
        session_count=len(ids),
        residual_rms_deg=rms,
        residual_p95_deg=p95,
        covariance_deg2=covariance,
        std_deg=std,
        per_session_rms_deg=per_session,
        leave_one_session_delta_deg=loo,
        block_out_delta_deg=block_out,
        convention=convention,
        ok=ok,
        notes=notes,
    )


def solve_r2g(
    sessions: list[UnifiedSession],
    r1b: R1bResult,
    *,
    level_static_session_ids: set[str],
    block_duration_s: float = 10.0,
) -> CompletedRotationResult:
    samples: list[tuple[str, str, np.ndarray]] = []
    right = r1b.baseline_axis_imu
    for session in sessions:
        if session.session_id not in level_static_session_ids:
            continue
        gyro_norm = np.linalg.norm(session.imu.gyro_rad_s, axis=1)
        accel_norm = np.linalg.norm(session.imu.acc_m_s2, axis=1)
        valid = (gyro_norm <= np.deg2rad(0.35)) & (np.abs(accel_norm - 9.80665) <= 0.15)
        block = np.floor(
            (session.imu.t_s - session.imu.t_s[0]) / block_duration_s
        ).astype(int)
        for block_id in np.unique(block[valid]):
            selected = valid & (block == block_id)
            if np.count_nonzero(selected) < 200:
                continue
            up = np.median(session.imu.acc_m_s2[selected], axis=0)
            up /= np.linalg.norm(up)
            up -= right * np.dot(up, right)
            if np.linalg.norm(up) < 0.9:
                continue
            up /= np.linalg.norm(up)
            forward = np.cross(up, right)
            forward /= np.linalg.norm(forward)
            C_IMU_RTK = np.column_stack([right, forward, up])
            samples.append((session.session_id, str(int(block_id)), C_IMU_RTK.T))
    return _result_from_samples(
        "R2G_baseline_plus_level_gravity",
        samples,
        "accelerometer specific-force points vehicle up on explicit level-static blocks",
        (
            "only caller-declared level-static sessions are eligible",
            "result is level/gravity-prior constrained, not dual-antenna-only",
        ),
    )


def _nearest_index(t: np.ndarray, value: float, tolerance: float) -> int | None:
    index = int(np.searchsorted(t, value))
    candidates = [item for item in (index - 1, index) if 0 <= item < t.size]
    if not candidates:
        return None
    best = min(candidates, key=lambda item: abs(t[item] - value))
    return best if abs(t[best] - value) <= tolerance else None


def solve_r2v(
    sessions: list[UnifiedSession],
    *,
    min_speed_m_s: float = 1.5,
    max_yaw_rate_deg_s: float = 3.0,
    max_baseline_course_error_deg: float = 15.0,
) -> CompletedRotationResult:
    candidates: dict[str, list[tuple[str, str, np.ndarray]]] = {
        "HI13_q_body_to_ENU": [],
        "NED_to_ENU_times_HI13_q": [],
        "HI13_q_inverse_as_body_to_ENU": [],
        "NED_to_ENU_times_HI13_q_inverse": [],
    }
    NED_TO_ENU = np.asarray([[0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]])
    for session in sessions:
        hpr_t, hpr_baseline = _valid_hpr(session)
        if hpr_t.size == 0:
            continue
        quaternion = session.imu_quaternion_wxyz
        norm = np.linalg.norm(quaternion, axis=1)
        valid_quaternion = np.isfinite(norm) & (np.abs(norm - 1.0) <= 0.02)
        normalized = quaternion / np.maximum(norm[:, None], 1e-12)
        imu_rotations = Rotation.from_quat(normalized[:, [1, 2, 3, 0]]).as_matrix()
        for row_index, row in enumerate(session.rtk_by_type.get("BESTNAVA", [])):
            if not (
                _truth(row, "checksum_valid")
                and _truth(row, "position_fixed")
                and _truth(row, "doppler_velocity_valid")
                and _f(row, "horizontal_speed_m_s") >= min_speed_m_s
                and _f(row, "horizontal_speed_std_m_s") <= 0.25
            ):
                continue
            t = _f(row, "t_device_s")
            hpr_index = _nearest_index(hpr_t, t, 0.15)
            imu_index = _nearest_index(session.imu.t_s, t, 0.03)
            if hpr_index is None or imu_index is None or not valid_quaternion[imu_index]:
                continue
            if abs(np.degrees(session.imu.gyro_rad_s[imu_index, 2])) > max_yaw_rate_deg_s:
                continue
            right = hpr_baseline[hpr_index]
            forward = np.asarray(
                [_f(row, "velocity_east_m_s"), _f(row, "velocity_north_m_s"), 0.0]
            )
            forward /= np.linalg.norm(forward)
            course_error = np.degrees(
                np.arcsin(np.clip(abs(np.dot(right, forward)), 0.0, 1.0))
            )
            if course_error > max_baseline_course_error_deg:
                continue
            forward -= right * np.dot(right, forward)
            forward /= np.linalg.norm(forward)
            up = np.cross(right, forward)
            if up[2] < 0:
                forward = -forward
                up = -up
            up /= np.linalg.norm(up)
            R_ENU_RTK = np.column_stack([right, forward, up])
            q = imu_rotations[imu_index]
            world_candidates = {
                "HI13_q_body_to_ENU": q,
                "NED_to_ENU_times_HI13_q": NED_TO_ENU @ q,
                "HI13_q_inverse_as_body_to_ENU": q.T,
                "NED_to_ENU_times_HI13_q_inverse": NED_TO_ENU @ q.T,
            }
            block_id = str(row_index // 10)
            for name, R_ENU_IMU in world_candidates.items():
                candidates[name].append(
                    (session.session_id, block_id, R_ENU_RTK.T @ R_ENU_IMU)
                )
    diagnostics = {
        name: _result_from_samples(
            "R2V_baseline_plus_doppler_velocity",
            values,
            name,
            (
                "RTK fixed + Doppler velocity + speed + low-yaw + baseline/course gates",
                "HI13 absolute quaternion may contain magnetic/navigation yaw bias",
            ),
        )
        for name, values in candidates.items()
    }
    finite = [result for result in diagnostics.values() if result.sample_count]
    if not finite:
        return diagnostics["HI13_q_body_to_ENU"]
    return min(finite, key=lambda result: result.residual_rms_deg)


def solve_r3(
    sessions: list[UnifiedSession],
    *,
    level_static_session_ids: set[str],
) -> R3Result:
    dynamic_sessions = [
        session for session in sessions if session.session_id not in level_static_session_ids
    ]
    r1b = solve_r1b(dynamic_sessions)
    r2v = solve_r2v(dynamic_sessions)
    r2g = solve_r2g(sessions, r1b, level_static_session_ids=level_static_session_ids)
    if r2v.R_RTK_IMU is None or r2g.R_RTK_IMU is None:
        delta = np.nan
    else:
        delta = float(
            np.degrees(np.linalg.norm(so3_log(r2v.R_RTK_IMU.T @ r2g.R_RTK_IMU)))
        )
    blockers = []
    if not r1b.ok:
        blockers.append("R1b baseline direction failed residual/observability gates")
    if not r2v.ok:
        blockers.append("R2V velocity-completed rotation failed stability gates")
    if not r2g.ok:
        blockers.append("R2G gravity-completed rotation failed stability gates")
    if not np.isfinite(delta) or delta > 2.0:
        blockers.append("R2V and R2G disagree by more than 2 deg")
    accepted = not blockers
    return R3Result(
        r1b=r1b,
        r2v=r2v,
        r2g=r2g,
        r2v_r2g_delta_deg=delta,
        full_rotation_accepted=accepted,
        translation_unlocked=accepted,
        blockers=tuple(blockers),
    )


def result_to_jsonable(result: R3Result) -> dict:
    def convert(value):
        if isinstance(value, np.ndarray):
            return value.tolist()
        if isinstance(value, np.generic):
            return value.item()
        if hasattr(value, "__dataclass_fields__"):
            return {key: convert(item) for key, item in asdict(value).items()}
        if isinstance(value, dict):
            return {str(key): convert(item) for key, item in value.items()}
        if isinstance(value, (tuple, list)):
            return [convert(item) for item in value]
        return value
    return convert(result)
