// Copyright (C) 2026 alto555
// SPDX-License-Identifier: GPL-2.0-only

//! Benchmark and correctness harness for the indexer.
//!
//! Subcommands:
//!   gen    <file> <nvars> <npoints>   write a synthetic binary raw file
//!   evict  <file>                     drop it from the page cache (cold runs)
//!   index  <file> [--decim N] [--threads N] [--no-sidecar]
//!   reopen <file>                     time loading the sidecar index
//!   query  <file> [var]               zoom-level query timings
//!   verify <file>                     index vs brute-force cross-check
//!   expr   <file> <varA> <varB>       derived-curve seam probe
//!   info   <file>                     header summary

use std::fs::File;
use std::io::Write;
use std::sync::Arc;
use std::time::Instant;

use wave_core::{Curve, Span};
use wave_io::{RawReader, WaveReader};
use wave_store::{Index, IndexCfg, WaveSource};

fn main() {
    let args: Vec<String> = std::env::args().collect();
    if args.len() < 2 {
        usage();
        std::process::exit(2);
    }
    let r = match args[1].as_str() {
        "gen" => cmd_gen(&args[2..]),
        "evict" => cmd_evict(&args[2..]),
        "index" => cmd_index(&args[2..]),
        "reopen" => cmd_reopen(&args[2..]),
        "query" => cmd_query(&args[2..]),
        "verify" => cmd_verify(&args[2..]),
        "expr" => cmd_expr(&args[2..]),
        "ac" => cmd_ac(&args[2..]),
        "meas" => cmd_meas(&args[2..]),
        "info" => cmd_info(&args[2..]),
        _ => {
            usage();
            std::process::exit(2);
        }
    };
    if let Err(e) = r {
        eprintln!("error: {}", e);
        std::process::exit(1);
    }
}

/// Open whatever the path is.  The rest of the program — index, reduce,
/// curves, expressions — never learns which format it got.
fn open_reader(path: &str) -> std::io::Result<Arc<dyn WaveReader>> {
    if path.to_ascii_lowercase().ends_with(".csv") {
        Ok(Arc::new(wave_io::CsvReader::open(path)?))
    } else {
        Ok(Arc::new(RawReader::open(path)?))
    }
}

fn usage() {
    eprintln!(
        "usage: wave-bench <gen|evict|index|reopen|query|verify|expr|info> <file> [args]"
    );
}

// ----------------------------------------------------------------- generate

/// Write a synthetic ngspice binary raw file with plausible analog content.
///
/// Deterministic, so a benchmark is reproducible.
fn cmd_gen(a: &[String]) -> std::io::Result<()> {
    if a.len() < 3 {
        return err("gen <file> <nvars> <npoints>");
    }
    let path = &a[0];
    let n_vars: usize = a[1].parse().unwrap_or(64);
    let n_points: usize = a[2].parse().unwrap_or(1_000_000);
    let t_end = 100e-6f64;

    let mut names = Vec::with_capacity(n_vars);
    names.push(("time".to_string(), "time".to_string()));
    for v in 1..n_vars {
        names.push((format!("v(n{:04})", v), "voltage".to_string()));
    }

    let mut f = std::io::BufWriter::with_capacity(1 << 22, File::create(path)?);
    write!(f, "Title: * synthetic waveform benchmark\n")?;
    write!(f, "Date: Mon Jan  1 00:00:00  2001\n")?;
    write!(f, "Plotname: Transient Analysis\n")?;
    write!(f, "Flags: real\n")?;
    write!(f, "No. Variables: {}\n", n_vars)?;
    write!(f, "No. Points: {}\n", n_points)?;
    write!(f, "Variables:\n")?;
    for (i, (n, t)) in names.iter().enumerate() {
        write!(f, "\t{}\t{}\t{}\n", i, n, t)?;
    }
    write!(f, "Binary:\n")?;

    let block_rows = 4096usize;
    let mut buf = vec![0f64; block_rows * n_vars];
    let dt = t_end / n_points as f64;
    let mut lcg: u64 = 0x2545F4914F6CDD1D;
    let t0 = Instant::now();
    let mut row = 0usize;
    while row < n_points {
        let rows = std::cmp::min(block_rows, n_points - row);
        for r in 0..rows {
            let i = row + r;
            let t = i as f64 * dt;
            let base = r * n_vars;
            buf[base] = t;
            for v in 1..n_vars {
                lcg = lcg.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
                let noise = ((lcg >> 40) as f64 / 16777216.0 - 0.5) * 2e-3;
                let val = match v % 4 {
                    // slow analog rail
                    0 => 0.9 + 0.05 * (t * 2.0e5 * (v as f64)).sin(),
                    // fast clock-like edge
                    1 => {
                        let ph = (t * 5.0e7 * (1.0 + v as f64 * 0.01)).fract();
                        if ph < 0.5 {
                            0.0
                        } else {
                            1.2
                        }
                    }
                    // ramp with a step partway through
                    2 => (t / t_end) * 1.2 + if t > t_end * 0.6 { 0.3 } else { 0.0 },
                    // decaying ring
                    _ => 0.6 + 0.4 * (-(t * 3.0e5)).exp() * (t * 4.0e6).cos(),
                };
                buf[base + v] = val + noise;
            }
        }
        let bytes = unsafe {
            std::slice::from_raw_parts(buf.as_ptr() as *const u8, rows * n_vars * 8)
        };
        f.write_all(bytes)?;
        row += rows;
    }
    f.flush()?;
    drop(f);
    let sz = std::fs::metadata(path)?.len();
    let el = t0.elapsed().as_secs_f64();
    println!(
        "wrote {}  {} vars x {} points = {}  in {:.1}s ({:.0} MB/s)",
        path,
        n_vars,
        n_points,
        human(sz),
        el,
        sz as f64 / el / 1e6
    );
    Ok(())
}

