#!/usr/bin/env python3
'''Audit RTK/IMU factor conventions without running an optimizer.'''
from __future__ import annotations
import argparse, json, math, sys
from dataclasses import asdict, is_dataclass
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 imu_lidar.imu_preintegration import preintegrate_imu
from rtk_imu.rtk_imu_engineering import (
    G_ENU, MIN_SEGMENT_DURATION_S, MIN_SEGMENT_NODE_COUNT,
    NUISANCE_DOF_PER_SEGMENT, _Segment, _all_hpr, _audit,
    _dense_colored_jacobian, _enu, _height_reference,
    _hpr_factor_observation, _initial_parameters, _marginal_lever_information,
    _motion_flags, _nodes, _position_valid, _residual,
    _segment_residual_size, _world_rtk)
from rtk_imu.rtk_imu_multisource import _f, _truth, load_unified_sessions
MECHANICAL_L_I_M = np.array([-0.45072, -0.25682, 0.73208])

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 is_dataclass(value): return _jsonable(asdict(value))
    if isinstance(value, dict): return {str(k): _jsonable(v) for k, v in value.items()}
    if isinstance(value, (list, tuple)): return [_jsonable(v) for v in value]
    return value

def _summary(error):
    a = np.asarray(error, dtype=float)
    return _jsonable(_audit(list(a.reshape(-1, 3)), 3)) if a.size else _jsonable(_audit([], 3))

def _corr(a, b):
    out = np.full(3, np.nan)
    for axis in range(3):
        if len(a) >= 3 and np.std(a[:, axis]) > 1e-10 and np.std(b[:, axis]) > 1e-10:
            out[axis] = np.corrcoef(a[:, axis], b[:, axis])[0, 1]
    return out

def _best_arrays(session, reference):
    rows = []
    for row in session.rtk_by_type.get('BESTNAVA', []):
        v = np.array([_f(row, 'velocity_east_m_s'), _f(row, 'velocity_north_m_s'),
                      _f(row, 'vertical_speed_m_s')])
        if _position_valid(row, 'BESTNAVA') and _truth(row, 'doppler_velocity_valid') and np.all(np.isfinite(v)):
            rows.append(row)
    rows.sort(key=lambda row: _f(row, 't_device_s'))
    rows = [row for i, row in enumerate(rows) if i == 0 or _f(row, 't_device_s') > _f(rows[i-1], 't_device_s')]
    t = np.asarray([_f(row, 't_device_s') for row in rows])
    p = np.asarray([_enu(row, 'BESTNAVA', reference)[0] for row in rows]).reshape(-1, 3)
    v = np.asarray([[_f(row, 'velocity_east_m_s'), _f(row, 'velocity_north_m_s'),
                     _f(row, 'vertical_speed_m_s')] for row in rows]).reshape(-1, 3)
    return rows, t, p, v

def _gnss_audit(sessions, reference, max_dt):
    all_dpdt, all_v, per_session = [], [], {}
    for session in sessions:
        _, t, p, v = _best_arrays(session, reference)
        dt = np.diff(t); keep = (dt > 0) & (dt <= max_dt)
        dpdt = np.diff(p, axis=0)[keep] / dt[keep, None]
        v_avg = .5 * (v[:-1] + v[1:])[keep]
        all_dpdt.append(dpdt); all_v.append(v_avg)
        per_session[session.session_id] = {
            'interval_count': len(dpdt), 'nominal_correlation_xyz': _corr(dpdt, v_avg),
            'nominal_error_m_s': _summary(dpdt-v_avg)}
    dpdt, velocity = np.vstack(all_dpdt), np.vstack(all_v)
    hypotheses = []
    for swap in (False, True):
        base = velocity[:, [1,0,2]] if swap else velocity
        for sx in (-1.,1.):
            for sy in (-1.,1.):
                for sz in (-1.,1.):
                    transformed = base * [sx,sy,sz]
                    hypotheses.append({
                        'mapping': ('[N,E,Z]' if swap else '[E,N,Z]')+f'*[{sx:+.0f},{sy:+.0f},{sz:+.0f}]',
                        'correlation_xyz': _corr(dpdt, transformed),
                        'error_m_s': _summary(dpdt-transformed)})
    hypotheses.sort(key=lambda item: item['error_m_s']['vector_rms'])
    nominal = next(x for x in hypotheses if x['mapping'] == '[E,N,Z]*[+1,+1,+1]')
    return {'interval_count': len(dpdt), 'nominal': nominal, 'best_mapping': hypotheses[0],
            'all_hypotheses': hypotheses, 'per_session': per_session}

