//! Renders a [`Spectrogram`] to a string for the terminal.
//!
//! The trick that gives real resolution in a text grid: each character cell is
//! the Unicode upper-half block `▀` with a 24-bit-colour **foreground** (top
//! pixel) and **background** (bottom pixel), so one row of text shows two
//! frequency bins. Magnitudes are mapped to dB against the loudest cell and
//! coloured with a magma-style ramp (dark = quiet, bright = loud). An ASCII
//! fallback ramp is available for non-truecolor / piped output.

use super::stft::Spectrogram;

/// Rendering options for [`render`].
pub struct RenderOpts {
    /// Output width in character columns (time axis).
    pub width: usize,
    /// Output height in character rows; vertical resolution is `2×height` bins.
    pub height: usize,
    /// Dynamic-range floor in dB below the loudest cell (e.g. `-80.0`).
    pub floor_db: f32,
    /// Top of the frequency axis in Hz (clamped to Nyquist).
    pub max_hz: f32,
    /// Use a plain-ASCII brightness ramp instead of 24-bit colour blocks.
    pub ascii: bool,
}

impl Default for RenderOpts {
    fn default() -> Self {
        RenderOpts {
            width: 100,
            height: 30,
            floor_db: -80.0,
            max_hz: 12_000.0,
            ascii: false,
        }
    }
}

const RESET: &str = "\x1b[0m";
/// Brightness ramp (dark→bright) for the ASCII fallback.
const ASCII_RAMP: &[u8] = b" .:-=+*#%@";

/// Renders `spec` to a multi-line string: a header, the frequency-labelled
/// spectrogram body, a time axis, and a dB colour legend.
pub fn render(spec: &Spectrogram, opts: &RenderOpts) -> String {
    if spec.frames.is_empty() {
        return "(empty signal — nothing to plot)\n".to_string();
    }

    let nyquist = spec.sample_rate as f32 / 2.0;
    let max_hz = opts.max_hz.min(nyquist).max(spec.bin_hz(1));
    // Number of bins from DC up to max_hz (at least 2 so we can aggregate).
    let bins_used = (0..spec.bins())
        .take_while(|&b| spec.bin_hz(b) <= max_hz)
        .count()
        .max(2);

    let n_frames = spec.frames.len();
    let width = opts.width.clamp(1, n_frames);
    let rows = opts.height.max(1) * 2; // 2 frequency pixels per character row

    // Stage 1: aggregate frames into `width` time columns (mean magnitude/bin).
    let mut columns: Vec<Vec<f32>> = Vec::with_capacity(width);
    for c in 0..width {
        let f_lo = c * n_frames / width;
        let f_hi = ((c + 1) * n_frames / width).max(f_lo + 1).min(n_frames);
        let span = (f_hi - f_lo) as f32;
        let mut acc = vec![0.0f32; bins_used];
        for f in f_lo..f_hi {
            let frame = &spec.frames[f];
            for (bin, a) in acc.iter_mut().enumerate() {
                *a += frame[bin];
            }
        }
        for a in &mut acc {
            *a /= span;
        }
        columns.push(acc);
    }

    // Stage 2: aggregate each column's bins into `rows` vertical pixels.
    // grid[col][pixel], pixel 0 = lowest freq.
    let mut grid: Vec<Vec<f32>> = Vec::with_capacity(width);
    let mut max_mag = 1e-9f32;
    for col in &columns {
        let mut pixels = vec![0.0f32; rows];
        for (p, px) in pixels.iter_mut().enumerate() {
            let b_lo = p * bins_used / rows;
            let b_hi = ((p + 1) * bins_used / rows).max(b_lo + 1).min(bins_used);
            let mean = col[b_lo..b_hi].iter().copied().sum::<f32>() / (b_hi - b_lo) as f32;
            *px = mean;
            max_mag = max_mag.max(mean);
        }
        grid.push(pixels);
    }

    // Stage 3: draw. Char row r (0 = top) shows pixels (top=high freq, bottom).
    let mut out = String::new();
    out.push_str(&format!(
        "Spectrogram  {:.0} Hz–{:.0} Hz  ·  {:.2}s  ·  {} frames, fft {}, hop {}\n",
        0.0,
        max_hz,
        spec.frame_time(n_frames),
        n_frames,
        spec.fft_size,
        spec.hop,
    ));

    let label_every = (opts.height.max(1) / 6).max(1);
    for r in 0..opts.height {
        let top_pixel = rows - 1 - 2 * r;
        let bot_pixel = rows.saturating_sub(2 + 2 * r);
        // Frequency label for this char row (use the top pixel's centre freq).
        let label = if r % label_every == 0 {
            let frac = top_pixel as f32 / (rows - 1) as f32;
            format!("{:>6.0}", frac * max_hz)
        } else {
            "      ".to_string()
        };
        out.push_str(&label);
        out.push_str(" \u{2502}"); // " │"
        for col in &grid {
            let top = norm_db(col[top_pixel], max_mag, opts.floor_db);
            let bot = norm_db(col[bot_pixel], max_mag, opts.floor_db);
            push_cell(&mut out, top, bot, opts.ascii);
        }
        if !opts.ascii {
            out.push_str(RESET);
        }
        out.push('\n');
    }

    // Time axis.
    out.push_str("       \u{2514}"); // "       └"
    out.push_str(&"\u{2500}".repeat(width));
    out.push('\n');
    out.push_str(&format!(
        "        0.00s{:>width$.2}s\n",
        spec.frame_time(n_frames),
        width = width.saturating_sub(5),
    ));

    out.push_str(&legend(opts));
    out
}

