//! `specview` — a developer CLI for the `dsp` toolkit: conjure test signals,
//! draw spectrograms in the terminal, and measure echo-cancellation quality
//! (ERLE) on WAV files. Not shipped in the GUI; a measurement aid.
//!
//! Usage:
//!   specview show  <file.wav> [--width N] [--height N] [--max-hz HZ] [--floor DB] [--ascii]
//!   specview gen   <kind> -o out.wav [--secs S] [--amp A] [--freq F] [--f0 F] [--f1 F] [--seed N]
//!         kinds: sine | sweep | white | pink | impulse | silence
//!   specview erle  <before.wav> <after.wav> [--show]
//!
//! Examples:
//!   specview gen sweep -o sweep.wav --secs 3 --f0 100 --f1 12000
//!   specview show ~/peerspeak-recordings/2026-06-14/peerspeak-*.wav
//!   specview erle echo_only.wav after_aec.wav --show

use std::collections::HashMap;
use std::path::Path;
use std::process::ExitCode;

use peerspeak::dsp::{aec, echo_path, generators, metrics, render, stft, wav};

const SAMPLE_RATE: u32 = 48_000;

fn main() -> ExitCode {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let Some(cmd) = args.first() else {
        eprintln!("{}", USAGE);
        return ExitCode::FAILURE;
    };

    let rest = &args[1..];
    let result = match cmd.as_str() {
        "show" => cmd_show(rest),
        "gen" => cmd_gen(rest),
        "erle" => cmd_erle(rest),
        "aec" => cmd_aec(rest),
        "-h" | "--help" | "help" => {
            println!("{}", USAGE);
            Ok(())
        }
        other => Err(format!("unknown command {other:?}\n\n{USAGE}")),
    };

    match result {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("error: {e}");
            ExitCode::FAILURE
        }
    }
}

const USAGE: &str = "specview — spectrogram + AEC measurement tool\n\n\
  specview show <file.wav> [--width N] [--height N] [--max-hz HZ] [--floor DB] [--ascii]\n\
  specview gen  <kind> -o out.wav [--secs S] [--amp A] [--freq F] [--f0 F] [--f1 F] [--seed N]\n\
                kinds: sine sweep white pink impulse silence\n\
  specview erle <before.wav> <after.wav> [--show]\n\
  specview aec  --far far.wav [--near near.wav] [--near-onset S] [--delay N] [--tail N]\n\
                [--atten A] [--taps N] [--mu M] [--dtd-threshold T] [--hangover N]\n\
                [--no-dtd] [--out cleaned.wav] [--show]";

/// Renders a WAV as a spectrogram plus a numeric summary.
fn cmd_show(args: &[String]) -> Result<(), String> {
    let (positional, flags) = parse_args(args);
    let path = positional.first().ok_or("show needs a <file.wav>")?;
    let data = wav::read(Path::new(path))?;

    let opts = render::RenderOpts {
        width: flags.usize_or("width", 100),
        height: flags.usize_or("height", 30),
        max_hz: flags.f32_or("max-hz", 12_000.0),
        floor_db: flags.f32_or("floor", -80.0),
        ascii: flags.present("ascii"),
    };
    let spec = stft::analyze(&data.samples, data.sample_rate, 2048, 512);
    print!("{}", render::render(&spec, &opts));
    print_summary(path, &data.samples, &spec);
    Ok(())
}

/// Generates a test signal and writes it to a WAV file.
fn cmd_gen(args: &[String]) -> Result<(), String> {
    let (positional, flags) = parse_args(args);
    let kind = positional.first().ok_or("gen needs a <kind>")?;
    let out = flags
        .get("o")
        .or_else(|| flags.get("out"))
        .ok_or("gen needs -o <out.wav>")?;

    let secs = flags.f32_or("secs", 2.0);
    let amp = flags.f32_or("amp", 0.8);
    let len = (secs * SAMPLE_RATE as f32) as usize;
    let seed = flags.u64_or("seed", 1);

    let samples = match kind.as_str() {
        "sine" => generators::sine(flags.f32_or("freq", 1000.0), amp, len, SAMPLE_RATE),
        "sweep" => generators::log_sweep(
            flags.f32_or("f0", 100.0),
            flags.f32_or("f1", 12_000.0),
            amp,
            len,
            SAMPLE_RATE,
        ),
        "white" => generators::white_noise(amp, len, seed),
        "pink" => generators::pink_noise(amp, len, seed),
        "impulse" => generators::impulse(amp, len),
        "silence" => generators::silence(len),
        other => {
            return Err(format!(
                "unknown kind {other:?} (sine sweep white pink impulse silence)"
            ));
        }
    };

    wav::write(Path::new(out), &samples, SAMPLE_RATE)?;
    println!(
        "wrote {} — {} ({:.2}s, {} samples, rms {:.1} dBFS)",
        out,
        kind,
        secs,
        samples.len(),
        metrics::dbfs(metrics::rms(&samples)),
    );
    Ok(())
}

