josie / alder-tools

//! htop-style TUI front-end. Consumes `sensors::` snapshots on a ~250 ms
//! tick; never touches sysfs itself. Style per design.md: terminal-default
//! background, one-line htop meters, ANSI color classes only.

use std::path::Path;
use std::time::{Duration, Instant};

use crossterm::event::{self, Event, KeyCode};
use ratatui::{
    layout::Rect,
    style::{Color, Modifier, Style},
    text::{Line, Span},
    Frame,
};

use crate::config;
use crate::log::{self, VidLogger};
use crate::plot::{self, Ring};
use crate::sensors;

const PEAK_WINDOW: Duration = Duration::from_secs(5 * 60);
/// vCore shown Yellow when within this margin of the limit, Red over it.
const VCORE_MARGIN: f64 = 0.05;
/// Auto-scale headroom above the session peak, as a fraction.
const SCALE_MARGIN: f64 = 0.05;
/// Temp graph scale/meter max defaults from cfg.temp_max now.
/// Graphs need at least this much width (meter fallback below it).
const GRAPHS_MIN_WIDTH: u16 = 70;
/// Minimum interior trace rows per boxed graph.
const GRAPH_MIN_ROWS: u16 = 2;
/// Width at/below which core sections stack to one column.
const DUAL_COLUMN_MIN_WIDTH: u16 = 100;
/// Reserved left margin: y-axis labels + trace never intrude into the key
/// column at this offset (peak lines run from here to the box's left edge).
const KEY_RESERVE: u16 = 8;

pub struct App {
    cfg: config::Config,
    vcore: Option<f64>,
    vcore_peak: Option<f64>,
    /// (timestamp, volts) per-tick maxima inside the 5-min peak window
    peak_ring: Vec<(Instant, f64)>,
    /// Highest vCore ever seen this session (auto-scale anchor)
    vcore_all_time_peak: f64,
    package_watts: Option<f64>,
    /// Highest pkg watts ever seen this session (auto-scale anchor)
    watts_all_time_peak: f64,
    package_temp: Option<f64>,
    /// Highest package temp ever seen this session.
    temp_all_time_peak: f64,
    /// Per physical core: label (coretemp "Core N"), freq kHz, bar-max kHz
    /// (that core's cpuinfo_max_freq), temperature °C.
    cores: Vec<CoreRow>,
    last_pkg_energy: Option<(u64, Instant)>,
    /// Graph history (newest last) for the package-zone plots.
    vcore_graph: Ring,
    /// Requested SVID setpoint history, overlaid on the vCore plot (blue).
    /// Stays empty when MSR is unreadable (non-root) → overlay draws nothing.
    vid_graph: Ring,
    watts_graph: Ring,
    temp_graph: Ring,
    /// Peak clock (max freq across all cores per tick) graph history.
    clock_graph: Ring,
    /// Peak clock (max freq across all cores per tick) — current + peak.
    clock_khz: Option<u64>,
    /// Highest peak-clock ever seen this session (auto-scale anchor).
    clock_all_time_peak: u64,
    /// Settings pane open (F2); F10 commits+writes conf, Esc closes.
    settings_open: bool,
    /// Which settings row the cursor is on (index into SETTINGS_ROWS).
    settings_row: usize,
    /// Edit buffers (digit keys append, backspace pops; Enter applies).
    edit_buf: String,
    /// Last settings-input error (inline, cleared on next keypress).
    settings_error: Option<String>,
    /// MSR access route (root direct read, or setcap'd aldermon-msr helper),
    /// probed once at startup. None = VID skipped each poll, panel "VID n/a".
    msr_access: sensors::MsrAccess,
    /// Requested SVID setpoint this poll, max across logical CPUs (V).
    vid: Option<f64>,
    /// Per-logical-cpu raw IA32_PERF_STATUS, for the CSV row.
    vid_msr: Vec<Option<u64>>,
    /// --log: CSV sink at ./aldermon-vid.log (None = logging off).
    logger: Option<VidLogger>,
    /// Logical CPU count, fixes the logger's per-cpu column count.
    n_cpus: usize,
}

/// Settings-pane rows: (label, config field edited, unit).
const SETTINGS_ROWS: [&str; 11] = [
    "Poll period",
    "Graph window",
    "Graph scale",
    "vCore min",
    "vCore max",
    "Clock min",
    "Clock max",
    "Power min",
    "Power max",
    "Temp min",
    "Temp max",
];

fn setting_value(app: &App, row: usize) -> String {
    match row {
        0 => format!("{} ms", app.cfg.poll_ms),
        1 => {
            // Minutes when the window is a whole minute or more.
            let s = app.cfg.graph_secs;
            if s >= 60 && s.is_multiple_of(60) {
                format!("{} m", s / 60)
            } else {
                format!("{} s", s)
            }
        }
        2 => {
            if app.cfg.graph_scale_fixed {
                "fixed".to_string()
            } else {
                "auto".to_string()
            }
        }
        3 => fmt_volts(app.cfg.vcore_min),
        4 => fmt_volts(app.cfg.vcore_bar_max),
        5 => fmt_ghz(app.cfg.clock_min),
        6 => fmt_ghz(app.cfg.clock_bar_max),
        7 => format!("{:.1} W", app.cfg.power_min),
        8 => format!("{:.1} W", app.cfg.power_bar_max),
        9 => format!("{:.0} °C", app.cfg.temp_min),
        _ => format!("{:.0} °C", app.cfg.temp_max),
    }
}

fn fmt_volts(v: f64) -> String {
    format!("{v:.3} V")
}

/// Clock scale is stored in kHz; show/edit it in GHz.
fn fmt_ghz(khz: f64) -> String {
    format!("{:.2} GHz", khz / 1_000_000.0)
}

/// Window input accepts "90" (seconds) or "5m"/"2m30s" (minutes).
fn parse_window_secs(raw: &str) -> Result<u64, String> {
    let raw = raw.trim().to_ascii_lowercase();
    if let Some(mins) = raw.strip_suffix('m') {
        return mins
            .trim()
            .parse::<u64>()
            .map(|m| m * 60)
            .map_err(|_| format!("'{raw}' is not a number"));
    }
    if let Some((m, s)) = raw.split_once('m') {
        let s = s.strip_suffix('s').unwrap_or(s);
        let m: u64 = m
            .trim()
            .parse()
            .map_err(|_| format!("'{raw}' is not a number"))?;
        let s: u64 = s
            .trim()
            .parse()
            .map_err(|_| format!("'{raw}' is not a number"))?;
        return Ok(m * 60 + s);
    }
    raw.parse::<u64>()
        .map_err(|_| format!("'{raw}' is not a number"))
}

fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
    match row {
        0 => {
            let v: u64 = raw
                .parse()
                .map_err(|_| format!("'{}' is not a number", raw))?;
            if !(50..=10_000).contains(&v) {
                return Err("poll_ms must be 50..10000".into());
            }
            app.cfg.poll_ms = v;
            // Ring capacity is derived from graph_secs / poll_ms.
            app.resize_graphs();
        }
        1 => {
            let v = parse_window_secs(raw)?;
            if !(5..=3600).contains(&v) {
                return Err("window must be 5s..60m".into());
            }
            app.cfg.graph_secs = v;
            app.resize_graphs();
        }
        2 => {
            // Scale: toggle auto/fixed. Bare Enter toggles; also accept
            // typed words for discoverability.
            match raw.to_ascii_lowercase().as_str() {
                "auto" => app.cfg.graph_scale_fixed = false,
                "fixed" => app.cfg.graph_scale_fixed = true,
                "" => app.cfg.graph_scale_fixed = !app.cfg.graph_scale_fixed,
                _ => return Err("use auto|fixed (or Enter to toggle)".into()),
            }
        }
        3 => parse_scale(raw, 0.0..=app.cfg.vcore_bar_max, "V").map(|v| app.cfg.vcore_min = v)?,
        4 => parse_scale(raw, app.cfg.vcore_min.max(0.1)..=5.0, "V")
            .map(|v| app.cfg.vcore_bar_max = v)?,
        5 => parse_scale(raw, 0.0..=app.cfg.clock_bar_max / 1_000_000.0, "GHz")
            .map(|v| app.cfg.clock_min = v * 1_000_000.0)?,
        6 => {
            parse_scale(raw, 0.5..=10.0, "GHz").map(|v| app.cfg.clock_bar_max = v * 1_000_000.0)?
        }
        7 => parse_scale(raw, 0.0..=app.cfg.power_bar_max, "W").map(|v| app.cfg.power_min = v)?,
        8 => parse_scale(raw, app.cfg.power_min.max(1.0)..=500.0, "W")
            .map(|v| app.cfg.power_bar_max = v)?,
        9 => parse_scale(raw, 0.0..=app.cfg.temp_max, "°C").map(|v| app.cfg.temp_min = v)?,
        _ => parse_scale(raw, app.cfg.temp_min.max(10.0)..=150.0, "°C")
            .map(|v| app.cfg.temp_max = v)?,
    }
    Ok(())
}

/// Parse a scale bound typed in the settings pane; must land in `range`.
fn parse_scale(raw: &str, range: std::ops::RangeInclusive<f64>, unit: &str) -> Result<f64, String> {
    let v: f64 = raw
        .trim()
        .parse()
        .map_err(|_| format!("'{raw}' is not a number"))?;
    if !range.contains(&v) {
        return Err(format!("out of range ({unit})"));
    }
    Ok(v)
}

/// Ring size = graph window at the poll period, clamped to 512..=20000.
fn ring_capacity(cfg: &config::Config) -> usize {
    ((cfg.graph_secs * 1000 / cfg.poll_ms) as usize).clamp(512, 20_000)
}

/// Ticks (samples) shown in a plot for the configured window.
fn graph_ticks(cfg: &config::Config) -> usize {
    (cfg.graph_secs * 1000 / cfg.poll_ms).max(1) as usize
}

/// Bar max = static default, or last peak + margin once the peak exceeds it.
/// The margin keeps the value text off the bar's right edge at the peak.
fn dynamic_max(peak: f64, static_max: f64, margin: f64) -> f64 {
    (peak * (1.0 + margin)).max(static_max)
}

/// One physical core's row in the per-core section.
#[derive(Clone)]
struct CoreRow {
    label: String,
    e_core: bool,
    freq_khz: Option<u64>,
    freq_min_khz: u64,
    freq_max_khz: u64,
    temp: Option<f64>,
}

impl App {
    pub fn new(logger: Option<VidLogger>, n_cpus: usize) -> App {
        let cfg = config::load();
        let cap = ring_capacity(&cfg);
        // Probe MSR once: root direct read, or the setcap'd aldermon-msr
        // helper. Avoids a syscall + fork/exec every poll.
        let msr_access = sensors::probe_msr();
        App {
            cfg,
            vcore: None,
            vcore_peak: None,
            peak_ring: Vec::new(),
            vcore_all_time_peak: 0.0,
            package_watts: None,
            watts_all_time_peak: 0.0,
            package_temp: None,
            temp_all_time_peak: 0.0,
            cores: Vec::new(),
            last_pkg_energy: None,
            vcore_graph: Ring::new(cap),
            vid_graph: Ring::new(cap),
            watts_graph: Ring::new(cap),
            temp_graph: Ring::new(cap),
            clock_graph: Ring::new(cap),
            clock_khz: None,
            clock_all_time_peak: 0,
            settings_open: false,
            settings_row: 0,
            edit_buf: String::new(),
            settings_error: None,
            msr_access,
            vid: None,
            vid_msr: Vec::new(),
            logger,
            n_cpus,
        }
    }

    /// Rebuild the graph rings at the capacity implied by the current cfg.
    /// Called whenever poll_ms or graph_secs change — the old sampling grid
    /// is stale at the new period, so history is dropped.
    fn resize_graphs(&mut self) {
        let cap = ring_capacity(&self.cfg);
        self.vcore_graph = Ring::new(cap);
        self.vid_graph = Ring::new(cap);
        self.watts_graph = Ring::new(cap);
        self.temp_graph = Ring::new(cap);
        self.clock_graph = Ring::new(cap);
    }

