josie / alder-tools

//! ratatui TUI front-end. Three modes:
//!  - **Picker** (home screen): choose Stability / Bench / Custom / q to quit.
//!  - **Editor** (custom preset): toggle workloads, edit duration/cores/
//!    cooldown/loop count, reorder, then Start to launch the sweep.
//!  - **Sweep**: consume `run::Run::tick()` every `poll_ms`, render the
//!    current workload + progress + a results table. q/Esc quits.
//!
//! NO sensor meters — adlermon owns sensors. adlerbench records scores.

use std::io;
use std::time::Duration;

use crossterm::event::{self, Event, KeyCode};
use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table},
};

use crate::config::{Config, WorkloadSpec};
use crate::presets;
use crate::report;
use crate::run::{Run, TickOutcome};
use crate::workload::{Score, Verdict};

/// The three top-level modes the TUI cycles through.
enum Mode {
    /// Picker home screen — choose a preset or custom.
    Picker(PickerState),
    /// Custom preset editor — tune the workload list + params before launch.
    Editor(EditorState),
    /// Sweep running — drives Run::tick() + renders progress + results.
    Sweep(App),
    /// Sweep done — final render sits until the user presses q.
    Done(App),
}

/// Picker screen state: which row is highlighted (0..N).
struct PickerState {
    cursor: usize,
}