def _nearest_imu(session, t, tolerance=.03):
    right = int(np.searchsorted(session.imu.t_s, t))
    candidates = [i for i in (right-1,right) if 0 <= i < len(session.imu.t_s)]
    if not candidates: return None
    index = min(candidates, key=lambda i: abs(session.imu.t_s[i]-t))
    return index if abs(session.imu.t_s[index]-t) <= tolerance else None

def _static_audit(sessions, rotation):
    current, opposite, per_session = [], [], {}
    for session in sessions:
        hpr, local = _all_hpr(session), []
        last_t = -np.inf
        for hpr_index in hpr.valid_indices:
            t = float(hpr.t_s[hpr_index])
            if t-last_t < 1.: continue
            last_t = t
            gravity, _ = _motion_flags(session,t,np.zeros(0),np.zeros((0,3)))
            baseline, _, valid, _, _ = _hpr_factor_observation(hpr,t)
            imu_index = _nearest_imu(session,t)
            if not (gravity and valid and imu_index is not None):
                continue
            R_WI = _world_rtk(baseline) @ rotation
            accel = session.imu.acc_m_s2[imu_index]
            value = R_WI @ accel + G_ENU
            local.append(value); current.append(value); opposite.append(R_WI @ accel-G_ENU)
        per_session[session.session_id] = {'count':len(local),'current_formula_m_s2':_summary(local)}
    return {'formula':'a_W_linear = R_WI @ specific_force_I + G_ENU',
            'current_formula_m_s2':_summary(current),
            'opposite_gravity_sign_m_s2':_summary(opposite),'per_session':per_session}

def _closure_audit(sessions, reference, rotation, lever, max_dt):
    all_p, all_v, per_session = [], [], {}
    for session in sessions:
        _, t, p_ant, v_ant = _best_arrays(session, reference)
        hpr, local_p, local_v = _all_hpr(session), [], []
        for index, dt in enumerate(np.diff(t)):
            if not .5 <= dt <= max_dt: continue
            baseline, _, valid, _, _ = _hpr_factor_observation(hpr, t[index])
            i0, i1 = _nearest_imu(session, t[index]), _nearest_imu(session, t[index+1])
            if not valid or i0 is None or i1 is None: continue
            pre = preintegrate_imu(session.imu.t_s, session.imu.gyro_rad_s,
                                   session.imu.acc_m_s2, t[index], t[index+1])
            if pre.duration_s <= 0 or abs(pre.duration_s-dt) > 1e-6: continue
            R0 = _world_rtk(baseline) @ rotation
            p_i0 = p_ant[index] - R0 @ lever
            v_i0 = v_ant[index] - R0 @ np.cross(session.imu.gyro_rad_s[i0], lever)
            p_i1 = p_i0 + v_i0*dt + .5*G_ENU*dt**2 + R0@pre.delta_p
            v_i1 = v_i0 + G_ENU*dt + R0@pre.delta_v
            R1 = R0 @ pre.delta_R
            local_p.append(p_i1 + R1@lever - p_ant[index+1])
            local_v.append(v_i1 + R1@np.cross(session.imu.gyro_rad_s[i1],lever) - v_ant[index+1])
        all_p.extend(local_p); all_v.extend(local_v)
        per_session[session.session_id] = {
            'interval_count':len(local_p),'position_m':_summary(local_p),
            'velocity_m_s':_summary(local_v)}
    return {'method':'single-step forward closure; fixed R2G/mechanical lever/BEST p-v; no least_squares',
            'position_m':_summary(all_p),'velocity_m_s':_summary(all_v),
            'per_session':per_session}

def _qualified_runs(sessions, reference, period):
    result = []
    for session in sessions:
        nodes, start, number = _nodes(session, reference, period), 0, 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, start = tuple(nodes[start:end]), end
            if (len(run) >= MIN_SEGMENT_NODE_COUNT
                    and run[-1].t_s-run[0].t_s >= MIN_SEGMENT_DURATION_S
                    and any(node.hpr_factor_valid for node in run)):
                result.append((session,run,f'{session.session_id}:{number:02d}'))
                number += 1
    return result