// -------------------------------------------------------------- page cache

extern "C" {
    fn posix_fadvise(fd: i32, offset: i64, len: i64, advice: i32) -> i32;
}
const POSIX_FADV_DONTNEED: i32 = 4;

/// Evict a file from the page cache so a "cold" measurement is really cold.
/// Works without root: DONTNEED drops clean pages.
fn cmd_evict(a: &[String]) -> std::io::Result<()> {
    if a.is_empty() {
        return err("evict <file>");
    }
    for p in a {
        let f = File::open(p)?;
        f.sync_all()?;
        use std::os::unix::io::AsRawFd;
        let rc = unsafe { posix_fadvise(f.as_raw_fd(), 0, 0, POSIX_FADV_DONTNEED) };
        if rc != 0 {
            eprintln!("posix_fadvise({}) returned {}", p, rc);
        }
    }
    Ok(())
}

// ------------------------------------------------------------------- index

fn cmd_index(a: &[String]) -> std::io::Result<()> {
    if a.is_empty() {
        return err("index <file> [--decim N] [--threads N] [--no-sidecar]");
    }
    let path = &a[0];
    let mut cfg = IndexCfg::default();
    let mut sidecar = true;
    let mut i = 1;
    while i < a.len() {
        match a[i].as_str() {
            "--decim" => {
                i += 1;
                cfg.base_decim = a[i].parse().unwrap_or(cfg.base_decim);
            }
            "--threads" => {
                i += 1;
                cfg.threads = a[i].parse().unwrap_or(cfg.threads);
            }
            "--no-sidecar" => sidecar = false,
            other => return err(format!("unknown flag {}", other)),
        }
        i += 1;
    }

    let file_len = std::fs::metadata(path)?.len();
    let t_open = Instant::now();
    let reader: Arc<dyn WaveReader> = open_reader(path)?;
    let open_ms = t_open.elapsed().as_secs_f64() * 1e3;

    let t = Instant::now();
    let ix = Index::build(&reader, cfg)?;
    let el = t.elapsed().as_secs_f64();

    println!("file        {}  ({})", path, human(file_len));
    println!(
        "shape       {} vars x {} points   header parse {:.1} ms",
        reader.n_vars(),
        reader.n_points(),
        open_ms
    );
    println!(
        "index       decim={} threads={} levels={}",
        cfg.base_decim,
        cfg.threads,
        ix.levels()
    );
    println!(
        "BUILD       {:.2} s   {:.0} MB/s   {:.1} M samples/s",
        el,
        file_len as f64 / el / 1e6,
        (reader.n_points() * reader.n_vars()) as f64 / el / 1e6
    );
    println!(
        "index size  {}  = {:.2}% of data",
        human(ix.size_bytes() as u64),
        100.0 * ix.size_bytes() as f64 / file_len as f64
    );
    if sidecar {
        let side = format!("{}.wvidx", path);
        let t = Instant::now();
        ix.save(&side)?;
        println!(
            "sidecar     wrote {} in {:.2} s",
            human(std::fs::metadata(&side)?.len()),
            t.elapsed().as_secs_f64()
        );
    }
    Ok(())
}