/// Custom editor state. Holds a mutable Config the user edits before
/// launching the sweep. The workload list is the central piece — each
/// row is a WorkloadSpec the user can toggle, edit, or reorder.
struct EditorState {
    cfg: Config,
    /// Which row is highlighted (index into cfg.workloads).
    cursor: usize,
    /// Which field of the highlighted row is selected for editing.
    /// 0 = name (toggle), 1 = duration, 2 = cores. The global fields
    /// (cooldown, loop count) are rows below the workload list.
    field: EditorField,
    /// True while editing a numeric field (digit keys append, Enter
    /// commits, Esc cancels).
    editing: Option<String>,
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum EditorField {
    Name,
    Duration,
    Cores,
    Cooldown,
    LoopCount,
}

impl EditorField {
    /// Render order (top to bottom in the editor).
    const PER_ROW: [Self; 3] = [Self::Name, Self::Duration, Self::Cores];
    const GLOBALS: [Self; 2] = [Self::Cooldown, Self::LoopCount];
}

// ---------------------------------------------------------------------------
// Picker
// ---------------------------------------------------------------------------

const PICKER_ROWS: &[(&str, &str)] = &[
    ("Stability", "stress-ng + y-cruncher SSE/AVX2, 5 min each, looped 1 hr"),
    ("Bench", "7z + y-cruncher SSE/AVX2, 60 s each, single pass"),
    ("Core-cycle", "single-core per-core stability, 16 cores x 2 passes"),
    ("Custom", "tune the workload list + params yourself"),
];

// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------

/// Entry point. Owns raw mode via ratatui::init/restore (same pattern as
/// adlermon). The mode loop: draw → poll events (poll_ms timeout) →
/// dispatch by mode. q/Esc at the picker quits; during the sweep it
/// cancels back to the picker (if the sweep was launched from there) or
/// quits (if launched from --preset/--tui directly).
pub fn run_tui(cfg: Config) {
    let mut terminal = ratatui::init();
    let mut mode = Mode::Picker(PickerState { cursor: 0 });
    // The cfg arg is the fallback for "Custom" (starts from whatever was
    // loaded from the conf file). Presets replace it wholesale.
    let default_cfg = cfg;

    loop {
        // Drive the sweep tick BEFORE drawing + polling, so the run
        // advances at poll_ms cadence even when no key is pressed.
        if let Mode::Sweep(app) = &mut mode {
            let outcome = match app.tick() {
                Ok(o) => o,
                Err(e) => {
                    eprintln!("run error: {e}");
                    break;
                }
            };
            // While a workload is RUNNING (not cooldown), don't re-draw
            // every tick — the render loop steals CPU from the workload,
            // which on single-core runs extends the wall time enough to
            // cause false Stopped verdicts. The user can see CPU load via
            // adlermon. We only re-draw on state transitions (workload
            // started / finished / sweep done) + during cooldown (when
            // the CPU is idle anyway).
            let should_draw = match &outcome {
                TickOutcome::WorkloadStarted { .. } => true,
                TickOutcome::WorkloadFinished { .. } => true,
                TickOutcome::SweepDone => true,
                TickOutcome::Idle => {
                    // Draw during cooldown (CPU is idle) but NOT during
                    // running (CPU is busy with the workload).
                    app.run.is_cooldown() || app.run.is_done()
                }
            };
            let is_done = matches!(outcome, TickOutcome::SweepDone);
            // Compute the sleep duration BEFORE we potentially move `app`
            // out (for the Done transition). This borrows app, but the
            // borrow ends when sleep_secs is computed (a u64, no ref).
            let sleep_secs = if !should_draw {
                compute_sleep_secs(app)
            } else {
                0
            };
            if is_done {
                let finished = std::mem::replace(app, App::empty());
                mode = Mode::Done(finished);
            }
            if !should_draw {
                // While the workload is running, sleep for the ENTIRE
                // estimated duration + a generous margin. One long sleep
                // means the TUI process is genuinely idle (not scheduling)
                // while the workload runs — no CPU contention.
                if event::poll(Duration::from_secs(sleep_secs)).unwrap_or(false) {
                    if let Ok(Event::Key(key)) = event::read() {
                        if key.kind == crossterm::event::KeyEventKind::Press
                            && (key.code == KeyCode::Char('q') || key.code == KeyCode::Esc)
                        {
                            break;
                        }
                    }
                }
                continue;
            }
        }

        terminal.draw(|f| draw(f, &mode)).expect("draw failed");
        let poll_ms = match &mode {
            Mode::Sweep(app) | Mode::Done(app) => app.run.poll_ms(),
            _ => 100, // picker + editor don't need fast refresh
        };
        if !event::poll(Duration::from_millis(poll_ms)).unwrap_or(false) {
            continue;
        }
        let event = event::read();
        let Ok(Event::Key(key)) = event else {
            continue;
        };
        if key.kind != crossterm::event::KeyEventKind::Press {
            continue;
        }
        match &mut mode {
            Mode::Picker(state) => {
                match key.code {
                    KeyCode::Up | KeyCode::Char('k') => state.cursor = state.cursor.saturating_sub(1),
                    KeyCode::Down | KeyCode::Char('j') => {
                        state.cursor = (state.cursor + 1).min(PICKER_ROWS.len() - 1)
                    }
                    KeyCode::Enter => {
                        match state.cursor {
                            0 => mode = Mode::Sweep(App::new(presets::stability())),
                            1 => mode = Mode::Sweep(App::new(presets::bench())),
                            2 => mode = Mode::Sweep(App::new(presets::stability_core_cycle(60))),
                            3 => mode = Mode::Editor(EditorState::from_cfg(default_cfg.clone())),
                            _ => {}
                        }
                    }
                    KeyCode::Char('q') | KeyCode::Esc => break,
                    _ => {}
                }
            }
            Mode::Editor(state) => {
                match editor_key(state, key.code) {
                    EditorAction::Start => mode = Mode::Sweep(App::new(state.cfg.clone())),
                    EditorAction::Cancel => mode = Mode::Picker(PickerState { cursor: 3 }),
                    EditorAction::None => {}
                }
            }
            Mode::Sweep(app) => {
                // The tick already ran at the top of the loop (before
                // draw + poll) so the sweep advances even without input.
                // Here we only handle key events.
                let _ = app; // borrow to keep the match arm exhaustive
                match key.code {
                    KeyCode::Char('q') | KeyCode::Esc => break,
                    _ => {}
                }
            }
            Mode::Done(app) => match key.code {
                KeyCode::Char('q') | KeyCode::Esc | KeyCode::Enter => {
                    // Print the summary + exit.
                    print_summary(app);
                    break;
                }
                _ => {}
            },
        }
    }

    ratatui::restore();
    // If we exited from Sweep/Done, print the post-TUI summary.
    if let Mode::Done(app) = &mode {
        print_summary(app);
    } else if let Mode::Sweep(app) = &mode {
        print_summary(app);
    }
}

fn print_summary(app: &App) {
    if app.results.is_empty() {
        if app.quit {
            println!("quit before any workload finished");
        }
        return;
    }
    println!();
    for r in &app.results {
        println!(
            "[{}] {} {:<8} {:<24} verdict={:?}",
            r.idx, r.name, r.isa_class, format_score_short(&r.score), r.verdict
        );
        println!("  report: {}", r.report_path);
    }
    println!("sweep complete: {} workloads", app.results.len());
}

// ---------------------------------------------------------------------------
// Editor
// ---------------------------------------------------------------------------

#[derive(PartialEq, Eq)]
enum EditorAction {
    None,
    Start,
    Cancel,
}

impl EditorState {
    fn from_cfg(cfg: Config) -> Self {
        EditorState {
            cfg,
            cursor: 0,
            field: EditorField::Name,
            editing: None,
        }
    }
}

/// Handle one key in the editor. Returns whether to start the sweep,
/// cancel back to the picker, or do nothing.
fn editor_key(state: &mut EditorState, key: KeyCode) -> EditorAction {
    // If we're editing a numeric field, digit keys append, Enter commits,
    // Esc cancels, Backspace pops.
    if let Some(buf) = &mut state.editing {
        match key {
            KeyCode::Enter => {
                let raw = std::mem::take(&mut state.editing).unwrap_or_default();
                apply_editor_edit(state, &raw);
            }
            KeyCode::Esc => {
                state.editing = None;
            }
            KeyCode::Backspace => {
                buf.pop();
            }
            KeyCode::Char(c) if c.is_ascii_digit() => {
                buf.push(c);
            }
            _ => {}
        }
        return EditorAction::None;
    }

    // Not editing — navigate fields + rows.
    match key {
        KeyCode::Up | KeyCode::Char('k') => {
            editor_move(state, -1);
        }
        KeyCode::Down | KeyCode::Char('j') => {
            editor_move(state, 1);
        }
        KeyCode::Left | KeyCode::Right => {
            // Cycle field within the current row (only for workload rows).
            if state.cursor < state.cfg.workloads.len() {
                let idx = EditorField::PER_ROW.iter().position(|f| *f == state.field).unwrap_or(0);
                let dir = if matches!(key, KeyCode::Left) { -1i32 } else { 1 };
                let n = EditorField::PER_ROW.len() as i32;
                let next = ((idx as i32 + dir).rem_euclid(n)) as usize;
                state.field = EditorField::PER_ROW[next];
            }
        }
        KeyCode::Enter => {
            // Enter on a workload row's Name field toggles inclusion (cycles
            // through available workload names). On Duration/Cores/Cooldown/
            // LoopCount it starts numeric editing. On a "Start" pseudo-row
            // (cursor == workloads.len() + globals.len()) it launches.
            let total_rows = state.cfg.workloads.len() + EditorField::GLOBALS.len() + 1; // +Start
            if state.cursor == total_rows - 1 {
                return EditorAction::Start;
            }
            match state.field {
                EditorField::Name => {
                    // Toggle to the next available workload name.
                    if let Some(w) = state.cfg.workloads.get_mut(state.cursor) {
                        let idx = presets::AVAILABLE_WORKLOADS
                            .iter()
                            .position(|n| *n == w.name)
                            .unwrap_or(0);
                        let next = (idx + 1) % presets::AVAILABLE_WORKLOADS.len();
                        w.name = presets::AVAILABLE_WORKLOADS[next].to_string();
                    }
                }
                EditorField::Duration | EditorField::Cores | EditorField::Cooldown | EditorField::LoopCount => {
                    let current = editor_current_value(state);
                    state.editing = Some(current);
                }
            }
        }
        // 'd' deletes the current workload row (for the custom editor's
        // "remove" action). Bound to a letter so it doesn't collide with
        // numeric editing.
        KeyCode::Char('d') if state.cursor < state.cfg.workloads.len() => {
            state.cfg.workloads.remove(state.cursor);
            if state.cursor >= state.cfg.workloads.len() && !state.cfg.workloads.is_empty() {
                state.cursor = state.cfg.workloads.len() - 1;
            }
        }
        // 'a' adds a new workload row at the end (defaulting to the first
        // available name).
        KeyCode::Char('a') => {
            state.cfg.workloads.push(WorkloadSpec {
                name: presets::AVAILABLE_WORKLOADS[0].to_string(),
                duration_secs: 60,
                cores: "0-15".to_string(),
            });
            state.cursor = state.cfg.workloads.len() - 1;
            state.field = EditorField::Name;
        }
        // Move the current workload row up/down (reorder). Uses `>`/`<`
        // (intuitive for ordering) so j/k stay navigation-only.
        KeyCode::Char('<') if state.cursor < state.cfg.workloads.len() && state.cursor > 0 => {
            state.cfg.workloads.swap(state.cursor, state.cursor - 1);
            state.cursor -= 1;
        }
        KeyCode::Char('>') if state.cursor < state.cfg.workloads.len()
            && state.cursor + 1 < state.cfg.workloads.len() =>
        {
            state.cfg.workloads.swap(state.cursor, state.cursor + 1);
            state.cursor += 1;
        }
        KeyCode::Esc => return EditorAction::Cancel,
        _ => {}
    }
    EditorAction::None
}

/// Move the editor cursor by `dir` rows (clamped). Fields reset to Name
/// when entering a workload row, or to the appropriate global field when
/// entering a global row.
fn editor_move(state: &mut EditorState, dir: i32) {
    let total = state.cfg.workloads.len() + EditorField::GLOBALS.len() + 1; // +Start
    let next = (state.cursor as i32 + dir).clamp(0, (total as i32) - 1) as usize;
    state.cursor = next;
    // Reset field to the first valid one for the new row.
    let workloads_n = state.cfg.workloads.len();
    if next < workloads_n {
        state.field = EditorField::Name;
    } else if next < workloads_n + EditorField::GLOBALS.len() {
        state.field = EditorField::GLOBALS[next - workloads_n];
    } else {
        // Start row — no field.
        state.field = EditorField::Name;
    }
}

/// The current field's value as a string (for the edit buffer's initial
/// content when the user presses Enter to edit).
fn editor_current_value(state: &EditorState) -> String {
    match state.field {
        EditorField::Name => state
            .cfg
            .workloads
            .get(state.cursor)
            .map(|w| w.name.clone())
            .unwrap_or_default(),
        EditorField::Duration => state
            .cfg
            .workloads
            .get(state.cursor)
            .map(|w| w.duration_secs.to_string())
            .unwrap_or_default(),
        EditorField::Cores => state
            .cfg
            .workloads
            .get(state.cursor)
            .map(|w| w.cores.clone())
            .unwrap_or_default(),
        EditorField::Cooldown => state.cfg.cool_secs.to_string(),
        EditorField::LoopCount => state.cfg.loop_count.to_string(),
    }
}

/// Apply a committed edit buffer to the current field.
fn apply_editor_edit(state: &mut EditorState, raw: &str) {
    let raw = raw.trim();
    match state.field {
        EditorField::Name => {
            if let Some(w) = state.cfg.workloads.get_mut(state.cursor) {
                w.name = raw.to_string();
            }
        }
        EditorField::Duration => {
            if let Some(w) = state.cfg.workloads.get_mut(state.cursor) {
                if let Ok(v) = raw.parse::<u64>() {
                    w.duration_secs = v.max(1);
                }
            }
        }
        EditorField::Cores => {
            if let Some(w) = state.cfg.workloads.get_mut(state.cursor) {
                // Accept a cores range like "0-11" or a single number "3".
                // Don't validate beyond non-empty — the workload impls
                // handle bad ranges gracefully.
                if !raw.is_empty() {
                    w.cores = raw.to_string();
                }
            }
        }
        EditorField::Cooldown => {
            if let Ok(v) = raw.parse::<u64>() {
                state.cfg.cool_secs = v;
            }
        }
        EditorField::LoopCount => {
            if let Ok(v) = raw.parse::<u32>() {
                state.cfg.loop_count = v;
            }
        }
    }
    state.editing = None;
}

// ---------------------------------------------------------------------------
// Sweep (App — preserved from the prior session's ui.rs)
// ---------------------------------------------------------------------------

/// One row in the results table, captured from each `WorkloadFinished`.
#[derive(Clone)]
struct ResultRow {
    idx: usize,
    name: String,
    isa_class: &'static str,
    verdict: Verdict,
    score: Score,
    report_path: String,
}

/// App owns the run + the captured results. The run loop is the same as
/// headless mode (tick every poll_ms); the TUI just renders between ticks.
pub struct App {
    run: Run,
    results: Vec<ResultRow>,
    quit: bool,
}

impl App {
    pub fn new(cfg: Config) -> Self {
        App {
            run: Run::new(cfg),
            results: Vec::new(),
            quit: false,
        }
    }