    fn poll(&mut self) {
        self.vcore = sensors::sio_vcore();
        if self.msr_access.usable() {
            self.vid_msr = sensors::vid_msrs(self.msr_access, self.n_cpus);
            self.vid = sensors::vid_max(&self.vid_msr);
        }
        if let Some(v) = self.vcore {
            self.vcore_graph.push(v);
            // Push VID (or NaN on a transient miss) every vcore tick so the
            // two rings stay column-aligned; NaN renders as a gap and is
            // ignored by the scale folds.
            self.vid_graph.push(self.vid.unwrap_or(f64::NAN));
            let now = Instant::now();
            self.peak_ring.push((now, v));
            self.peak_ring
                .retain(|(t, _)| now.duration_since(*t) <= PEAK_WINDOW);
            self.vcore_peak = Some(
                self.peak_ring
                    .iter()
                    .map(|(_, v)| *v)
                    .fold(f64::MIN, f64::max),
            );
            self.vcore_all_time_peak = self.vcore_all_time_peak.max(v);
        }

        if let Some(chip) = sensors::discover_chips()
            .into_iter()
            .find(|c| c.name == "coretemp")
        {
            let temps = sensors::temperatures(&chip);
            self.package_temp = temps
                .iter()
                .find(|(l, _)| l.contains("Package"))
                .map(|(_, v)| *v);
            if let Some(t) = self.package_temp {
                self.temp_graph.push(t);
                self.temp_all_time_peak = self.temp_all_time_peak.max(t);
            }

            // Per physical core: coretemp label "Core N" N == topology
            // core_id; cpufreq is per logical cpu, so a core's speed is the
            // max across its HT siblings. Verified mapping for the 12600KF:
            // P core_ids 0/4/8/12/16/20 → cpus (0,1)..(10,11), E 28..31 →
            // cpu12..15 (no HT).
            let freqs = sensors::cpu_frequencies();
            self.cores = temps
                .iter()
                .filter(|(l, _)| l.starts_with("Core "))
                .map(|(label, t)| {
                    let core_id: u64 = label
                        .strip_prefix("Core ")
                        .and_then(|d| d.parse().ok())
                        .unwrap_or(u64::MAX);
                    let cpus = core_to_cpus(core_id);
                    let e_core = cpus.first().is_some_and(|c| *c >= 12);
                    let freq_khz = cpus
                        .iter()
                        .filter_map(|c| freqs.iter().find(|(cpu, _)| cpu == c).map(|(_, k)| *k))
                        .max();
                    let freq_max_khz = cpus
                        .iter()
                        .filter_map(|c| sensors::cpu_max_freq(*c))
                        .max()
                        .unwrap_or(0);
                    let freq_min_khz = cpus
                        .iter()
                        .filter_map(|c| sensors::cpu_min_freq(*c))
                        .max()
                        .unwrap_or(0);
                    CoreRow {
                        label: label.clone(),
                        e_core,
                        freq_khz,
                        freq_min_khz,
                        freq_max_khz,
                        temp: Some(*t),
                    }
                })
                .collect();
            self.cores
                .sort_by_key(|c| (c.e_core, c.label[5..].parse::<u64>().unwrap_or(u64::MAX)));

            // Peak clock: max freq across all cores this tick (kHz).
            if let Some(peak_khz) = self.cores.iter().filter_map(|c| c.freq_khz).max() {
                self.clock_graph.push(peak_khz as f64);
                self.clock_khz = Some(peak_khz);
                self.clock_all_time_peak = self.clock_all_time_peak.max(peak_khz);
            }
        }

        // Watts = energy delta over the tick (poll cadence IS the sample
        // interval; no extra sleep like the dump's fixed 500 ms window).
        if let Some(d) = sensors::rapl_domains()
            .iter()
            .find(|d| d.name.starts_with("package"))
        {
            if let Some(e) = sensors::rapl_energy_uj(&d.id) {
                self.last_pkg_energy = match self.last_pkg_energy {
                    Some((prev, t0)) => {
                        let dt = t0.elapsed().as_secs_f64();
                        if dt > 0.0 && e >= prev {
                            let w = (e - prev) as f64 / 1_000_000.0 / dt;
                            self.package_watts = Some(w);
                            self.watts_graph.push(w);
                            if w.is_finite() {
                                self.watts_all_time_peak = self.watts_all_time_peak.max(w);
                            }
                        }
                        Some((e, Instant::now()))
                    }
                    None => Some((e, Instant::now())),
                };
            }
        }

        // Append this poll's cross-check row (--log), after all sensors for
        // the tick have been read so the CSV and the display agree.
        if let Some(logger) = self.logger.as_mut() {
            let freqs: Vec<Option<u64>> = {
                let mut out = vec![None; self.n_cpus];
                for (cpu, khz) in sensors::cpu_frequencies() {
                    if let Some(slot) = out.get_mut(cpu) {
                        *slot = Some(khz);
                    }
                }
                out
            };
            let sample = log::Sample {
                sio_vcore: self.vcore,
                vid_max: self.vid,
                pkg_watts: self.package_watts,
                vid_msr: self.vid_msr.clone(),
                freq_khz: freqs,
            };
            if let Err(e) = logger.log(&sample) {
                self.settings_error = Some(format!("log write failed: {e}"));
            }
        }
    }
}

/// Verified topology mapping for the 12600KF: physical core N with HT has
/// logical cpus (2k, 2k+1); P core_ids 0/4/8/12/16/20 map to pairs starting
/// at core_id/2·2. E-cores 28..31 map 1:1 to cpu12..15.
fn core_to_cpus(core_id: u64) -> Vec<usize> {
    match core_id {
        0 => vec![0, 1],
        4 => vec![2, 3],
        8 => vec![4, 5],
        12 => vec![6, 7],
        16 => vec![8, 9],
        20 => vec![10, 11],
        28..=31 => vec![core_id as usize - 16],
        _ => Vec::new(),
    }
}

fn temp_color(t: f64, warn: f64, crit: f64) -> Color {
    if t >= crit {
        Color::Red
    } else if t >= warn {
        Color::Yellow
    } else {
        Color::Green
    }
}

fn vcore_color(v: f64, limit: f64) -> Color {
    if v >= limit {
        Color::Red
    } else if v >= limit - VCORE_MARGIN {
        Color::Yellow
    } else {
        Color::Green
    }
}