fn cmd_reopen(a: &[String]) -> std::io::Result<()> {
    if a.is_empty() {
        return err("reopen <file>");
    }
    let path = &a[0];
    let t = Instant::now();
    let src = WaveSource::open_raw(path, IndexCfg::default(), true)?;
    let el = t.elapsed().as_secs_f64();
    println!(
        "REOPEN      {:.3} s   from_sidecar={}   {} curves ready",
        el,
        src.loaded_from_sidecar(),
        src.reader().n_vars()
    );
    Ok(())
}

// ------------------------------------------------------------------- query

fn cmd_query(a: &[String]) -> std::io::Result<()> {
    if a.is_empty() {
        return err("query <file> [var]");
    }
    let path = &a[0];
    let var: usize = a.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);

    let src = WaveSource::open_raw(path, IndexCfg::default(), true)?;
    let curve = src.curve(var);
    let full = curve.x_span();
    let n_px = 2000usize;

    println!(
        "curve       {} [{}]   span {:.6e} .. {:.6e} s   sidecar={}",
        curve.name(),
        curve.unit().symbol(),
        full.x0,
        full.x1,
        src.loaded_from_sidecar()
    );
    println!();
    let curves = src.curves();
    let n_traces = curves.len() - 1;
    println!(
        "{:>12}  {:>14}  {:>11}  {:>14}  {:>12}  {:>7}  {:>6}",
        "zoom",
        "samples in view",
        "1 curve",
        format!("x{} per-curve", n_traces),
        format!("x{} batched", n_traces),
        "buckets",
        "exact"
    );

    for zoom in [1u64, 10, 100, 1_000, 10_000, 100_000, 1_000_000] {
        let w = full.width() / zoom as f64;
        let c = full.x0 + full.width() * 0.5;
        let span = Span::new(c - w / 2.0, c + w / 2.0);
        let (r0, r1) = src.row_range(span);

        // Warm one, then time a batch.
        let _ = curve.reduce(span, n_px);
        let reps = 20;
        let t = Instant::now();
        let mut last = None;
        for _ in 0..reps {
            last = curve.reduce(span, n_px).ready();
        }
        let per = t.elapsed().as_secs_f64() / reps as f64;
        let red = last.unwrap_or_default();

        // What a real repaint costs: every trace in the pane, same span.
        let t = Instant::now();
        for c in curves.iter().skip(1) {
            let _ = c.reduce(span, n_px);
        }
        let repaint = t.elapsed().as_secs_f64() * 1e3;

        let all: Vec<usize> = (1..curves.len()).collect();
        let t = Instant::now();
        let _ = src.reduce_many(&all, span, n_px);
        let batched = t.elapsed().as_secs_f64() * 1e3;

        println!(
            "{:>12}  {:>14}  {:>8.3} ms  {:>11.1} ms  {:>9.1} ms  {:>7}  {:>6}",
            format!("1/{}", zoom),
            r1 - r0,
            per * 1e3,
            repaint,
            batched,
            red.len(),
            red.exact
        );
    }
    if let Some(c) = src.cache() {
        let (hits, misses) = c.stats();
        println!();
        println!("row-window cache: {} hits, {} misses", hits, misses);
    }

    // Cursor readout.
    let xc = full.x0 + full.width() * 0.37;
    let t = Instant::now();
    let v = curve.value_at(xc).ready().flatten();
    println!(
        "cursor readout at {:.6e}: {:?}  in {:.3} ms",
        xc,
        v,
        t.elapsed().as_secs_f64() * 1e3
    );
    Ok(())
}

// ------------------------------------------------------------------ verify