    /// An empty App for the Done state (no run, no results) — used as a
    /// placeholder when we need to move the real App out of a Mode.
    pub fn empty() -> Self {
        App {
            run: Run::new(Config::default()),
            results: Vec::new(),
            quit: true,
        }
    }

    /// Drive one tick + capture any WorkloadFinished into the results vec.
    fn tick(&mut self) -> io::Result<TickOutcome> {
        let outcome = self.run.tick()?;
        if let TickOutcome::WorkloadFinished {
            idx,
            name,
            verdict,
            score,
            report_path,
        } = &outcome
        {
            self.results.push(ResultRow {
                idx: *idx,
                name: name.clone(),
                isa_class: report::isa_class(name),
                verdict: verdict.clone(),
                score: score.clone(),
                report_path: report_path.display().to_string(),
            });
        }
        Ok(outcome)
    }
}

// ---------------------------------------------------------------------------
// Draw dispatch
// ---------------------------------------------------------------------------

fn draw(f: &mut Frame, mode: &Mode) {
    match mode {
        Mode::Picker(state) => draw_picker(f, state),
        Mode::Editor(state) => draw_editor(f, state),
        Mode::Sweep(app) => draw_sweep(f, app),
        Mode::Done(app) => draw_sweep(f, app),
    }
}

fn draw_picker(f: &mut Frame, state: &PickerState) {
    let area = f.area();
    let items: Vec<ListItem> = PICKER_ROWS
        .iter()
        .enumerate()
        .map(|(i, (name, desc))| {
            let cursor = if i == state.cursor { ">" } else { " " };
            let style = if i == state.cursor {
                Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan)
            } else {
                Style::default()
            };
            ListItem::new(Line::from(vec![
                Span::styled(format!("{cursor} "), style),
                Span::styled(format!("{name:<14}"), style),
                Span::raw("  "),
                Span::styled(*desc, Style::default().fg(Color::DarkGray)),
            ]))
        })
        .collect();
    let list = List::new(items)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::DarkGray))
                .title(Span::styled(" adlerbench — choose a sweep ", Style::default().fg(Color::Cyan))),
        );
    f.render_widget(list, area);
    // Footer with keybinds.
    let footer = Line::from(vec![
        Span::styled(
            "↑↓ select  Enter start  q quit",
            Style::default().fg(Color::DarkGray),
        ),
    ]);
    let footer_area = Rect { y: area.bottom().saturating_sub(1), height: 1, ..area };
    f.render_widget(Paragraph::new(footer), footer_area);
}