/// One htop-style meter line: `Label [|||||  1.152 V  ]`.
/// `ratio` is fill fraction (None → empty, sensor missing). `marker_at`
/// (fraction) draws a bold `!` at that bar position — the vCore limit.
fn meter_line(
    label: &str,
    value_text: &str,
    ratio: Option<f64>,
    color: Color,
    width: u16,
    marker_at: Option<f64>,
) -> Line<'static> {
    let cap_w = 12usize;
    let inner = width.saturating_sub(cap_w as u16 + 2) as usize; // room for [ ]
    let mut spans = vec![Span::styled(
        format!("{:<w$}", label, w = cap_w),
        Style::default().fg(Color::Cyan),
    )];

    let ratio = ratio.unwrap_or(0.0).clamp(0.0, 1.0);
    let fill = (ratio * inner as f64).round() as usize;
    let marker_pos = marker_at
        .map(|m| ((m.clamp(0.0, 1.0)) * inner as f64).round() as usize)
        .map(|p| p.min(inner.saturating_sub(1)));

    // Value text right-aligned inside the bar (htop pattern); bold default fg.
    let text_len = value_text.chars().count();
    let text: Vec<char> = if text_len >= inner {
        value_text
            .chars()
            .rev()
            .take(inner)
            .collect::<Vec<_>>()
            .into_iter()
            .rev()
            .collect()
    } else {
        let pad = inner - text_len;
        std::iter::repeat_n(' ', pad)
            .chain(value_text.chars())
            .collect()
    };

    spans.push(Span::raw("["));
    for (i, &tc) in text.iter().enumerate().take(inner) {
        // marker needs a blank cell underneath; never eat the value text
        let is_marker = Some(i) == marker_pos && tc == ' ';
        let bg_ch = if i < fill { '|' } else { ' ' };
        let (ch, style) = if is_marker {
            (
                '!',
                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
            )
        } else if tc != ' ' {
            (tc, Style::default().add_modifier(Modifier::BOLD))
        } else if i < fill {
            (bg_ch, Style::default().fg(color))
        } else {
            (' ', Style::default())
        };
        spans.push(Span::styled(ch.to_string(), style));
    }
    spans.push(Span::raw("]"));
    Line::from(spans)
}

/// vCore panel text: delivered (SIO in0), requested SVID setpoint, and the
/// delta (delivered − requested; negative = VRM droop, positive = LLC
/// overshoot). VID needs MSR access (root, or the setcap'd aldermon-msr
/// helper); `msr_readable` distinguishes "no access" from a transient miss.
fn hero_text(vcore: Option<f64>, vid: Option<f64>, msr_readable: bool) -> String {
    let base = match vcore {
        Some(v) => format!("{v:.3} V"),
        None => "n/a".to_string(),
    };
    match (vid, vcore) {
        (Some(ask), Some(got)) => format!("{base}  VID {ask:.3}  Δ{:+.3}", got - ask),
        (Some(ask), None) => format!("{base}  VID {ask:.3}"),
        (None, _) if msr_readable => format!("{base}  VID n/a"),
        (None, _) => format!("{base}  VID n/a (no msr access)"),
    }
}

/// Key-row spans for a plot caption: cyan label + bold live value. When the
/// plot has an overlay AND the value carries a numeric `VID <x>` token, that
/// token is tinted the overlay's trace color (blue) so the second line needs
/// no separate legend; a non-numeric `VID n/a` stays default (nothing drawn).
fn key_spans(label: &str, live: &str, overlay_color: Option<Color>) -> Vec<Span<'static>> {
    let bold = Style::default().add_modifier(Modifier::BOLD);
    let mut spans = vec![Span::styled(
        format!("{label}  "),
        Style::default().fg(Color::Cyan),
    )];
    if let Some(color) = overlay_color {
        if let Some(pos) = live.find("VID ") {
            if live[pos + 4..].starts_with(|c: char| c.is_ascii_digit()) {
                let end = live[pos..]
                    .find("  ")
                    .map(|e| pos + e)
                    .unwrap_or(live.len());
                spans.push(Span::styled(live[..pos].to_string(), bold));
                spans.push(Span::styled(
                    live[pos..end].to_string(),
                    Style::default().fg(color).add_modifier(Modifier::BOLD),
                ));
                spans.push(Span::styled(live[end..].to_string(), bold));
                return spans;
            }
        }
    }
    spans.push(Span::styled(live.to_string(), bold));
    spans
}

fn fmt_opt_watts(v: Option<f64>) -> String {
    match v {
        Some(w) => format!("{:.1} W", w),
        None => "unreadable (root)".to_string(),
    }
}

fn fmt_opt_temp(v: Option<f64>) -> String {
    match v {
        Some(t) => format!("{:.0} °C", t),
        None => "n/a".to_string(),
    }
}

/// Build a core-column's lines: section caption + one meter per core with
/// a blank separator after. Width adapts to the column rect.
fn core_section_lines(title: &str, cores: &[CoreRow], app: &App, width: u16) -> Vec<Line<'static>> {
    let mut lines = vec![Line::from(Span::styled(
        title.to_string(),
        Style::default().fg(Color::Cyan),
    ))];
    for core in cores {
        let text = match (core.freq_khz, core.temp) {
            (Some(f), Some(t)) => format!("{:.2}GHz  {:.0}°C", f as f64 / 1_000_000.0, t),
            (Some(f), None) => format!("{:.2}GHz", f as f64 / 1_000_000.0),
            (None, Some(t)) => format!("{:.0}°C", t),
            (None, None) => "n/a".to_string(),
        };
        // Bar spans the cpu's own [min_freq, max_freq] window so the
        // 800 MHz idle floor sits at 0% and turbo pegs 100%.
        let ratio = match (core.freq_khz, core.freq_max_khz) {
            (Some(f), max) if max > core.freq_min_khz => Some(
                ((f as f64 - core.freq_min_khz as f64) / (max - core.freq_min_khz) as f64)
                    .clamp(0.0, 1.0),
            ),
            _ => None,
        };
        let color = match core.temp {
            Some(t) => temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit),
            None => Color::DarkGray,
        };
        lines.push(meter_line(&core.label, &text, ratio, color, width, None));
    }
    lines.push(Line::from(""));
    lines
}