/// Cross-check the index against a brute-force scan of the same file.
fn cmd_verify(a: &[String]) -> std::io::Result<()> {
    if a.is_empty() {
        return err("verify <file>");
    }
    let path = &a[0];
    let reader: Arc<dyn WaveReader> = open_reader(path)?;
    let n = reader.n_points();
    let nv = reader.n_vars();
    let rl = reader.row_len();
    let comps = reader.components();
    println!(
        "verify      {}  {} vars x {} points  kind={:?}",
        path,
        nv,
        n,
        reader.kind()
    );

    // Brute force global min/max per variable, straight from the reader.
    let mut bf_min = vec![f64::INFINITY; nv];
    let mut bf_max = vec![f64::NEG_INFINITY; nv];
    let block = 8192usize;
    let mut buf = vec![0f64; block * rl];
    let mut row = 0usize;
    while row < n {
        let got = reader.read_rows(row, block.min(n - row), &mut buf)?;
        if got == 0 {
            break;
        }
        for r in 0..got {
            for v in 0..nv {
                let val = if comps == 2 {
                    buf[r * rl + v * 2].hypot(buf[r * rl + v * 2 + 1])
                } else {
                    buf[r * rl + v]
                };
                if val < bf_min[v] {
                    bf_min[v] = val;
                }
                if val > bf_max[v] {
                    bf_max[v] = val;
                }
            }
        }
        row += got;
    }

    let ix = Index::build(&reader, IndexCfg::default())?;
    let mut worst = 0f64;
    let mut bad = 0;
    for v in 0..nv {
        let top = ix.y[v].last().unwrap();
        let mut mn = f32::INFINITY;
        let mut mx = f32::NEG_INFINITY;
        for b in top {
            if b[0] < mn {
                mn = b[0];
            }
            if b[1] > mx {
                mx = b[1];
            }
        }
        // f32 storage is the only permitted loss; check against its epsilon.
        let scale = bf_max[v].abs().max(bf_min[v].abs()).max(1e-30);
        let e = (((mn as f64) - bf_min[v]).abs()).max(((mx as f64) - bf_max[v]).abs()) / scale;
        if e > worst {
            worst = e;
        }
        if e > 1e-6 {
            bad += 1;
            if bad < 5 {
                println!(
                    "  MISMATCH var {} ({}): index [{:e},{:e}] brute [{:e},{:e}]",
                    v,
                    reader.variables()[v].name,
                    mn,
                    mx,
                    bf_min[v],
                    bf_max[v]
                );
            }
        }
    }
    println!(
        "global min/max: {} vars checked, worst relative error {:.3e}  -> {}",
        nv,
        worst,
        if bad == 0 { "OK" } else { "FAILED" }
    );

    // A reduction must envelope the samples it covers.
    let src = WaveSource::from_reader(Arc::clone(&reader), path, IndexCfg::default(), false)?;
    let curve = src.curve(if nv > 1 { 1 } else { 0 });
    let full = curve.x_span();
    let red = curve.reduce(full, 512).ready().unwrap_or_default();
    let vi = 1.min(nv - 1);
    // Tolerance must be f32-relative: the envelope is stored as f32 by
    // design, so a bound may sit one ulp outside the f64 brute-force value.
    let tol = bf_max[vi].abs().max(bf_min[vi].abs()).max(1.0) * (f32::EPSILON as f64);
    let mut cover_ok = true;
    for i in 0..red.len() {
        if !(red.min[i] as f64 >= bf_min[vi] - tol) || !(red.max[i] as f64 <= bf_max[vi] + tol) {
            cover_ok = false;
            break;
        }
    }
    println!(
        "reduction envelope within global bounds ({} buckets): {}",
        red.len(),
        if cover_ok { "OK" } else { "FAILED" }
    );

    // Every level must agree on the global envelope: the pyramid is lossless
    // for min/max, so folding cannot change it.
    let mut levels_ok = true;
    for v in 0..nv {
        let mut prev: Option<(f32, f32)> = None;
        for lvl in &ix.y[v] {
            let mut mn = f32::INFINITY;
            let mut mx = f32::NEG_INFINITY;
            for b in lvl {
                if b[0] < mn {
                    mn = b[0];
                }
                if b[1] > mx {
                    mx = b[1];
                }
            }
            if let Some((pmn, pmx)) = prev {
                if (pmn - mn).abs() > 0.0 || (pmx - mx).abs() > 0.0 {
                    levels_ok = false;
                }
            }
            prev = Some((mn, mx));
        }
    }
    println!(
        "pyramid lossless across {} levels: {}",
        ix.levels(),
        if levels_ok { "OK" } else { "FAILED" }
    );
    Ok(())
}

// -------------------------------------------------------------- expr probe