fn draw_editor(f: &mut Frame, state: &EditorState) {
    let area = f.area();
    // Vertical: header(1) + workload list(flex) + globals(3) + start(1) + footer(1).
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Min(5),
            Constraint::Length(3),
            Constraint::Length(1),
            Constraint::Length(1),
        ])
        .split(area);

    // Header.
    f.render_widget(
        Paragraph::new(Line::from(Span::styled(
            "adlerbench — custom sweep editor",
            Style::default().add_modifier(Modifier::BOLD),
        ))),
        chunks[0],
    );

    // Workload list (each row: name | duration | cores).
    let rows: Vec<Row> = state
        .cfg
        .workloads
        .iter()
        .enumerate()
        .map(|(i, w)| {
            let cursor = if i == state.cursor { ">" } else { " " };
            let row_style = if i == state.cursor {
                Style::default().add_modifier(Modifier::BOLD)
            } else {
                Style::default()
            };
            // Highlight the active field within the row.
            let cell = |field: EditorField, text: String| -> Cell {
                if i == state.cursor && state.field == field {
                    let editing = state.editing.as_deref().unwrap_or(&text);
                    Cell::from(Span::styled(
                        format!("[{editing}]"),
                        Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
                    ))
                } else {
                    Cell::from(Span::styled(text, row_style))
                }
            };
            Row::new(vec![
                Cell::from(Span::styled(format!("{cursor} "), row_style)),
                cell(EditorField::Name, w.name.clone()),
                cell(EditorField::Duration, format!("{}s", w.duration_secs)),
                cell(EditorField::Cores, w.cores.clone()),
            ])
        })
        .collect();
    let header = Row::new(vec![
        Cell::from(Span::styled("  ", Style::default())),
        Cell::from(Span::styled("workload", Style::default().fg(Color::Cyan))),
        Cell::from(Span::styled("dur", Style::default().fg(Color::Cyan))),
        Cell::from(Span::styled("cores", Style::default().fg(Color::Cyan))),
    ]);
    let table = Table::new(
        rows,
        [
            Constraint::Length(2),
            Constraint::Min(18),
            Constraint::Length(8),
            Constraint::Length(10),
        ],
    )
    .header(header)
    .block(
        Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::DarkGray))
            .title(Span::styled(" workloads ", Style::default().fg(Color::Cyan))),
    );
    f.render_widget(table, chunks[1]);

    // Globals (cooldown + loop count).
    let global_cell = |field: EditorField, label: &str, value: String| -> Line {
        let cursor = if state.cursor == state.cfg.workloads.len() + EditorField::GLOBALS.iter().position(|f| *f == field).unwrap() {
            ">"
        } else {
            " "
        };
        let val_span = if state.cursor >= state.cfg.workloads.len()
            && state.field == field
        {
            let editing = state.editing.as_deref().unwrap_or(&value);
            Span::styled(format!("[{editing}]"), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
        } else {
            Span::raw(value)
        };
        Line::from(vec![
            Span::raw(format!("{cursor} ")),
            Span::styled(format!("{label:<12}"), Style::default().fg(Color::Cyan)),
            val_span,
        ])
    };
    let globals = [global_cell(EditorField::Cooldown, "cooldown", format!("{}s", state.cfg.cool_secs)),
        global_cell(EditorField::LoopCount, "loop count", state.cfg.loop_count.to_string())];
    for (i, line) in globals.iter().enumerate() {
        f.render_widget(
            Paragraph::new(line.clone()),
            Rect { x: chunks[2].x, y: chunks[2].y + i as u16, width: chunks[2].width, height: 1 },
        );
    }

    // Start row.
    let start_cursor = if state.cursor == state.cfg.workloads.len() + EditorField::GLOBALS.len() {
        ">"
    } else {
        " "
    };
    let start_style = if state.cursor == state.cfg.workloads.len() + EditorField::GLOBALS.len() {
        Style::default().add_modifier(Modifier::BOLD).fg(Color::Green)
    } else {
        Style::default().fg(Color::DarkGray)
    };
    f.render_widget(
        Paragraph::new(Line::from(vec![
            Span::styled(format!("{start_cursor} "), start_style),
            Span::styled("Start sweep", start_style),
        ])),
        chunks[3],
    );

    // Footer with keybinds.
    f.render_widget(
        Paragraph::new(Line::from(vec![
            Span::styled(
                "j/k row  ←→ field  Enter edit/toggle  a add  d del  </> reorder  Esc back",
                Style::default().fg(Color::DarkGray),
            ),
        ])),
        chunks[4],
    );
}

