//! Correlate a capture **per frame** and cross-check the frames against each
//! other — the placement is only believable if independent frames agree.
//!
//! `correlate_capture` treats one capture log as one set of draws. It is not:
//! an F10 press dumps ~14 frames with no delimiter, and `WV_ref⁻¹ · WV_p` only
//! cancels the camera within a single frame (see
//! [`sylpheed_formats::ship_capture::segment_frames`]). With a moving camera the
//! mixed-frame answer is wrong, and — worse — it is wrong *silently*.
//!
//! So: segment, correlate each frame independently, then report per part the
//! median translation and the spread across frames. A part whose spread is a
//! few units is measured; a part that swings by hundreds is not, whatever the
//! single-shot number said.
//!
//! Usage:
//!   SYLPHEED_ISO=... cargo run --release --example correlate_frames -- \
//!       <capture.log> <Stage_SNN> <ship_id> [ref_part_substr] [--min-parts N]

use sylpheed_formats::mesh::{xbg7_resource_names, Xbg7Model};
use sylpheed_formats::ship::{is_base_part, ship_id_of};
use sylpheed_formats::ship_capture::{
    correlate, parse_capture, parse_drawlog, segment_frames, PartKey,
};
use sylpheed_formats::xiso::open_iso;
use std::collections::{BTreeMap, HashSet};
use std::path::Path;