fn cmd_expr(a: &[String]) -> std::io::Result<()> {
    if a.len() < 3 {
        return err("expr <file> <varA> <varB>");
    }
    let path = &a[0];
    let va: usize = a[1].parse().unwrap_or(1);
    let vb: usize = a[2].parse().unwrap_or(2);
    let src = WaveSource::open_raw(path, IndexCfg::default(), true)?;

    let ca = src.curve(va);
    let cb = src.curve(vb);
    let diff: Arc<dyn Curve> = Arc::new(wave_expr::BinOpCurve::new(
        wave_expr::Op::Sub,
        Arc::clone(&ca),
        Arc::clone(&cb),
    ));

    // The renderer sees only `&dyn Curve`.  If it can tell these apart, the
    // seam is broken.
    let full = diff.x_span();
    for c in [&ca, &cb, &diff] {
        let t = Instant::now();
        let r = c.reduce(full, 2000).ready().unwrap_or_default();
        let (lo, hi) = r.y_extent().unwrap_or((f32::NAN, f32::NAN));
        println!(
            "{:<28} unit={:<4} buckets={:<5} y=[{:+.4},{:+.4}]  {:.3} ms",
            c.name(),
            c.unit().symbol(),
            r.len(),
            lo,
            hi,
            t.elapsed().as_secs_f64() * 1e3
        );
    }

    // At deep zoom the envelope must collapse onto the true difference.
    let w = full.width() / 500_000.0;
    let cx = full.x0 + full.width() * 0.5;
    let span = Span::new(cx - w / 2.0, cx + w / 2.0);
    let r = diff.reduce(span, 2000).ready().unwrap_or_default();
    let exact = diff.value_at(cx).ready().flatten();
    let widest = (0..r.len())
        .map(|i| (r.max[i] - r.min[i]) as f64)
        .fold(0.0f64, f64::max);
    println!(
        "deep zoom: {} buckets, exact={}, widest envelope {:.3e}, value_at = {:?}",
        r.len(),
        r.exact,
        widest,
        exact
    );
    Ok(())
}

// ---------------------------------------------------------------- ac check

/// Check every complex projection against ngspice's own functions.
///
/// Reference produced by:
///   set units=degrees
///   let m=mag(v(out)) d=db(v(out)) p=ph(v(out)) cp=cph(v(out))
///       re=real(v(out)) im=imag(v(out))
///   wrdata acref.csv m d p cp re im
/// wrdata emits an (x, y) pair per vector, hence the stride of 2.
/// Half the value of the last digit printed by "%.8e".
fn half_ulp(v: f64) -> f64 {
    if v == 0.0 || !v.is_finite() {
        return 1e-300;
    }
    0.5 * 10f64.powi(v.abs().log10().floor() as i32 - 8)
}