fn draw_sweep(f: &mut Frame, app: &App) {
    let area = f.area();
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Length(6),
            Constraint::Min(5),
            Constraint::Length(1),
        ])
        .split(area);

    draw_header(f, chunks[0], app);
    draw_current(f, chunks[1], app);
    draw_results(f, chunks[2], app);
    draw_footer(f, chunks[3], app);
}

fn draw_header(f: &mut Frame, area: Rect, app: &App) {
    let total = app.run.total_workloads();
    let current = app.run.current_ordinal().min(total);
    let loop_idx = app.run.loop_index();
    let loop_total = app.run.loop_count();
    let loop_str = if loop_total == 0 {
        format!("loop {loop_idx}/∞")
    } else {
        format!("loop {loop_idx}/{loop_total}")
    };
    let title = if app.run.is_done() {
        format!("adlerbench — sweep complete: {} workloads", app.results.len())
    } else if current == 0 {
        format!("adlerbench — starting (0/{total})  {loop_str}")
    } else {
        format!("adlerbench — workload {current}/{total}  {loop_str}")
    };
    f.render_widget(
        Paragraph::new(Line::from(Span::styled(
            title,
            Style::default().add_modifier(Modifier::BOLD),
        ))),
        area,
    );
}

fn draw_current(f: &mut Frame, area: Rect, app: &App) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::DarkGray))
        .title(Span::styled(" current ", Style::default().fg(Color::Cyan)));

    if app.run.is_done() {
        let inner = block.inner(area);
        f.render_widget(block, area);
        f.render_widget(
            Paragraph::new(Line::from(Span::styled(
                "sweep complete — press q to exit",
                Style::default().fg(Color::DarkGray),
            ))),
            inner,
        );
        return;
    }

    let Some(spec) = app.run.current_spec() else {
        let inner = block.inner(area);
        f.render_widget(block, area);
        f.render_widget(
            Paragraph::new(Line::from(Span::styled(
                "(no workload)",
                Style::default().fg(Color::DarkGray),
            ))),
            inner,
        );
        return;
    };

    let isa = report::isa_class(&spec.name);
    let state_label = if app.run.is_cooldown() { "cooldown" } else { "running" };
    let state_color = if app.run.is_cooldown() { Color::Blue } else { Color::Green };
    let elapsed = app.run.elapsed_secs();

    let inner = block.inner(area);
    f.render_widget(block, area);

    let lines = if app.run.is_cooldown() {
        // Cooldown: show progress (CPU is idle, the TUI can render freely).
        let remaining = app.run.remaining_secs().unwrap_or(0.0);
        let duration = spec.duration_secs as f64;
        let progress = if duration > 0.0 {
            (elapsed / duration).clamp(0.0, 1.0)
        } else {
            0.0
        };
        [
            Line::from(vec![
                Span::styled(format!("{:<20}", spec.name), Style::default().add_modifier(Modifier::BOLD)),
                Span::raw("  "),
                Span::styled(format!("isa={:<8}", isa), Style::default().fg(Color::Cyan)),
                Span::raw("  "),
                Span::styled(state_label, Style::default().fg(state_color)),
            ]),
            Line::from(vec![
                Span::styled(
                    format!("elapsed {:>5.1}s  remaining {:>5.1}s", elapsed, remaining),
                    Style::default().fg(Color::DarkGray),
                ),
            ]),
            progress_bar(progress, inner.width),
        ]
    } else {
        // Running: the duration is a display-only estimate ("this will
        // probably take X seconds"). No progress bar — the workload runs
        // until it self-exits or the user quits. The user can see CPU
        // load via adlermon.
        [
            Line::from(vec![
                Span::styled(format!("{:<20}", spec.name), Style::default().add_modifier(Modifier::BOLD)),
                Span::raw("  "),
                Span::styled(format!("isa={:<8}", isa), Style::default().fg(Color::Cyan)),
                Span::raw("  "),
                Span::styled(state_label, Style::default().fg(state_color)),
            ]),
            Line::from(vec![
                Span::styled(
                    format!("~{}s  cores={}", spec.duration_secs, spec.cores),
                    Style::default().fg(Color::DarkGray),
                ),
                Span::raw("  "),
                Span::styled(
                    format!("elapsed {:>5.1}s", elapsed),
                    Style::default(),
                ),
            ]),
            Line::from(Span::styled(
                "running... (watch CPU load in adlermon, press q to cancel)",
                Style::default().fg(Color::DarkGray),
            )),
        ]
    };
    for (i, line) in lines.iter().enumerate() {
        if (i as u16) >= inner.height {
            break;
        }
        f.render_widget(
            Paragraph::new(line.clone()),
            Rect { x: inner.x, y: inner.y + i as u16, width: inner.width, height: 1 },
        );
    }
}