/// Render one meter line inside a column rect (render_widget per line row).
fn render_lines(f: &mut Frame, area: Rect, lines: &[Line<'static>]) {
    for (i, line) in lines.iter().enumerate() {
        if (i as u16) >= area.height {
            break;
        }
        f.render_widget(
            ratatui::text::Text::from(line.clone()),
            Rect {
                x: area.x,
                y: area.y + i as u16,
                width: area.width,
                height: 1,
            },
        );
    }
}

/// One stacked plot. `axis_*` bounds are RAW (shown in the axis labels —
/// the user's conf/window values); `trace_*` bounds are PADDED (what the
/// renderer maps the trace over). `axis_div` is the display divisor —
/// clock shows GHz on the axis but stores kHz.
struct PlotSpec<'a> {
    label: &'a str,
    live: String,
    peak: String,
    ring: &'a Ring,
    /// Second series drawn over the same bounds (color: VID blue on vCore).
    overlay: Option<(&'a Ring, Color)>,
    axis_min: f64,
    axis_max: f64,
    trace_min: f64,
    trace_max: f64,
    color: Color,
    marker: Option<u16>,
    axis_div: f64,
}

pub fn draw(f: &mut Frame, app: &App) {
    let area = f.area();
    let w = area.width;
    let dual = match app.cfg.layout {
        config::Layout::Single => false,
        config::Layout::Dual => true,
        config::Layout::Auto => w >= DUAL_COLUMN_MIN_WIDTH,
    };

    // ---- vertical budget: cores keep what they need, plots flex ----
    // Full readout needs caption + core rows + blank (8 dual / 12 single).
    // Too short for full cores AND min-height plots → cores collapse to
    // one meter per class; still too short → plots drop to 1-row traces,
    // then to the meter fallback when even that won't fit.
    let footer_h = 1u16;
    let graphs_on = app.cfg.graphs && w >= GRAPHS_MIN_WIDTH;
    let cores_full: u16 = if dual { 8 } else { 12 };
    let compact_h: u16 = if dual { 2 } else { 3 }; // meter row(s) + blank
    let plots_min: u16 = 4 * (GRAPH_MIN_ROWS + 3) + 1; // +separator row
    let (cores_h, graph_rows) = if !graphs_on {
        (area.height.saturating_sub(3 + footer_h), 0)
    } else if area.height >= cores_full + plots_min + footer_h {
        let leftover = area.height - cores_full - footer_h - 1;
        (
            cores_full,
            (leftover / 4).saturating_sub(3).max(GRAPH_MIN_ROWS),
        )
    } else if area.height >= compact_h + plots_min + footer_h {
        (compact_h, GRAPH_MIN_ROWS)
    } else {
        (
            compact_h,
            (area.height.saturating_sub(compact_h + footer_h + 1) / 4).saturating_sub(3),
        )
    };
    let box_h = graph_rows + 2;
    let plot_h = graph_rows + 3; // box + key row above
    let bottom_h: u16 = if graph_rows >= 1 {
        4 * plot_h
    } else {
        2 // pkg power + pkg temp meter rows
    };
    let bottom_h = bottom_h + 1; // blank separator above footer
    let cores_area = Rect {
        height: cores_h,
        ..area
    };
    let bottom_y = area.y + cores_h;
    let footer_y = area.y + area.height.saturating_sub(footer_h);

    // ---- cores zone: P column | E column, or single column ----
    // Shorter than the full readout → one meter per class (max across
    // that class's cores) instead of clipped per-core rows.
    let p: Vec<CoreRow> = app.cores.iter().filter(|c| !c.e_core).cloned().collect();
    let e: Vec<CoreRow> = app.cores.iter().filter(|c| c.e_core).cloned().collect();
    let (col_w, n_cols) = if dual { ((w / 2).max(1), 2) } else { (w, 1) };
    if graphs_on && cores_h <= compact_h {
        // Compact: "P-cores"/"E-cores" meters show the busiest core of each
        // class (freq max; temp max for the safety color).
        let mut compact = |title: &str, cores: &[CoreRow], rect: Rect| {
            if cores.is_empty() || rect.width == 0 {
                return;
            }
            let freq = cores.iter().filter_map(|c| c.freq_khz).max();
            let temp = cores.iter().filter_map(|c| c.temp).reduce(f64::max);
            let text = match (freq, temp) {
                (Some(fq), Some(t)) => format!("{:.2}GHz  {:.0}°C", fq as f64 / 1_000_000.0, t),
                (Some(fq), None) => format!("{:.2}GHz", fq as f64 / 1_000_000.0),
                (None, Some(t)) => format!("{:.0}°C", t),
                (None, None) => "n/a".to_string(),
            };
            let fmax = cores.iter().map(|c| c.freq_max_khz).max().unwrap_or(0);
            let fmin = cores.iter().map(|c| c.freq_min_khz).max().unwrap_or(0);
            let ratio = match freq {
                Some(fq) if fmax > fmin => {
                    Some(((fq as f64 - fmin as f64) / (fmax - fmin) as f64).clamp(0.0, 1.0))
                }
                _ => None,
            };
            let color = match temp {
                Some(t) => temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit),
                None => Color::DarkGray,
            };
            render_lines(
                f,
                rect,
                &[meter_line(title, &text, ratio, color, rect.width, None)],
            );
        };
        compact(
            "P-cores",
            &p,
            Rect {
                width: col_w,
                ..cores_area
            },
        );
        if n_cols == 2 {
            compact(
                "E-cores",
                &e,
                Rect {
                    x: area.x + col_w,
                    width: w - col_w,
                    ..cores_area
                },
            );
        }
    } else {
        let sections = [
            (
                "P-cores",
                p,
                Rect {
                    width: col_w,
                    ..cores_area
                },
            ),
            (
                "E-cores",
                e,
                Rect {
                    x: area.x + col_w,
                    width: w - col_w,
                    ..cores_area
                },
            ),
        ];
        for (i, (title, cores, rect)) in sections.into_iter().enumerate() {
            if i >= n_cols || rect.width == 0 {
                continue;
            }
            let lines = core_section_lines(title, &cores, app, rect.width);
            render_lines(f, rect, &lines);
        }
    }

    // ---- package zone (bottom) ----
    let bottom_area = Rect {
        y: bottom_y,
        height: bottom_h,
        ..area
    };
    let vc_max = dynamic_max(app.vcore_all_time_peak, app.cfg.vcore_bar_max, SCALE_MARGIN);
    let watt_max = dynamic_max(app.watts_all_time_peak, app.cfg.power_bar_max, SCALE_MARGIN);
    let vc_color = match app.vcore {
        Some(v) => vcore_color(v, app.cfg.vcore_limit),
        None => Color::DarkGray,
    };

    let hero_text = hero_text(app.vcore, app.vid, app.msr_access.usable());
    let hero_peak = match app.vcore_peak {
        Some(p) => format!("peak {p:.3}"),
        None => String::new(),
    };
    let clock_text = match app.clock_khz {
        Some(k) => format!("{:.2} GHz", k as f64 / 1_000_000.0),
        None => "n/a".to_string(),
    };
    let clock_peak = if app.clock_all_time_peak > 0 {
        format!("peak {:.2}", app.clock_all_time_peak as f64 / 1_000_000.0)
    } else {
        String::new()
    };
    let watts_peak = if app.watts_graph.peak() > f64::MIN {
        format!("peak {:.1}", app.watts_graph.peak())
    } else {
        String::new()
    };
    let temp_peak = if app.temp_all_time_peak > 0.0 {
        format!("peak {:.0}", app.temp_all_time_peak)
    } else {
        String::new()
    };

    if graph_rows >= 1 && !app.settings_open {
        // Graphs sit in light-grey boxes; they skip rendering while the
        // settings pane is open (the pane owns the screen). The plot key
        // (label + live value) is left-aligned in the gutter. The trace is
        // braille sub-pixel, 1:1 (one sample per sub-col, 2 per cell),
        // right-pinned — NO decimation, NO hysteresis. Visible window =
        // sub_w × poll_ms (sub_w = 2 × inner_w); graph_secs sizes the
        // ring for peaks/scale only, NOT the visible span. Middle-of-trace
        // dots never change as the window scrolls (btop rule); only the
        // right edge wiggles as new samples arrive. The 4-level sub-row
        // quantization is the noise floor — small jitters map to the same
        // sub-row and don't move the dot.
        let ring_ticks = graph_ticks(&app.cfg); // peak/scale span (ring history)
        let ring_max = |ring: &Ring, floor: f64| -> f64 {
            (ring.max_window(ring_ticks) * (1.0 + SCALE_MARGIN)).max(floor)
        };
        // Fixed scale (settings pane / graph_scale_fixed): the min/max keys
        // below, never rescaled mid-benchmark — peaks clip at the top instead
        // of the whole trace compacting. Auto: window min/max + margin, so
        // the trace fills the interior vertically; bounds never collapse,
        // and one-row headroom at each edge keeps the line ~2 cells clear
        // of the box borders.
        let ring_min = |ring: &Ring| ring.min_window(ring_ticks);
        // Raw bounds: fixed = conf min/max, auto = window min/max +
        // SCALE_MARGIN. Used for the axis labels so they show the user's
        // conf values (not the padded trace bounds).
        let (vc_lo, vc_hi, ck_lo, ck_hi, pw_lo, pw_hi, tp_lo, tp_hi) = if app.cfg.graph_scale_fixed
        {
            (
                app.cfg.vcore_min,
                app.cfg.vcore_bar_max,
                app.cfg.clock_min,
                app.cfg.clock_bar_max,
                app.cfg.power_min,
                app.cfg.power_bar_max,
                app.cfg.temp_min,
                app.cfg.temp_max,
            )
        } else {
            // vCore + VID share one scale, so fold both rings' window bounds.
            let vc = (
                ring_min(&app.vcore_graph).min(ring_min(&app.vid_graph)),
                ring_max(&app.vcore_graph, 0.0).max(ring_max(&app.vid_graph, 0.0)),
            );
            let ck = (ring_min(&app.clock_graph), ring_max(&app.clock_graph, 0.0));
            let pw = (ring_min(&app.watts_graph), ring_max(&app.watts_graph, 0.0));
            let tp = (ring_min(&app.temp_graph), ring_max(&app.temp_graph, 0.0));
            (vc.0, vc.1, ck.0, ck.1, pw.0, pw.1, tp.0, tp.1)
        };
        // Padded bounds for the trace mapping: ~1/4 interior row top and
        // bottom (plus a small per-metric floor) so the trace stays just
        // clear of the box borders in both fixed and auto mode. Applied
        // to a COPY of the raw bounds; the raw bounds stay for axis labels.
        let pad = |lo: f64, hi: f64, floor: f64| -> (f64, f64) {
            let row_w = ((hi - lo) / (graph_rows.max(1) as f64) * 0.25).max(floor);
            ((lo - row_w).max(0.0), hi + row_w)
        };
        let (p_vc_lo, p_vc_hi) = pad(vc_lo, vc_hi, 0.02);
        let (p_ck_lo, p_ck_hi) = pad(ck_lo, ck_hi, 50_000.0);
        let (p_pw_lo, p_pw_hi) = pad(pw_lo, pw_hi, 1.0);
        let (p_tp_lo, p_tp_hi) = pad(tp_lo, tp_hi, 1.0);
        // 4 boxed plots stacked top→bottom, sharing one time axis.
        // Cascade: vCore → Peak clock → Pkg power → Pkg temp (user order).
        // vCore limit marker cell row (over the PADDED bounds, clamped so
        // an out-of-scale limit pins to the box edge).
        let vc_marker = ((1.0
            - ((app.cfg.vcore_limit - p_vc_lo) / (p_vc_hi - p_vc_lo)).clamp(0.0, 1.0))
            * (graph_rows - 1) as f64)
            .round() as u16;
        let temp_now = app.package_temp.map_or(Color::DarkGray, |t| {
            temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit)
        });
        let plots: [PlotSpec; 4] = [
            PlotSpec {
                label: "vCore",
                live: hero_text.clone(),
                peak: hero_peak,
                ring: &app.vcore_graph,
                overlay: Some((&app.vid_graph, Color::Blue)),
                axis_min: vc_lo,
                axis_max: vc_hi,
                trace_min: p_vc_lo,
                trace_max: p_vc_hi,
                color: vc_color,
                marker: Some(vc_marker),
                axis_div: 1.0,
            },
            PlotSpec {
                label: "Clock",
                live: clock_text,
                peak: clock_peak,
                ring: &app.clock_graph,
                overlay: None,
                axis_min: ck_lo,
                axis_max: ck_hi,
                trace_min: p_ck_lo,
                trace_max: p_ck_hi,
                color: Color::Green,
                marker: None,
                axis_div: 1_000_000.0,
            },
            PlotSpec {
                label: "Power",
                live: fmt_opt_watts(app.package_watts),
                peak: watts_peak,
                ring: &app.watts_graph,
                overlay: None,
                axis_min: pw_lo,
                axis_max: pw_hi,
                trace_min: p_pw_lo,
                trace_max: p_pw_hi,
                color: Color::Blue,
                marker: None,
                axis_div: 1.0,
            },
            PlotSpec {
                label: "Temp",
                live: fmt_opt_temp(app.package_temp),
                peak: temp_peak,
                ring: &app.temp_graph,
                overlay: None,
                axis_min: tp_lo,
                axis_max: tp_hi,
                trace_min: p_tp_lo,
                trace_max: p_tp_hi,
                color: temp_now,
                marker: None,
                axis_div: 1.0,
            },
        ];
        let mut y = bottom_y;
        for PlotSpec {
            label,
            live,
            peak,
            ring,
            overlay,
            axis_min,
            axis_max,
            trace_min,
            trace_max,
            color,
            marker,
            axis_div,
        } in plots
        {
            let axis_max = plot::fmt_axis(axis_max / axis_div);
            let axis_min = if axis_div > 1.0 {
                // GHz axis: always show decimals so a near-zero min isn't "0".
                format!("{:.1}", axis_min / axis_div)
            } else {
                plot::fmt_axis(axis_min / axis_div)
            };
            // Box spans the full width — no left gutter. Axis labels
            // overlay the top/bottom BORDER rows, just inside the ┌/└
            // corners (the braille trace lives in the interior, so the
            // border rows are free for labels). KEY_RESERVE still reserves
            // the left of the key row so the peak line stays clear of the
            // label text.
            let box_x = area.x;
            let box_w = w;
            let inner_w = box_w.saturating_sub(2);

            // Key row ABOVE the box (label + live value left-aligned at
            // col 0; session peak right-aligned against the trace's right
            // edge, never left of KEY_RESERVE). Single spans per style —
            // ratatui's line diff drops separator spans in narrow rects.
            // The overlay's key chunk (VID) is tinted like its trace, so
            // the blue line is legible without a legend.
            let key = Line::from(key_spans(label, &live, overlay.map(|(_, c)| c)));
            f.render_widget(
                ratatui::text::Text::from(key),
                Rect {
                    x: area.x,
                    y,
                    width: w,
                    height: 1,
                },
            );

            let box_rect = Rect {
                x: box_x,
                y: y + 1,
                width: box_w,
                height: box_h,
            };
            f.render_widget(
                ratatui::widgets::Block::default()
                    .borders(ratatui::widgets::Borders::ALL)
                    .border_style(Style::default().fg(Color::DarkGray)),
                box_rect,
            );
            let axis_st = Style::default().fg(Color::DarkGray);
            // Overlay max/min labels on the border rows, just inside the
            // left corner (after ┌ / └). First-blank-wins: border glyphs
            // are already rendered, so we overwrite the first few cells
            // inside the border with the label text.
            let max_w = axis_max.chars().count() as u16;
            let min_w = axis_min.chars().count() as u16;
            if max_w + 2 <= box_w {
                f.render_widget(
                    ratatui::text::Text::from(Line::from(Span::styled(axis_max.clone(), axis_st))),
                    Rect {
                        x: box_x + 2,
                        y: box_rect.y,
                        width: max_w,
                        height: 1,
                    },
                );
            }
            if min_w + 2 <= box_w {
                f.render_widget(
                    ratatui::text::Text::from(Line::from(Span::styled(axis_min.clone(), axis_st))),
                    Rect {
                        x: box_x + 2,
                        y: box_rect.y + box_h - 1,
                        width: min_w,
                        height: 1,
                    },
                );
            }
            let trace_cols = plot::render(
                f,
                Rect {
                    x: box_rect.x + 1,
                    y: box_rect.y + 1,
                    width: inner_w,
                    height: graph_rows,
                },
                ring,
                trace_min,
                trace_max,
                color,
                marker,
                overlay,
            );
            if !peak.is_empty() {
                let peak_w = peak.chars().count() as u16;
                let trace_right = box_rect.x + 1 + trace_cols.max(1);
                // Never call clamp with min > max (it panics): order the
                // bounds with saturating math first.
                let hi = box_rect.x + box_w.saturating_sub(peak_w);
                let lo = (area.x + KEY_RESERVE).min(hi);
                let peak_x = trace_right.saturating_sub(peak_w).max(lo).min(hi);
                f.render_widget(
                    ratatui::text::Text::from(Line::from(Span::styled(
                        peak,
                        Style::default().fg(Color::DarkGray),
                    ))),
                    Rect {
                        x: peak_x,
                        y,
                        width: peak_w,
                        height: 1,
                    },
                );
            }
            y += plot_h;
        }
    } else if !app.settings_open {
        let mut lines = vec![meter_line(
            "vCore",
            &hero_text,
            app.vcore.map(|v| v / vc_max),
            vc_color,
            w,
            Some(app.cfg.vcore_limit / vc_max),
        )];
        if !app.cfg.graphs || w < GRAPHS_MIN_WIDTH {
            lines.push(meter_line(
                "Pkg power",
                &fmt_opt_watts(app.package_watts),
                app.package_watts.map(|v| v / watt_max),
                Color::Blue,
                w,
                None,
            ));
            lines.push(meter_line(
                "Pkg temp",
                &fmt_opt_temp(app.package_temp),
                app.package_temp.map(|t| t / app.cfg.temp_max),
                app.package_temp.map_or(Color::DarkGray, |t| {
                    temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit)
                }),
                w,
                None,
            ));
            lines.push(Line::from(""));
        }
        render_lines(f, bottom_area, &lines);
    }

    // ---- footer (or settings pane overlay) ----
    if app.settings_open {
        draw_settings(f, area, app);
    } else {
        let footer = Line::from(vec![
            Span::styled("Max freq: ", Style::default().fg(Color::Cyan)),
            Span::raw(format!(
                "{:.2} GHz",
                app.cores
                    .iter()
                    .filter_map(|c| c.freq_khz)
                    .max()
                    .unwrap_or(0) as f64
                    / 1_000_000.0
            )),
            Span::styled(
                "    q: quit  F2: settings",
                Style::default().fg(Color::DarkGray),
            ),
        ]);
        f.render_widget(
            ratatui::text::Text::from(footer),
            Rect {
                y: footer_y,
                height: 1,
                ..area
            },
        );
    }
}