fn cmd_ac(a: &[String]) -> std::io::Result<()> {
    if a.len() < 3 {
        return err("ac <ac.raw> <acref.csv> <varname>");
    }
    let src = WaveSource::open_raw(&a[0], IndexCfg::default(), false)?;
    let store = match src.complex_store() {
        Some(s) => s,
        None => return err("not a complex plot"),
    };
    let var = match src.find(&a[2]) {
        Some(v) => v,
        None => return err(format!("no variable named {}", a[2])),
    };

    let text = std::fs::read_to_string(&a[1])?;
    let rows: Vec<Vec<f64>> = text
        .lines()
        .filter(|l| !l.trim().is_empty())
        .map(|l| l.split_whitespace().filter_map(|t| t.parse::<f64>().ok()).collect())
        .collect();

    println!("plot        {}  ({:?})", src.reader().plot_name(), src.kind());
    println!("x scale     {:?}   (from the file's grid= annotation)", src.x_scale());
    println!("points      {}   resident {:.1} KB", store.n_points(), store.size_bytes() as f64 / 1024.0);
    println!("reference   {} rows from ngspice", rows.len());
    println!();

    // (component, reference column index)
    let checks = [
        (wave_core::Component::Magnitude, 1usize, "mag"),
        (wave_core::Component::Db, 3, "db"),
        (wave_core::Component::Phase, 5, "ph"),
        (wave_core::Component::PhaseUnwrapped, 7, "cph"),
        (wave_core::Component::Real, 9, "real"),
        (wave_core::Component::Imag, 11, "imag"),
    ];
    let n = rows.len().min(store.n_points());
    let mut all_ok = true;
    println!(
        "{:<6} {:>14} {:>18} {:>10}",
        "comp", "max abs err", "in half-ULP of ref", "verdict"
    );
    for (comp, col, label) in checks {
        let mut worst_abs = 0f64;
        let mut worst_ulp = 0f64;
        for r in 0..n {
            if rows[r].len() <= col {
                continue;
            }
            let mine = store.value(var, r, comp);
            let theirs = rows[r][col];
            let d = (mine - theirs).abs();
            if d > worst_abs {
                worst_abs = d;
            }
            // The reference is text at %.8e — 9 significant digits — so the
            // smallest difference it can express is one unit in the last
            // printed place.  Anything at or under half of that is agreement
            // to the full precision the file carries; a tighter threshold
            // would be measuring the printf format, not the arithmetic.
            if d > worst_ulp * half_ulp(theirs) {
                let h = half_ulp(theirs);
                if h > 0.0 {
                    worst_ulp = worst_ulp.max(d / h);
                }
            }
        }
        let ok = worst_ulp <= 1.0;
        all_ok &= ok;
        println!(
            "{:<6} {:>14.3e} {:>18.3} {:>10}",
            label,
            worst_abs,
            worst_ulp,
            if ok { "OK" } else { "MISMATCH" }
        );
    }
    println!();

    // Where does unwrapping actually matter?
    let mut max_gap = 0f64;
    let mut at = 0f64;
    for r in 0..store.n_points() {
        let g = (store.value(var, r, wave_core::Component::PhaseUnwrapped)
            - store.value(var, r, wave_core::Component::Phase))
        .abs();
        if g > max_gap {
            max_gap = g;
            at = store.x(r);
        }
    }
    println!(
        "phase unwrap diverges from wrapped by up to {:.1} deg (at {:.4e} Hz)",
        max_gap, at
    );

    // A nonlinear projection must be refused on reduced (real) data.
    println!();
    println!("component availability:");
    for (comp, _, label) in checks {
        println!(
            "  {:<5} complex={:<5} real={}",
            label,
            src.curve_component(var, comp).is_some(),
            if comp.nonlinear() { "refused (would need its own pyramid)" } else { "yes" }
        );
    }
    if !all_ok {
        return err("component mismatch against ngspice reference");
    }
    Ok(())
}

// ------------------------------------------------------------ measurements