/// Build a one-line progress bar: `[####······] 42%`.
fn progress_bar(ratio: f64, width: u16) -> Line<'static> {
    let ratio = ratio.clamp(0.0, 1.0);
    let label = format!("{:>3.0}%", ratio * 100.0);
    let bar_w = width.saturating_sub(2 + 1 + 4) as usize;
    let fill = (ratio * bar_w as f64).round() as usize;
    let fill = fill.min(bar_w);
    let mut spans = vec![Span::raw("[")];
    for i in 0..bar_w {
        let ch = if i < fill { "█" } else { "·" };
        let color = if i < fill { Color::Green } else { Color::DarkGray };
        spans.push(Span::styled(ch.to_string(), Style::default().fg(color)));
    }
    spans.push(Span::raw("] "));
    spans.push(Span::styled(label, Style::default().add_modifier(Modifier::BOLD)));
    Line::from(spans)
}

fn draw_results(f: &mut Frame, area: Rect, app: &App) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::DarkGray))
        .title(Span::styled(" results ", Style::default().fg(Color::Cyan)));

    if app.results.is_empty() {
        let inner = block.inner(area);
        f.render_widget(block, area);
        f.render_widget(
            Paragraph::new(Line::from(Span::styled(
                "(no workloads finished yet)",
                Style::default().fg(Color::DarkGray),
            ))),
            inner,
        );
        return;
    }

    let header_cells = [
        Cell::from(Span::styled("#", Style::default().fg(Color::Cyan))),
        Cell::from(Span::styled("workload", Style::default().fg(Color::Cyan))),
        Cell::from(Span::styled("isa", Style::default().fg(Color::Cyan))),
        Cell::from(Span::styled("verdict", Style::default().fg(Color::Cyan))),
        Cell::from(Span::styled("score", Style::default().fg(Color::Cyan))),
    ];
    let header = Row::new(header_cells).height(1);

    let rows = app.results.iter().map(|r| {
        let (vlabel, vcolor) = verdict_badge(&r.verdict);
        Row::new(vec![
            Cell::from(Span::raw(format!("{}", r.idx))),
            Cell::from(Span::raw(r.name.clone())),
            Cell::from(Span::raw(r.isa_class.to_string())),
            Cell::from(Span::styled(vlabel, Style::default().fg(vcolor).add_modifier(Modifier::BOLD))),
            Cell::from(Span::raw(format_score_short(&r.score))),
        ])
    });

    let table = Table::new(
        rows,
        [
            Constraint::Length(3),
            Constraint::Min(18),
            Constraint::Length(8),
            Constraint::Length(10),
            Constraint::Min(20),
        ],
    )
    .header(header)
    .block(block);
    f.render_widget(table, area);
}