fn median(mut v: Vec<f32>) -> f32 {
    v.sort_by(|a, b| a.partial_cmp(b).unwrap());
    let n = v.len();
    if n % 2 == 1 { v[n / 2] } else { 0.5 * (v[n / 2 - 1] + v[n / 2]) }
}

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let positional: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with("--")).collect();
    if positional.len() < 3 {
        eprintln!("usage: correlate_frames <capture.log> <Stage_SNN> <ship_id> [ref_part] [--min-parts N]");
        std::process::exit(2);
    }
    let (log, stage, id) = (positional[0], positional[1], positional[2]);
    let ref_sub = positional.get(3).map(|s| s.as_str()).unwrap_or("bdy_01");
    let min_parts: usize = args
        .iter()
        .position(|a| a == "--min-parts")
        .and_then(|i| args.get(i + 1))
        .and_then(|s| s.parse().ok())
        .unwrap_or(3);

    let iso = std::env::var("SYLPHEED_ISO").expect("SYLPHEED_ISO");
    let text = std::fs::read_to_string(log).expect("read log");
    let mut draws = parse_capture(&text);
    if draws.is_empty() {
        draws = parse_drawlog(&text);
    }
    let frames = segment_frames(&draws);
    println!("{} draws → {} camera-consistent blocks", draws.len(), frames.len());

    let bytes = {
        let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
        rt.block_on(async {
            let mut r = open_iso(Path::new(&iso)).await.unwrap();
            r.read_file(&format!("hidden/resource3d/{stage}.xpr")).await.unwrap()
        })
    };
    let names = xbg7_resource_names(&bytes);
    let base_parts: Vec<String> = names
        .iter()
        .filter(|n| is_base_part(n) && ship_id_of(n) == Some(id.as_str()))
        .cloned()
        .collect();
    let mut want: HashSet<String> = base_parts.iter().cloned().collect();
    for p in &base_parts {
        for suf in ["_m", "_l", "_d"] {
            let c = format!("{p}{suf}");
            if names.contains(&c) {
                want.insert(c);
            }
        }
    }
    let models = Xbg7Model::models_named(&bytes, &want, &|| false);
    let positions_of = |name: &str| -> Option<Vec<[f32; 3]>> {
        let m = models.iter().find(|m| m.name == name)?;
        Some(m.meshes.iter().flat_map(|s| s.positions.iter().copied()).collect())
    };

    // part -> [T per frame], and how many frames placed it at all.
    let mut samples: BTreeMap<String, Vec<[f32; 3]>> = BTreeMap::new();
    let mut rots: BTreeMap<String, Vec<[[f32; 3]; 3]>> = BTreeMap::new();
    let mut used_frames = 0usize;
    for (fi, fr) in frames.iter().enumerate() {
        let mut keys: Vec<PartKey> = Vec::new();
        for part in &base_parts {
            let variants =
                [part.clone(), format!("{part}_m"), format!("{part}_l"), format!("{part}_d")];
            let union: Vec<[f32; 3]> =
                variants.iter().filter_map(|v| positions_of(v)).flatten().collect();
            for cand in &variants {
                if let Some(pos) = positions_of(cand) {
                    let vcount = pos.len() as u32;
                    if fr.iter().any(|d| d.vcount == vcount) {
                        keys.push(PartKey { part: part.clone(), vcount, ref_pos: union.clone() });
                    }
                }
            }
        }
        let Some(ship) = correlate(id, fr, &keys, ref_sub) else { continue };
        if ship.parts.len() < min_parts {
            continue;
        }
        // Placements are expressed in the REFERENCE part's frame, so blocks that
        // fell back to a different reference (because the requested one was not
        // drawn in that block) are in a different coordinate system entirely.
        // Averaging them together is what makes an otherwise clean result look
        // like it disagrees by exactly the distance between the two references.
        if !ship.reference.contains(ref_sub) {
            println!("  block {fi:2}: skipped — reference fell back to {}", ship.reference);
            continue;
        }
        used_frames += 1;
        println!(
            "  block {fi:2} ({:4} draws): ref={} parts={}",
            fr.len(),
            ship.reference,
            ship.parts.len()
        );
        for p in &ship.parts {
            samples.entry(p.part.clone()).or_default().push(p.t);
            rots.entry(p.part.clone()).or_default().push(p.m);
        }
    }

    if used_frames == 0 {
        println!("\nno block placed {min_parts}+ parts — the ship is not drawn close enough");
        return;
    }
    // Aggregate by CONSENSUS, not by average. A stage holds several ships of the
    // same class, they share vertex buffers, and a block can therefore contain
    // one instance's full-LOD part next to another instance's `_m` copy — two
    // different buffers, so nothing splits them, and the recovered translation
    // then belongs to whichever instance the correlator happened to pick. Those
    // are outliers by thousands of units, so a mean or a median over all blocks
    // is meaningless; the largest cluster of blocks that agree with each other
    // is the placement, and the rest are honestly reported as other instances.
    const TOL: f32 = 25.0; // float noise in the WV products, measured ≤0.4
    let cluster = |ts: &Vec<[f32; 3]>| -> (Vec<usize>, usize) {
        let mut best: Vec<usize> = Vec::new();
        for seed in ts {
            let near: Vec<usize> = ts
                .iter()
                .enumerate()
                .filter(|(_, t)| (0..3).all(|i| (t[i] - seed[i]).abs() < TOL))
                .map(|(i, _)| i)
                .collect();
            if near.len() > best.len() {
                best = near;
            }
        }
        let out = ts.len() - best.len();
        (best, out)
    };
    println!("\nacross {used_frames} blocks — consensus T (largest agreeing cluster):");
    let mut agree = 0usize;
    let mut consensus: BTreeMap<String, ([f32; 3], [[f32; 3]; 3])> = BTreeMap::new();
    for (part, ts) in &samples {
        let (cl_idx, outliers) = cluster(ts);
        let cl: Vec<[f32; 3]> = cl_idx.iter().map(|&i| ts[i]).collect();
        let med = [
            median(cl.iter().map(|t| t[0]).collect()),
            median(cl.iter().map(|t| t[1]).collect()),
            median(cl.iter().map(|t| t[2]).collect()),
        ];
        let spread: Vec<f32> = (0..3)
            .map(|a| {
                let v: Vec<f32> = cl.iter().map(|t| t[a]).collect();
                v.iter().cloned().fold(f32::MIN, f32::max) - v.iter().cloned().fold(f32::MAX, f32::min)
            })
            .collect();
        let verdict = if cl.len() < 2 {
            "single block — unverified"
        } else {
            agree += 1;
            "AGREES"
        };
        // How much the ROTATION varies between blocks that agree on position.
        // A part bolted to the hull reads 0 here; a part that is articulating
        // (turret aiming, engine gimballing) does not — which is what separates
        // "the assembler has the rotation wrong" from "the part moved".
        let all_ms = rots.get(part).cloned().unwrap_or_default();
        let ms: Vec<[[f32; 3]; 3]> =
            cl_idx.iter().filter_map(|&i| all_ms.get(i).copied()).collect();
        // Keep a rotation from INSIDE the cluster: the first sample overall can
        // belong to another instance, and diffing static against that reads as a
        // rotation error that is really an instance mix-up.
        consensus.insert(
            part.clone(),
            (med, ms.first().copied().unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])),
        );
        let rot_var = ms
            .iter()
            .flat_map(|a| ms.iter().map(move |b| (a, b)))
            .map(|(a, b)| {
                (0..3)
                    .flat_map(|i| (0..3).map(move |j| (i, j)))
                    .map(|(i, j)| (a[i][j] - b[i][j]).abs())
                    .fold(0.0f32, f32::max)
            })
            .fold(0.0f32, f32::max);
        println!(
            "  {part:18} {:2}/{:2} blocks  T=[{:9.1}{:9.1}{:9.1}]  spread=[{:6.2}{:6.2}{:6.2}]  rotVar={rot_var:5.3}  {verdict}{}",
            cl.len(), ts.len(), med[0], med[1], med[2], spread[0], spread[1], spread[2],
            if outliers > 0 { format!("  (+{outliers} other-instance)") } else { String::new() }
        );
    }
    println!("\n{agree}/{} parts reproduce across blocks", samples.len());

    // `--static <Stage_SNN.xpr>`: diff the offline assembler against this
    // ground truth. Static placements are in ship space, so both sides are
    // re-expressed in the reference part's frame before comparing — and the
    // rotation is compared too, because "wrong orientation" is half of the
    // reported viewer symptom and a translation-only check cannot see it.
    let Some(si) = args.iter().position(|a| a == "--static") else { return };
    let Some(spath) = args.get(si + 1) else { return };
    let sbytes = std::fs::read(spath).expect("read stage container");
    // `include_external = true` — the engine cluster, the bridge and cross-id
    // turrets live in SEPARATE composites (`e_rou_e106_eng`, 3 nodes) that the
    // primary-composite pass does not reach. With `false` an e106 assembles as
    // 5 parts and the runtime capture's bridge/nacelles read as "not produced by
    // assemble_ship", which is a property of the caller, not of the format.
    let scene = sylpheed_formats::ship::assemble_ship(&sbytes, id, true);
    let Some(sref) = scene.iter().find(|p| p.resource.contains(ref_sub)) else {
        println!("\nstatic: no part matching '{ref_sub}' — cannot align frames");
        return;
    };
    // The reference is placed axis-aligned in every ship seen so far; if that
    // ever stops holding, the rotation would have to be unwound here too.
    println!("\nstatic vs runtime (both relative to {}):", sref.resource);
    let mut worst_t = 0.0f32;
    let mut worst_r = 0.0f32;
    for (part, (med, rm)) in &consensus {
        let (med, rm) = (*med, *rm);
        // A part may be instanced (mirrored twins share a resource name); take
        // the static copy that lands nearest the captured one.
        let cands: Vec<&sylpheed_formats::mesh::ScenePart> =
            scene.iter().filter(|p| &p.resource == part).collect();
        if cands.is_empty() {
            println!("  {part:18} — not produced by assemble_ship");
            continue;
        }
        let rel = |p: &sylpheed_formats::mesh::ScenePart| {
            [p.t[0] - sref.t[0], p.t[1] - sref.t[1], p.t[2] - sref.t[2]]
        };
        let best = cands
            .iter()
            .min_by(|a, b| {
                let d = |p: &sylpheed_formats::mesh::ScenePart| {
                    let r = rel(p);
                    (0..3).map(|i| (r[i] - med[i]).powi(2)).sum::<f32>()
                };
                d(a).partial_cmp(&d(b)).unwrap()
            })
            .unwrap();
        let r = rel(best);
        let dt: Vec<f32> = (0..3).map(|i| r[i] - med[i]).collect();
        let dtm = dt.iter().map(|v| v.abs()).fold(0.0f32, f32::max);
        let drm = (0..3)
            .flat_map(|i| (0..3).map(move |j| (i, j)))
            .map(|(i, j)| (best.m[i][j] - rm[i][j]).abs())
            .fold(0.0f32, f32::max);
        worst_t = worst_t.max(dtm);
        worst_r = worst_r.max(drm);
        let mark = if dtm < 1.0 && drm < 0.02 { "MATCH" } else { "DIFFERS" };
        println!(
            "  {part:18} static=[{:9.1}{:9.1}{:9.1}]  dT={dtm:7.2}  dR={drm:6.3}  {mark}",
            r[0], r[1], r[2]
        );
    }
    println!("\nworst dT={worst_t:.2}  worst dR={worst_r:.3}  ({} static parts, {} captured)",
             scene.len(), samples.len());
}