/// Maps a linear magnitude to `[0,1]` over `[floor_db, 0]` relative to `max_mag`.
fn norm_db(mag: f32, max_mag: f32, floor_db: f32) -> f32 {
    if mag <= 1e-9 {
        return 0.0;
    }
    let db = 20.0 * (mag / max_mag).log10();
    ((db - floor_db) / -floor_db).clamp(0.0, 1.0)
}

/// Appends one character cell (two stacked pixels) to `out`.
fn push_cell(out: &mut String, top: f32, bot: f32, ascii: bool) {
    if ascii {
        // One pixel per cell in ASCII mode (use the brighter of the pair).
        let v = top.max(bot);
        let idx = ((v * (ASCII_RAMP.len() - 1) as f32).round() as usize).min(ASCII_RAMP.len() - 1);
        out.push(ASCII_RAMP[idx] as char);
    } else {
        let (tr, tg, tb) = magma(top);
        let (br, bg, bb) = magma(bot);
        // fg = top pixel, bg = bottom pixel, glyph = upper half block.
        out.push_str(&format!(
            "\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m\u{2580}"
        ));
    }
}

/// A magma-style colormap: `t` in `[0,1]` → `(r,g,b)`. Piecewise-linear through
/// control colours sampled from matplotlib's `magma`.
fn magma(t: f32) -> (u8, u8, u8) {
    const STOPS: &[(f32, (f32, f32, f32))] = &[
        (0.0, (0.0, 0.0, 4.0)),
        (0.25, (80.0, 18.0, 123.0)),
        (0.5, (182.0, 54.0, 121.0)),
        (0.75, (252.0, 136.0, 97.0)),
        (1.0, (252.0, 253.0, 191.0)),
    ];
    let t = t.clamp(0.0, 1.0);
    for win in STOPS.windows(2) {
        let (t0, c0) = win[0];
        let (t1, c1) = win[1];
        if t <= t1 {
            let f = if t1 > t0 { (t - t0) / (t1 - t0) } else { 0.0 };
            return (
                (c0.0 + (c1.0 - c0.0) * f) as u8,
                (c0.1 + (c1.1 - c0.1) * f) as u8,
                (c0.2 + (c1.2 - c0.2) * f) as u8,
            );
        }
    }
    (252, 253, 191)
}

/// A horizontal dB colour legend strip.
fn legend(opts: &RenderOpts) -> String {
    let mut s = format!("  dB: {:.0}", opts.floor_db);
    let steps = 32;
    if opts.ascii {
        for i in 0..steps {
            let v = i as f32 / (steps - 1) as f32;
            let idx =
                ((v * (ASCII_RAMP.len() - 1) as f32).round() as usize).min(ASCII_RAMP.len() - 1);
            s.push(ASCII_RAMP[idx] as char);
        }
    } else {
        s.push(' ');
        for i in 0..steps {
            let v = i as f32 / (steps - 1) as f32;
            let (r, g, b) = magma(v);
            s.push_str(&format!("\x1b[48;2;{r};{g};{b}m "));
        }
        s.push_str(RESET);
    }
    s.push_str(" 0\n");
    s
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::dsp::{generators, stft};

    #[test]
    fn norm_db_maps_floor_and_peak() {
        // At max magnitude -> 0 dB -> top of range (1.0).
        assert!((norm_db(1.0, 1.0, -80.0) - 1.0).abs() < 1e-5);
        // Silence -> 0.0.
        assert_eq!(norm_db(0.0, 1.0, -80.0), 0.0);
        // -80 dB (mag 1e-4 of max) -> ~0.0 bottom of range.
        assert!(norm_db(1e-4, 1.0, -80.0) < 0.01);
    }

    #[test]
    fn magma_endpoints() {
        assert_eq!(magma(0.0), (0, 0, 4));
        assert_eq!(magma(1.0), (252, 253, 191));
    }

    #[test]
    fn render_produces_grid_of_expected_height() {
        let sig = generators::sine(2000.0, 0.8, 48_000, 48_000);
        let spec = stft::analyze(&sig, 48_000, 1024, 512);
        let opts = RenderOpts {
            width: 40,
            height: 10,
            ..Default::default()
        };
        let out = render(&spec, &opts);
        // Header + 10 body rows + time axis (2) + legend = non-trivial.
        let lines = out.lines().count();
        assert!(lines >= 13, "expected >=13 lines, got {lines}");
        assert!(out.contains("Spectrogram"));
    }

    #[test]
    fn empty_spectrogram_renders_message() {
        let spec = stft::analyze(&[], 48_000, 512, 256);
        let out = render(&spec, &RenderOpts::default());
        assert!(out.contains("empty"));
    }
}