fn draw_footer(f: &mut Frame, area: Rect, _app: &App) {
    f.render_widget(
        Paragraph::new(Line::from(Span::styled(
            "q: quit  Esc: quit",
            Style::default().fg(Color::DarkGray),
        ))),
        area,
    );
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Compute the sleep duration for the "workload running, no re-draw"
/// path. Sleeps for the remaining estimated time + a 5s margin, so the
/// TUI process is genuinely idle (not scheduling) while the workload
/// runs. After the sleep, the next tick will detect the workload has
/// exited (or still running, in which case we sleep again).
fn compute_sleep_secs(app: &mut App) -> u64 {
    let elapsed = app.run.elapsed_secs();
    let duration = app.run.current_spec().map(|s| s.duration_secs as f64).unwrap_or(60.0);
    let remaining = (duration - elapsed).max(1.0);
    remaining as u64 + 5
}

fn format_score_short(s: &Score) -> String {
    match s {
        Score::None => "(stability only)".to_string(),
        Score::SevenZip { rating_mips, .. } => format!("{rating_mips} MIPS"),
        Score::YCruncher { tests } => {
            if tests.is_empty() {
                "(no tests)".to_string()
            } else if tests.len() == 1 {
                let t = &tests[0];
                format!("{} {:.2} Gb/s", t.tag, t.bits_per_sec / 1e9)
            } else {
                format!("{} tests", tests.len())
            }
        }
    }
}

fn verdict_badge(v: &Verdict) -> (&'static str, Color) {
    match v {
        Verdict::Clean => ("CLEAN", Color::Green),
        Verdict::Failed(_) => ("FAILED", Color::Red),
        Verdict::Stopped => ("STOPPED", Color::Yellow),
        Verdict::Error(_) => ("ERROR", Color::Red),
    }
}