def _first_node_audit(runs, rotation, lever):
    records, ranges = [], {}
    for session, run, segment_id in runs:
        node = run[0]
        hpr_node = next(item for item in run if item.hpr_factor_valid)
        R_WI = _world_rtk(hpr_node.baseline_enu) @ rotation
        velocity = node.velocity_enu_m_s
        v = np.full(3,np.nan) if velocity is None else velocity
        records.append({
            'segment_id':segment_id,'source':node.source,'t_s':node.t_s,
            'first_position_global_enu_m':node.p_enu_m,'first_velocity_enu_m_s':v,
            'free_x0_l0_position_residual_m':np.zeros(3),
            'free_x0_l0_velocity_residual_m_s':-v,
            'mechanical_l_unshifted_x0_position_residual_m':R_WI@lever,
            'mechanical_l_unshifted_x0_velocity_residual_m_s':R_WI@np.cross(node.gyro_rad_s,lever)-v,
            'observation_seeded_mechanical_position_residual_m':np.zeros(3),
            'observation_seeded_mechanical_velocity_residual_m_s':
                np.zeros(3) if velocity is not None else np.full(3,np.nan)})
        ranges.setdefault(session.session_id,[]).append(node.p_enu_m)
    ranges = {key:{'count':len(value),'min_global_enu_m':np.min(value,axis=0),
                   'max_global_enu_m':np.max(value,axis=0)} for key,value in ranges.items()}
    return {'common_global_enu_reference':True,'segment_state_is_global_imu_position':True,
            'per_session_first_node_ranges':ranges,'segments':records}

def _gyro_score(session, run, category):
    keep = (session.imu.t_s >= run[0].t_s) & (session.imu.t_s <= run[-1].t_s)
    t, gyro = session.imu.t_s[keep], session.imu.gyro_rad_s[keep]
    if len(t) < 2: return 0.
    value = np.trapezoid(np.abs(gyro),t,axis=0)
    return float(value[2] if category != 'slope' else np.hypot(value[0],value[1]))

def _build_segment(session, run, segment_id):
    pre = tuple(preintegrate_imu(session.imu.t_s,session.imu.gyro_rad_s,
                                 session.imu.acc_m_s2,a.t_s,b.t_s)
                for a,b in zip(run[:-1],run[1:]))
    hpr = next(node for node in run if node.hpr_factor_valid)
    return _Segment(segment_id,session.session_id,run,pre,_world_rtk(hpr.baseline_enu))

def _subset_marginal(jacobian,residual,segments,indices):
    rows, row0 = [], 0
    for index,segment in enumerate(segments):
        count = _segment_residual_size(segment)
        if index in indices: rows.extend(range(row0,row0+count))
        row0 += count
    columns = [0,1,2]
    for index in indices:
        start = 3 + NUISANCE_DOF_PER_SEGMENT*index
        columns.extend(range(start,start+NUISANCE_DOF_PER_SEGMENT))
    rows, columns = np.asarray(rows), np.asarray(columns)
    return _marginal_lever_information(
        jacobian[np.ix_(rows,columns)],residual[rows])[0]

def _jacobian_schur_audit(runs,categories,rotation,lever):
    segments, indices = [], {}
    for category in ('circle','left_right','slope'):
        choices = [item for item in runs if categories[item[0].session_id] == category]
        if not choices:
            indices[category] = []; continue
        best = max(choices,key=lambda item:_gyro_score(item[0],item[1],category))
        indices[category] = [len(segments)]
        segments.append(_build_segment(*best))
    x = _initial_parameters(segments); x[:3] = lever
    residual = _residual(x,segments,rotation)
    jacobian = _dense_colored_jacobian(x,segments,rotation)
    comparisons = []
    for axis in range(3):
        plus, minus = x.copy(), x.copy()
        plus[axis] += .001; minus[axis] -= .001
        numeric = (_residual(plus,segments,rotation)-_residual(minus,segments,rotation))/.002
        current = jacobian[:,axis]; delta = numeric-current
        comparisons.append({
            'axis':'XYZ'[axis],'perturbation_m':.001,
            'relative_difference':float(np.linalg.norm(delta)/max(np.linalg.norm(numeric),1e-12)),
            'difference_norm':float(np.linalg.norm(delta)),
            'max_absolute_difference':float(np.max(np.abs(delta))),
            'correlation':float(np.corrcoef(numeric,current)[0,1])})
    full = _marginal_lever_information(jacobian,residual)[0]
    parts = {key:_subset_marginal(jacobian,residual,segments,value)
             for key,value in indices.items() if value}
    summed = sum(parts.values(),np.zeros((3,3)))
    error = np.linalg.norm(summed-full,ord='fro')
    return {'linearization':'same observation-seeded nuisance state and mechanical lever; no optimizer',
            'selected_segments':[segment.segment_id for segment in segments],
            'finite_difference_vs_solver_jacobian':comparisons,
            'full_marginal_information':full,'category_marginal_information':parts,
            'category_sum':summed,'additivity_error_fro':float(error),
            'additivity_relative_error':float(error/max(np.linalg.norm(full,ord='fro'),1e-12))}