/// Measures ERLE between a "before" (echo present) and "after" (post-AEC) file —
/// broadband and per voice band — so you can see how much echo was removed and
/// in which frequency range any residual lives.
fn cmd_erle(args: &[String]) -> Result<(), String> {
    let (positional, flags) = parse_args(args);
    let before = positional
        .first()
        .ok_or("erle needs <before.wav> <after.wav>")?;
    let after = positional
        .get(1)
        .ok_or("erle needs <before.wav> <after.wav>")?;

    let b = wav::read(Path::new(before))?;
    let a = wav::read(Path::new(after))?;
    // Compare over the overlapping length.
    let n = b.samples.len().min(a.samples.len());
    let bs = &b.samples[..n];
    let as_ = &a.samples[..n];

    let broadband = metrics::erle(bs, as_);
    println!("ERLE (broadband): {broadband:+.1} dB   (higher = more echo removed)");
    println!(
        "  before: {:.1} dBFS rms   after: {:.1} dBFS rms",
        metrics::dbfs(metrics::rms(bs)),
        metrics::dbfs(metrics::rms(as_)),
    );

    // Per-band ERLE via averaged STFT magnitude energy.
    let sb = stft::analyze(bs, b.sample_rate, 2048, 512);
    let sa = stft::analyze(as_, a.sample_rate, 2048, 512);
    let avg_b = average_frame(&sb);
    let avg_a = average_frame(&sa);
    println!("  per band:");
    for band in metrics::VOICE_BANDS {
        let eb = metrics::band_energy(&avg_b, |bin| sb.bin_hz(bin), band.low_hz, band.high_hz);
        let ea = metrics::band_energy(&avg_a, |bin| sa.bin_hz(bin), band.low_hz, band.high_hz);
        let db = if eb > 1e-12 && ea > 1e-12 {
            10.0 * (eb / ea).log10()
        } else if eb > 1e-12 {
            120.0
        } else {
            0.0
        };
        println!("    {:<18} {:+6.1} dB", band.label, db);
    }

    if flags.present("show") {
        println!("\n--- before ---");
        print!("{}", render::render(&sb, &render::RenderOpts::default()));
        println!("\n--- after ---");
        print!("{}", render::render(&sa, &render::RenderOpts::default()));
    }
    Ok(())
}