fn draw_settings(f: &mut Frame, area: Rect, app: &App) {
    let h = (SETTINGS_ROWS.len() as u16 + 4).min(area.height); // title + rows + hint + margin
    let w = 52u16.min(area.width);
    let y = area.height.saturating_sub(h + 1) / 2;
    let rect = Rect {
        x: (area.width - w) / 2,
        y,
        width: w,
        height: h,
    };

    f.render_widget(ratatui::widgets::Clear, rect); // blank the graphs behind
    let mut lines = vec![Line::from(Span::styled(
        "Settings",
        Style::default()
            .fg(Color::Cyan)
            .add_modifier(Modifier::BOLD),
    ))];
    for (i, label) in SETTINGS_ROWS.iter().enumerate() {
        let cursor = if i == app.settings_row { ">" } else { " " };
        let editing = app.settings_row == i && !app.edit_buf.is_empty();
        let value = if editing {
            format!("{}_", app.edit_buf)
        } else {
            setting_value(app, i)
        };
        let style = if i == app.settings_row {
            Style::default().add_modifier(Modifier::BOLD)
        } else {
            Style::default()
        };
        lines.push(Line::from(vec![
            Span::styled(
                format!("{} {:<14}", cursor, label),
                Style::default().fg(Color::Cyan),
            ),
            Span::styled(value, style),
        ]));
    }
    if let Some(err) = &app.settings_error {
        lines.push(Line::from(Span::styled(
            err.clone(),
            Style::default().fg(Color::Red),
        )));
    } else {
        lines.push(Line::from(""));
    }
    lines.push(Line::from(Span::styled(
        "↑↓ select  Enter edit/apply  F10 save  Esc close",
        Style::default().fg(Color::DarkGray),
    )));
    f.render_widget(ratatui::text::Text::from(lines), rect);
}