def _legacy_diagnostics(path):
    if path is None or not path.exists():
        return {'available':False,'reason':'no prior artifact supplied'}
    payload = json.loads(path.read_text(encoding='utf-8'))
    final_l = np.asarray(payload.get('free_solution',{}).get('l_I_m',[np.nan]*3))
    return {'available':False,'source':str(path),'initial_cost':None,'final_cost':None,
            'cost_reduction':None,'nfev':None,'optimality':None,'gradient_norm':None,
            'initial_l_I_m':[0.,0.,0.],'final_l_I_m':final_l,
            'l_step_norm_m':float(np.linalg.norm(final_l)) if np.all(np.isfinite(final_l)) else None,
            'reason':'legacy artifact did not persist optimizer state; prohibited solve was not rerun'}

def main():
    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('--sample-period-s',type=float,default=1.)
    parser.add_argument('--max-best-interval-s',type=float,default=1.75)
    parser.add_argument('--rotation-rpy-deg',nargs=3,type=float,
                        default=[.4543066225,-.0026392019,.0122384129])
    parser.add_argument('--mechanical-l-I-m',nargs=3,type=float,
                        default=MECHANICAL_L_I_M.tolist())
    parser.add_argument('--previous-free-result',type=Path)
    parser.add_argument('--level-static-session',action='append',
                        default=['0819_20260819_072130','0819_20260819_073045'])
    args = parser.parse_args()
    categories = {args.circle_session:'circle',args.left_right_session:'left_right',
                  args.slope_session:'slope'}
    selected_ids = set(categories) | set(args.level_static_session)
    all_sessions = load_unified_sessions(args.manifest,selected_session_ids=selected_ids)
    sessions = [session for session in all_sessions if session.session_id in categories]
    static_sessions = [session for session in all_sessions
                       if session.session_id in set(args.level_static_session)]
    reference = _height_reference(sessions)
    if reference is None: raise RuntimeError('no valid BESTNAVA height reference')
    rotation = Rotation.from_euler('xyz',args.rotation_rpy_deg,degrees=True).as_matrix()
    lever = np.asarray(args.mechanical_l_I_m)
    runs = _qualified_runs(sessions,reference,args.sample_period_s)
    payload = {
        'scope':'factor consistency only; no free solve/LOO/prior/bootstrap/sensitivity',
        'least_squares_called':False,'rotation_source':'R2G_gravity_level_prior',
        'rotation_rpy_deg':args.rotation_rpy_deg,'mechanical_l_I_m':lever,
        'common_enu_reference':reference,
        'gnss_position_difference_vs_doppler':_gnss_audit(
            sessions,reference,args.max_best_interval_s),
        'static_specific_force_gravity_sign':_static_audit(static_sessions,rotation),
        'one_step_imu_preintegration_closure':_closure_audit(
            sessions,reference,rotation,lever,args.max_best_interval_s),
        'first_node_origin_and_initial_residual':_first_node_audit(runs,rotation,lever),
        'lever_jacobian_and_category_schur':_jacobian_schur_audit(
            runs,categories,rotation,lever),
        'previous_free_fit_solver_diagnostics':_legacy_diagnostics(args.previous_free_result)}
    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(_jsonable({
        'gnss':payload['gnss_position_difference_vs_doppler'],
        'static':payload['static_specific_force_gravity_sign'],
        'closure':payload['one_step_imu_preintegration_closure'],
        'jacobian_schur':payload['lever_jacobian_and_category_schur']}),
        ensure_ascii=False,indent=2))
    return 0

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