/// The full headless AEC loop: take a far-end signal, run it through a synthetic
/// echo path to make the echo a mic would hear, optionally add near-end speech,
/// then cancel with the NLMS filter and measure how much echo was removed.
///
/// Because the simulator hands us the true echo and near-end separately, ERLE is
/// measured against ground truth (residual echo = cleaned − near) — exact even
/// when near-end talk is present.
fn cmd_aec(args: &[String]) -> Result<(), String> {
    let (_positional, flags) = parse_args(args);
    let far_path = flags.get("far").ok_or("aec needs --far <far.wav>")?;
    let far = wav::read(Path::new(far_path))?;
    let sr = far.sample_rate;

    // Optional near-end (the local voice the canceller must preserve). An onset
    // can be set so the near-end starts partway through, exercising the classic
    // "far-end converges first, then double-talk" case.
    let onset = (flags.f32_or("near-onset", 0.0) * sr as f32) as usize;
    let near = match flags.get("near") {
        Some(p) => {
            let d = wav::read(Path::new(p))?;
            let mut v = d.samples;
            v.resize(far.samples.len(), 0.0); // align length to far-end
            let cut = onset.min(v.len());
            for s in v.iter_mut().take(cut) {
                *s = 0.0; // silence before onset
            }
            v
        }
        None => vec![0.0f32; far.samples.len()],
    };

    // Build the echo and the mic signal (echo + near-end).
    let delay = flags.usize_or("delay", 480); // ~10 ms at 48 kHz
    let tail = flags.usize_or("tail", 960); // ~20 ms reverb tail
    let atten = flags.f32_or("atten", 0.3); // ~ -10 dB echo (realistic acoustic coupling)
    let path = echo_path::EchoPath::synthetic(delay, tail, atten, flags.u64_or("seed", 1));
    let echo = path.apply(&far.samples);
    let mic: Vec<f32> = echo.iter().zip(&near).map(|(&e, &n)| e + n).collect();

    // Cancel with the full echo canceller (NLMS + double-talk detection).
    let taps = flags.usize_or("taps", (delay + tail).next_power_of_two());
    let mu = flags.f32_or("mu", 0.5);
    // 0.5 is a good operating point for ~-10 to -20 dB acoustic coupling: high
    // enough that echo-only rarely trips it, low enough to catch near-end talk.
    let dtd_threshold = flags.f32_or("dtd-threshold", 0.5);
    let hangover = flags.usize_or("hangover", sr as usize / 50); // ~20 ms
    let mut canceller = aec::EchoCanceller::new(taps, mu, 1e-6, dtd_threshold, hangover);
    if flags.present("no-dtd") {
        canceller.set_dtd_enabled(false);
    }
    let cleaned = canceller.process(&far.samples, &mic);

    // Residual echo (oracle): whatever's left after removing the known near-end.
    let residual: Vec<f32> = cleaned.iter().zip(&near).map(|(&c, &n)| c - n).collect();

    let broadband = metrics::erle(&echo, &residual);
    let q = echo.len() / 4;
    let early = metrics::erle(&echo[..q], &residual[..q]);
    let late = metrics::erle(&echo[3 * q..], &residual[3 * q..]);
    let has_near = flags.present("near");

    println!("AEC simulation");
    println!(
        "  echo path: delay {delay} ({:.0} ms), tail {tail} ({:.0} ms), atten {atten} ({:.1} dB)",
        1000.0 * delay as f32 / sr as f32,
        1000.0 * tail as f32 / sr as f32,
        metrics::dbfs(atten),
    );
    println!(
        "  filter: {taps} taps, mu {mu}{}",
        if has_near {
            " (with near-end / double-talk)"
        } else {
            ""
        }
    );
    if has_near {
        let dtd = if flags.present("no-dtd") { "off" } else { "on" };
        println!(
            "  double-talk: detector {dtd}, threshold {dtd_threshold}, flagged {:.0}% of samples{}",
            100.0 * canceller.double_talk_rate(),
            if onset > 0 {
                format!(", near-end onset {:.1}s", onset as f32 / sr as f32)
            } else {
                String::new()
            },
        );
    }
    println!(
        "  mic before: {:.1} dBFS rms",
        metrics::dbfs(metrics::rms(&mic))
    );
    println!(
        "  residual echo after: {:.1} dBFS rms",
        metrics::dbfs(metrics::rms(&residual))
    );
    println!("  ERLE broadband: {broadband:+.1} dB");
    println!("  ERLE early/late: {early:+.1} -> {late:+.1} dB  (rise = filter converging)");

    // Per-band residual echo, to see where any leak lives.
    let se = stft::analyze(&echo, sr, 2048, 512);
    let sr_res = stft::analyze(&residual, sr, 2048, 512);
    let avg_e = average_frame(&se);
    let avg_r = average_frame(&sr_res);
    println!("  ERLE by band:");
    for band in metrics::VOICE_BANDS {
        let ee = metrics::band_energy(&avg_e, |b| se.bin_hz(b), band.low_hz, band.high_hz);
        let er = metrics::band_energy(&avg_r, |b| sr_res.bin_hz(b), band.low_hz, band.high_hz);
        let db = if ee > 1e-12 && er > 1e-12 {
            10.0 * (ee / er).log10()
        } else if ee > 1e-12 {
            120.0
        } else {
            0.0
        };
        println!("    {:<18} {:+6.1} dB", band.label, db);
    }

    if let Some(out) = flags.get("out") {
        wav::write(Path::new(out), &cleaned, sr)?;
        println!("  wrote cleaned output -> {out}");
    }
    if flags.present("show") {
        println!("\n--- mic (echo present) ---");
        print!(
            "{}",
            render::render(
                &stft::analyze(&mic, sr, 2048, 512),
                &render::RenderOpts::default()
            )
        );
        println!("\n--- cleaned (post-AEC) ---");
        print!(
            "{}",
            render::render(
                &stft::analyze(&cleaned, sr, 2048, 512),
                &render::RenderOpts::default()
            )
        );
    }
    Ok(())
}