/// Settings-pane key handling. Returns Err(msg) shown inline on bad input.
fn settings_key(app: &mut App, key: KeyCode) -> Result<(), String> {
    match key {
        KeyCode::Up => {
            app.settings_row = app.settings_row.saturating_sub(1);
            app.edit_buf.clear();
        }
        KeyCode::Down => {
            app.settings_row = (app.settings_row + 1).min(SETTINGS_ROWS.len() - 1);
            app.edit_buf.clear();
        }
        KeyCode::Enter => {
            let raw = std::mem::take(&mut app.edit_buf);
            setting_absorb(app, app.settings_row, &raw)?;
        }
        KeyCode::Backspace => {
            app.edit_buf.pop();
        }
        KeyCode::Esc => {
            app.settings_open = false;
            app.edit_buf.clear();
        }
        KeyCode::Char(c) if c.is_ascii_digit() || matches!(c, '.' | 'm' | 's') => {
            app.edit_buf.push(c)
        }
        _ => {}
    }
    Ok(())
}

pub fn run_tui(log_enabled: bool) {
    let n_cpus = sensors::cpu_count();
    let logger = if log_enabled {
        match VidLogger::open(Path::new(crate::LOG_PATH), n_cpus) {
            Ok(l) => Some(l),
            Err(e) => {
                eprintln!("aldermon: cannot open {}: {e}", crate::LOG_PATH);
                std::process::exit(1);
            }
        }
    } else {
        None
    };
    let mut terminal = ratatui::init();
    let mut app = App::new(logger, n_cpus);
    loop {
        app.poll();
        terminal.draw(|f| draw(f, &app)).expect("draw failed");
        let poll_ms = app.cfg.poll_ms;
        if event::poll(Duration::from_millis(poll_ms)).unwrap_or(false) {
            if let Ok(Event::Key(key)) = event::read() {
                if key.kind != crossterm::event::KeyEventKind::Press {
                    continue;
                }
                match key.code {
                    KeyCode::F(2) => {
                        app.settings_open = true;
                        app.settings_error = None;
                        app.edit_buf.clear();
                    }
                    KeyCode::F(10) if app.settings_open => {
                        if let Err(e) = save_conf(&app) {
                            // Failed save keeps the pane open; error inline.
                            app.settings_error = Some(format!("save failed: {e}"));
                        } else {
                            app.settings_open = false;
                        }
                    }
                    KeyCode::Char('q') | KeyCode::Esc
                        if !app.settings_open || key.code == KeyCode::Esc =>
                    {
                        // Esc inside the pane closes the pane; otherwise quit.
                        if app.settings_open {
                            app.settings_open = false;
                            app.edit_buf.clear();
                        } else {
                            break;
                        }
                    }
                    code if app.settings_open => {
                        app.settings_error = None;
                        if let Err(e) = settings_key(&mut app, code) {
                            app.settings_error = Some(e);
                        }
                    }
                    _ => {}
                }
            }
        }
    }
    ratatui::restore();
}