/// All measurements for one signal over a span.
///
/// Everything below reads exact samples through `for_each_sample`; none of it
/// touches `reduce`.  A rise time taken from a bucket envelope is not a rise
/// time.
fn cmd_meas(a: &[String]) -> std::io::Result<()> {
    if a.len() < 2 {
        return err("meas <file> <var> [--from x] [--to y] [--delay <var2>]");
    }
    let src = WaveSource::open_raw(&a[0], IndexCfg::default(), true)?;
    let var = match src.find(&a[1]) {
        Some(v) => v,
        None => return err(format!("no variable named {}", a[1])),
    };
    let curve = src.curve(var);
    let full = curve.x_span();
    let mut span = full;
    let mut delay_to: Option<String> = None;
    let mut abs_thresh: Option<(f64, f64)> = None;
    let mut i = 2usize;
    while i < a.len() {
        match a[i].as_str() {
            "--from" => {
                i += 1;
                span.x0 = a[i].parse().unwrap_or(span.x0);
            }
            "--to" => {
                i += 1;
                span.x1 = a[i].parse().unwrap_or(span.x1);
            }
            "--delay" => {
                i += 1;
                delay_to = Some(a[i].clone());
            }
            // Absolute trigger levels, matching ngspice's
            // "TRIG ... VAL=x TARG ... VAL=y" form exactly.
            "--abs" => {
                let lo: f64 = a[i + 1].parse().unwrap_or(0.0);
                let hi: f64 = a[i + 2].parse().unwrap_or(0.0);
                abs_thresh = Some((lo, hi));
                i += 2;
            }
            o => return err(format!("unknown flag {}", o)),
        }
        i += 1;
    }

    println!("signal      {} [{}]", curve.name(), curve.unit().symbol());
    println!("span        {:.6e} .. {:.6e}", span.x0, span.x1);

    let t = Instant::now();
    let st = match wave_meas::stats(&*curve, span) {
        Some(s) => s,
        None => return err("no samples in span"),
    };
    let ms = t.elapsed().as_secs_f64() * 1e3;
    println!("samples     {}   (streamed in {:.1} ms)", st.n, ms);
    println!();
    println!("  min       {:>16.8e}  at {:.6e}", st.min, st.x_at_min);
    println!("  max       {:>16.8e}  at {:.6e}", st.max, st.x_at_max);
    println!("  pp        {:>16.8e}", st.peak_to_peak());
    println!("  avg       {:>16.8e}   (time-weighted)", st.mean);
    println!("  rms       {:>16.8e}   (time-weighted)", st.rms);
    println!("  integral  {:>16.8e}", st.area);

    if let Some((lo, hi)) = abs_thresh {
        let up = wave_meas::crossings(&*curve, span, lo, true).unwrap_or_default();
        let up2 = wave_meas::crossings(&*curve, span, hi, true).unwrap_or_default();
        let dn = wave_meas::crossings(&*curve, span, hi, false).unwrap_or_default();
        let dn2 = wave_meas::crossings(&*curve, span, lo, false).unwrap_or_default();
        println!();
        if let (Some(a0), Some(b0)) = (up.first(), up2.iter().find(|t| **t >= up[0])) {
            println!(
                "  rise(abs) {:>16.8e}   {} -> {}, trig {:.6e} targ {:.6e}",
                b0 - a0, lo, hi, a0, b0
            );
        }
        if let (Some(a0), Some(b0)) = (dn.first(), dn2.iter().find(|t| **t >= dn[0])) {
            println!(
                "  fall(abs) {:>16.8e}   {} -> {}, trig {:.6e} targ {:.6e}",
                b0 - a0, hi, lo, a0, b0
            );
        }
    }
    if let Some(tr) = wave_meas::transition(&*curve, span, true, 10.0, 90.0) {
        println!();
        println!(
            "  rise      {:>16.8e}   10%..90%, {:.6e} -> {:.6e}",
            tr.duration, tr.start, tr.end
        );
    }
    if let Some(tf) = wave_meas::transition(&*curve, span, false, 10.0, 90.0) {
        println!(
            "  fall      {:>16.8e}   90%..10%, {:.6e} -> {:.6e}",
            tf.duration, tf.start, tf.end
        );
    }
    if let Some(p) = wave_meas::periodic(&*curve, span) {
        println!();
        println!("  period    {:>16.8e}   over {} cycles", p.period, p.cycles);
        println!("  frequency {:>16.8e}", p.frequency);
        println!("  duty      {:>15.4} %", p.duty);
    }
    if let Some(name) = delay_to {
        let v2 = match src.find(&name) {
            Some(v) => v,
            None => return err(format!("no variable named {}", name)),
        };
        let c2 = src.curve(v2);
        let lv = wave_meas::levels(&*curve, span).unwrap();
        let mid = lv.at(50.0);
        if let Some(d) = wave_meas::delay(&*curve, &*c2, span, mid, true, mid, true) {
            println!();
            println!(
                "  delay     {:>16.8e}   {} -> {} at {:.4} (50%)",
                d,
                curve.name(),
                c2.name(),
                mid
            );
        }
    }
    Ok(())
}

// -------------------------------------------------------------------- info

fn cmd_info(a: &[String]) -> std::io::Result<()> {
    if a.is_empty() {
        return err("info <file>");
    }
    let rd = RawReader::open(&a[0])?;
    println!("path        {}", rd.path().display());
    println!("plot        {}", rd.plot_name());
    println!("format      {}", if rd.is_binary() { "binary" } else { "ascii" });
    println!("kind        {:?}", rd.kind());
    println!("points      {}", rd.n_points());
    if rd.truncated() {
        println!("            TRUNCATED: header claims {}", rd.header_points());
    }
    println!("variables   {}", rd.n_vars());
    for v in rd.variables().iter().take(12) {
        println!("  {:>3}  {:<28} {}", v.index, v.name, v.unit.symbol());
    }
    if rd.n_vars() > 12 {
        println!("  ... {} more", rd.n_vars() - 12);
    }
    Ok(())
}

// ----------------------------------------------------------------- helpers

fn err<T>(msg: impl Into<String>) -> std::io::Result<T> {
    Err(std::io::Error::new(
        std::io::ErrorKind::InvalidInput,
        msg.into(),
    ))
}

fn human(b: u64) -> String {
    const U: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
    let mut v = b as f64;
    let mut i = 0;
    while v >= 1024.0 && i < U.len() - 1 {
        v /= 1024.0;
        i += 1;
    }
    format!("{:.2} {}", v, U[i])
}