/// Prints RMS/peak/dBFS plus the per-band energy distribution of a signal.
fn print_summary(path: &str, samples: &[f32], spec: &stft::Spectrogram) {
    println!(
        "\n{}\n  duration {:.2}s   rms {:.1} dBFS   peak {:.1} dBFS",
        path,
        samples.len() as f32 / spec.sample_rate as f32,
        metrics::dbfs(metrics::rms(samples)),
        metrics::dbfs(metrics::peak(samples)),
    );
    let avg = average_frame(spec);
    let total: f32 = avg.iter().map(|m| m * m).sum::<f32>().max(1e-12);
    println!("  energy by band:");
    for band in metrics::VOICE_BANDS {
        let e = metrics::band_energy(&avg, |bin| spec.bin_hz(bin), band.low_hz, band.high_hz);
        let pct = 100.0 * e / total;
        let bar = "█".repeat((pct / 2.5).round() as usize);
        println!("    {:<18} {:5.1}%  {}", band.label, pct, bar);
    }
}

/// Mean magnitude spectrum across all STFT frames (the time-averaged spectrum).
fn average_frame(spec: &stft::Spectrogram) -> Vec<f32> {
    let bins = spec.bins();
    let mut avg = vec![0.0f32; bins];
    if spec.frames.is_empty() {
        return avg;
    }
    for frame in &spec.frames {
        for (bin, a) in avg.iter_mut().enumerate() {
            *a += frame[bin];
        }
    }
    let n = spec.frames.len() as f32;
    for a in &mut avg {
        *a /= n;
    }
    avg
}

/// A trivial `--flag value` / `--flag` (boolean) parser. Positional args are
/// everything not consumed as a flag value. Keeps the binary dependency-free.
struct Flags {
    map: HashMap<String, String>,
    bools: Vec<String>,
}

impl Flags {
    fn get(&self, key: &str) -> Option<&String> {
        self.map.get(key)
    }
    fn present(&self, key: &str) -> bool {
        self.bools.iter().any(|b| b == key) || self.map.contains_key(key)
    }
    fn f32_or(&self, key: &str, default: f32) -> f32 {
        self.map
            .get(key)
            .and_then(|v| v.parse().ok())
            .unwrap_or(default)
    }
    fn usize_or(&self, key: &str, default: usize) -> usize {
        self.map
            .get(key)
            .and_then(|v| v.parse().ok())
            .unwrap_or(default)
    }
    fn u64_or(&self, key: &str, default: u64) -> u64 {
        self.map
            .get(key)
            .and_then(|v| v.parse().ok())
            .unwrap_or(default)
    }
}

/// Splits args into positionals and flags. A token like `--width`/`-o` followed
/// by a non-flag token captures that token as its value; otherwise it's a bool.
fn parse_args(args: &[String]) -> (Vec<String>, Flags) {
    let mut positional = Vec::new();
    let mut map = HashMap::new();
    let mut bools = Vec::new();
    let mut i = 0;
    while i < args.len() {
        let tok = &args[i];
        if let Some(key) = tok.strip_prefix("--").or_else(|| tok.strip_prefix('-')) {
            let next_is_value = args.get(i + 1).is_some_and(|n| !n.starts_with('-'));
            if next_is_value {
                map.insert(key.to_string(), args[i + 1].clone());
                i += 2;
            } else {
                bools.push(key.to_string());
                i += 1;
            }
        } else {
            positional.push(tok.clone());
            i += 1;
        }
    }
    (positional, Flags { map, bools })
}