/// Write the settings block back to the conf the app loaded (F10). No-op
/// when no file was loaded (defaults only).
fn save_conf(app: &App) -> Result<(), String> {
    if let Some(path) = &app.cfg.loaded_path {
        let text = std::fs::read_to_string(path).unwrap_or_default();
        let updated = config::update_conf_text(&text, &app.cfg);
        std::fs::write(path, updated).map_err(|e| format!("{}: {e}", path.display()))?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn key_spans_tints_only_the_numeric_vid_token() {
        let spans = key_spans("vCore", "1.016 V  VID 1.100  Δ-0.084", Some(Color::Blue));
        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
        assert_eq!(text, "vCore  1.016 V  VID 1.100  Δ-0.084");
        let blue: Vec<&str> = spans
            .iter()
            .filter(|s| s.style.fg == Some(Color::Blue))
            .map(|s| s.content.as_ref())
            .collect();
        assert_eq!(blue, ["VID 1.100"]);
    }

    #[test]
    fn key_spans_leaves_unreadable_vid_default() {
        // No numeric VID → no blue token (nothing is drawn for it).
        let spans = key_spans("vCore", "1.016 V  VID n/a", Some(Color::Blue));
        assert!(spans.iter().all(|s| s.style.fg != Some(Color::Blue)));
        // No overlay → plain caption, no tinting.
        let spans = key_spans("Power", "95.5 W", None);
        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
        assert_eq!(text, "Power  95.5 W");
    }

    #[test]
    fn hero_shows_vid_and_signed_delta() {
        // delivered 0.668, requested 0.821 → −153 mV (droop).
        let t = hero_text(Some(0.668), Some(0.821), true);
        assert_eq!(t, "0.668 V  VID 0.821  Δ-0.153");
        // delivered above requested → + sign.
        assert_eq!(
            hero_text(Some(1.30), Some(1.20), true),
            "1.300 V  VID 1.200  Δ+0.100"
        );
    }

    #[test]
    fn hero_distinguishes_not_root_from_transient_miss() {
        assert_eq!(
            hero_text(Some(1.0), None, false),
            "1.000 V  VID n/a (no msr access)"
        );
        assert_eq!(hero_text(Some(1.0), None, true), "1.000 V  VID n/a");
    }

    #[test]
    fn hero_handles_missing_vcore() {
        assert_eq!(hero_text(None, Some(1.2), true), "n/a  VID 1.200");
        assert_eq!(hero_text(None, None, false), "n/a  VID n/a (no msr access)");
    }
}