//! Rendering of the application state into a Ratatui frame.
//!
//! The render function is pure with respect to runtime: given an
//! `App` and a `Theme`, the same frame is produced regardless of
//! when or where it is called. That property is what makes Tier 2
//! (TestBackend) and Tier 3 (synthetic event) tests in ADR-0008
//! straightforward.

use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap};

use crate::app::{App, EchoStatus, EffectiveMode, OutputKind, OutputLine, OutputStyleClass};
use crate::mode::Mode;
use crate::theme::Theme;

/// Render the entire application frame.
///
/// Takes `&mut App` because the renderer reports the current
/// output-panel row count back to the App for scroll-cap
/// computation — without that feedback, scrolling past the top
/// of the buffer would slide the visible window off and
/// "eat" lines from the bottom on subsequent renders.
pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
    let area = frame.area();
    paint_background(theme, frame, area);

    // Reserve two rows at the bottom for status:
    //   - top row: "Project: <Display Name>" (P-NAME-3, ADR-0015 §2).
    //   - bottom row: mode-aware keyboard shortcuts.
    let outer = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Min(8),
            Constraint::Length(1),
            Constraint::Length(1),
        ])
        .split(area);

    let columns = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Length(28), Constraint::Min(20)])
        .split(outer[0]);

    render_items_panel(app, theme, frame, columns[0]);
    render_right_column(app, theme, frame, columns[1]);
    render_project_label(app, theme, frame, outer[1]);
    render_status_bar(app, theme, frame, outer[2]);

    // Modal dialogs (rebuild confirm, save-as prompt, load
    // picker, …) are drawn last so they overlay the rest of
    // the frame.
    if let Some(modal) = app.modal.as_ref() {
        render_modal(modal, theme, frame, area);
    }
}

fn render_modal(modal: &crate::app::Modal, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
    use crate::app::Modal;
    match modal {
        Modal::RebuildConfirm(m) => render_rebuild_confirm(&m.summary, theme, frame, area),
        Modal::PathEntry(m) => render_path_entry(m, theme, frame, area),
        Modal::LoadPicker(m) => render_load_picker(m, theme, frame, area),
        Modal::UndoConfirm(m) => render_undo_confirm(m, theme, frame, area),
    }
}

fn render_path_entry(
    m: &crate::app::PathEntryModal,
    theme: &Theme,
    frame: &mut Frame<'_>,
    area: Rect,
) {
    let dialog_w = area.width.clamp(20, 70);
    let inner_w = dialog_w.saturating_sub(4) as usize;
    let prompt_lines = wrap_lines(&m.prompt, inner_w);
    // Title + blank + prompt + blank + input box (1 row + borders) + blank + key hints.
    let dialog_h = (prompt_lines.len() as u16).saturating_add(8).min(area.height);
    let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
    let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
    let dialog_area = Rect {
        x,
        y,
        width: dialog_w,
        height: dialog_h,
    };
    frame.render_widget(ratatui::widgets::Clear, dialog_area);

    let title_style = Style::default()
        .fg(theme.fg)
        .add_modifier(Modifier::BOLD);
    let block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .border_style(Style::default().fg(theme.fg))
        .title(Line::from(vec![Span::styled(
            format!(" {} ", m.title),
            title_style,
        )]))
        .style(Style::default().bg(theme.bg).fg(theme.fg));

    let mut text_lines: Vec<Line<'_>> = Vec::new();
    text_lines.push(Line::from(""));
    for line in prompt_lines {
        text_lines.push(Line::from(line));
    }
    text_lines.push(Line::from(""));
    let cursor_marker = "█";
    let display_input = if m.cursor == m.input.len() {
        format!("{}{cursor_marker}", m.input)
    } else {
        format!(
            "{}{cursor_marker}{}",
            &m.input[..m.cursor],
            &m.input[m.cursor..]
        )
    };
    text_lines.push(Line::from(format!("> {display_input}")));
    text_lines.push(Line::from(""));
    text_lines.push(Line::from(vec![
        Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
        Span::raw(format!(" {}   ", crate::t!("shortcut.confirm"))),
        Span::styled("Esc", Style::default().fg(theme.muted)),
        Span::styled(
            format!(" {}", crate::t!("shortcut.cancel")),
            Style::default().fg(theme.muted),
        ),
    ]));

    let paragraph = Paragraph::new(text_lines)
        .block(block)
        .wrap(Wrap { trim: false });
    frame.render_widget(paragraph, dialog_area);
}

fn render_load_picker(
    m: &crate::app::LoadPickerModal,
    theme: &Theme,
    frame: &mut Frame<'_>,
    area: Rect,
) {
    use crate::app::LoadPickerSubMode;
    let dialog_w = area.width.clamp(20, 70);
    let dialog_h = area.height.clamp(10, 20);
    let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
    let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
    let dialog_area = Rect {
        x,
        y,
        width: dialog_w,
        height: dialog_h,
    };
    frame.render_widget(ratatui::widgets::Clear, dialog_area);

    let title_style = Style::default()
        .fg(theme.fg)
        .add_modifier(Modifier::BOLD);
    let block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .border_style(Style::default().fg(theme.fg))
        .title(Line::from(vec![Span::styled(
            format!(" {} ", crate::t!("modal.load_picker_title")),
            title_style,
        )]))
        .style(Style::default().bg(theme.bg).fg(theme.fg));

    let mut text_lines: Vec<Line<'_>> = Vec::new();
    text_lines.push(Line::from(""));

    match &m.sub_mode {
        LoadPickerSubMode::List => {
            if m.entries.is_empty() {
                text_lines.push(Line::from(crate::t!("modal.load_picker_empty")));
            } else {
                for (i, entry) in m.entries.iter().enumerate() {
                    let marker = if i == m.selected { "›" } else { " " };
                    let temp_tag = if entry.is_temp { "[TEMP] " } else { "" };
                    let style = if i == m.selected {
                        Style::default()
                            .fg(theme.fg)
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(theme.fg)
                    };
                    let line = format!(
                        " {marker} {temp_tag}{name}   {modified}",
                        name = entry.display_name,
                        modified = entry.modified,
                    );
                    text_lines.push(Line::from(Span::styled(line, style)));
                }
            }
            text_lines.push(Line::from(""));
            text_lines.push(Line::from(vec![
                Span::styled("↑↓", Style::default().add_modifier(Modifier::BOLD)),
                Span::raw(format!(" {}   ", crate::t!("shortcut.select"))),
                Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
                Span::raw(format!(" {}   ", crate::t!("shortcut.load"))),
                Span::styled("b", Style::default().add_modifier(Modifier::BOLD)),
                Span::raw(format!(" {}   ", crate::t!("shortcut.browse_path"))),
                Span::styled("Esc", Style::default().fg(theme.muted)),
                Span::styled(
                    format!(" {}", crate::t!("shortcut.cancel")),
                    Style::default().fg(theme.muted),
                ),
            ]));
        }
        LoadPickerSubMode::PathEntry { input, cursor } => {
            text_lines.push(Line::from(crate::t!("modal.load_picker_path_prompt")));
            text_lines.push(Line::from(""));
            let cursor_marker = "█";
            let display_input = if *cursor == input.len() {
                format!("{input}{cursor_marker}")
            } else {
                format!(
                    "{}{cursor_marker}{}",
                    &input[..*cursor],
                    &input[*cursor..]
                )
            };
            text_lines.push(Line::from(format!("> {display_input}")));
            text_lines.push(Line::from(""));
            text_lines.push(Line::from(vec![
                Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
                Span::raw(format!(" {}   ", crate::t!("shortcut.load"))),
                Span::styled("Esc", Style::default().fg(theme.muted)),
                Span::styled(
                    format!(" {}", crate::t!("shortcut.back_to_list")),
                    Style::default().fg(theme.muted),
                ),
            ]));
        }
    }

    let paragraph = Paragraph::new(text_lines)
        .block(block)
        .wrap(Wrap { trim: false });
    frame.render_widget(paragraph, dialog_area);
}

/// Centred dialog with a one-paragraph body and a [Y]es/[N]o
/// hint at the bottom. Sized at min(60 cols, area.width-4)
/// wide and tall enough to fit the wrapped body plus 4 rows
/// of chrome.
fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
    let dialog_w = area.width.min(60).saturating_sub(0);
    let dialog_w = dialog_w.max(20);
    let inner_w = dialog_w.saturating_sub(4) as usize;

    let body_lines: Vec<String> = wrap_lines(summary, inner_w);
    let body_height = body_lines.len() as u16;
    // Title row + blank + body + blank + prompt + blank + keys + borders (2).
    let dialog_h = body_height.saturating_add(7).min(area.height);

    let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
    let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
    let dialog_area = Rect {
        x,
        y,
        width: dialog_w,
        height: dialog_h,
    };

    // Solid background panel so we cover whatever was beneath.
    let bg = ratatui::widgets::Clear;
    frame.render_widget(bg, dialog_area);

    let title_style = Style::default()
        .fg(theme.fg)
        .add_modifier(Modifier::BOLD);
    let block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .border_style(Style::default().fg(theme.fg))
        .title(Line::from(vec![Span::styled(
            format!(" {} ", crate::t!("modal.rebuild_confirm_title")),
            title_style,
        )]))
        .style(Style::default().bg(theme.bg).fg(theme.fg));

    let mut text_lines: Vec<Line<'_>> = Vec::new();
    text_lines.push(Line::from(""));
    for line in body_lines {
        text_lines.push(Line::from(line));
    }
    text_lines.push(Line::from(""));
    text_lines.push(Line::from(crate::t!("modal.rebuild_confirm_prompt")));
    text_lines.push(Line::from(""));
    text_lines.push(Line::from(vec![
        Span::styled(
            "[Y]",
            Style::default()
                .fg(theme.fg)
                .add_modifier(Modifier::BOLD),
        ),
        Span::raw(format!(" {}   ", crate::t!("shortcut.yes"))),
        Span::styled(
            "[N]",
            Style::default()
                .fg(theme.fg)
                .add_modifier(Modifier::BOLD),
        ),
        Span::raw(format!(" {}   ", crate::t!("shortcut.no"))),
        Span::styled("Esc", Style::default().fg(theme.muted)),
        Span::styled(
            format!(" {}", crate::t!("shortcut.cancel")),
            Style::default().fg(theme.muted),
        ),
    ]));

    let paragraph = Paragraph::new(text_lines)
        .block(block)
        .wrap(Wrap { trim: false });
    frame.render_widget(paragraph, dialog_area);
}

/// Format a stored ISO-8601 UTC timestamp for display in a
/// confirmation dialog (issue #13): parse it, convert to the
/// machine's local timezone, and render a fixed human-friendly
/// form (`24 May 2026, 11:00`). Month names stay English — no
/// locale feature. Falls back to the raw input if it can't be
/// parsed; this is defensive only, since stored values are always
/// `utc_iso8601_now()` output.
fn format_snapshot_timestamp(iso: &str) -> String {
    chrono::DateTime::parse_from_rfc3339(iso)
        .map(|dt| format_local_datetime(dt.with_timezone(&chrono::Local)))
        .unwrap_or_else(|_| iso.to_string())
}

/// Render a timezone-aware datetime in the fixed display form.
/// Split out from [`format_snapshot_timestamp`] so the format can
/// be unit-tested deterministically with a fixed offset (the
/// `Local` conversion itself is machine-dependent).
fn format_local_datetime<Tz>(dt: chrono::DateTime<Tz>) -> String
where
    Tz: chrono::TimeZone,
    Tz::Offset: std::fmt::Display,
{
    dt.format("%-d %b %Y, %H:%M").to_string()
}

/// Preferred outer width (columns) for the undo/redo confirm
/// dialog (issue #13): wide enough to hold the longest content
/// line on a single row, clamped to sane bounds and the available
/// area so a short insert no longer wraps on roomy terminals.
fn undo_dialog_width(
    content_widths: impl IntoIterator<Item = usize>,
    area_width: u16,
) -> u16 {
    /// Floor — comfortably fits the button row plus borders.
    const MIN: u16 = 34;
    /// Ceiling for outlier (ultra-wide) terminals.
    const MAX: u16 = 100;
    let widest = content_widths.into_iter().max().unwrap_or(0);
    // +4: left/right border (2) + one padding column each side (2).
    let preferred =
        u16::try_from(widest).unwrap_or(u16::MAX).saturating_add(4);
    let upper = area_width.min(MAX);
    let lower = MIN.min(upper);
    preferred.clamp(lower, upper)
}

/// `undo` / `redo` confirmation modal (ADR-0006 Amendment 1). Names
/// the command that will be undone / re-applied and when its
/// snapshot was taken, then prompts `Y` / `N`.
fn render_undo_confirm(
    m: &crate::app::UndoConfirmModal,
    theme: &Theme,
    frame: &mut Frame<'_>,
    area: Rect,
) {
    let intro = if m.is_redo {
        crate::t!("modal.redo_confirm_command")
    } else {
        crate::t!("modal.undo_confirm_command")
    };
    let title = if m.is_redo {
        crate::t!("modal.redo_confirm_title")
    } else {
        crate::t!("modal.undo_confirm_title")
    };
    let intro_line = format!("{intro} {}", m.command);
    // Local-time, human-formatted snapshot stamp (issue #13).
    let when_display = format_snapshot_timestamp(&m.timestamp);
    let when_line =
        crate::t!("modal.undo_confirm_when", timestamp = when_display);
    let prompt = crate::t!("modal.undo_confirm_prompt");
    // Reconstruct the button row purely to measure its width — the
    // styled spans are built below. Keep this in sync with them.
    let buttons_measure = format!(
        "[Y] {}   [N] {}   Esc {}",
        crate::t!("shortcut.yes"),
        crate::t!("shortcut.no"),
        crate::t!("shortcut.cancel"),
    );

    // Grow the dialog to fit the longest content line on one row
    // (issue #13). The title sits in the border, so it needs two
    // extra columns for the surrounding spaces.
    let dialog_w = undo_dialog_width(
        [
            title.chars().count() + 2,
            intro_line.chars().count(),
            when_line.chars().count(),
            prompt.chars().count(),
            buttons_measure.chars().count(),
        ],
        area.width,
    );
    let inner_w = dialog_w.saturating_sub(4) as usize;

    let mut body_lines: Vec<String> = wrap_lines(&intro_line, inner_w);
    body_lines.extend(wrap_lines(&when_line, inner_w));
    let body_height = body_lines.len() as u16;
    // Title row + blank + body + blank + prompt + blank + keys + borders (2).
    let dialog_h = body_height.saturating_add(7).min(area.height);

    let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
    let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
    let dialog_area = Rect {
        x,
        y,
        width: dialog_w,
        height: dialog_h,
    };

    frame.render_widget(ratatui::widgets::Clear, dialog_area);

    let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
    let block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .border_style(Style::default().fg(theme.fg))
        .title(Line::from(vec![Span::styled(
            format!(" {title} "),
            title_style,
        )]))
        .style(Style::default().bg(theme.bg).fg(theme.fg));

    let mut text_lines: Vec<Line<'_>> = Vec::new();
    text_lines.push(Line::from(""));
    for line in body_lines {
        text_lines.push(Line::from(line));
    }
    text_lines.push(Line::from(""));
    text_lines.push(Line::from(prompt));
    text_lines.push(Line::from(""));
    text_lines.push(Line::from(vec![
        Span::styled("[Y]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
        Span::raw(format!(" {}   ", crate::t!("shortcut.yes"))),
        Span::styled("[N]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
        Span::raw(format!(" {}   ", crate::t!("shortcut.no"))),
        Span::styled("Esc", Style::default().fg(theme.muted)),
        Span::styled(
            format!(" {}", crate::t!("shortcut.cancel")),
            Style::default().fg(theme.muted),
        ),
    ]));

    let paragraph = Paragraph::new(text_lines)
        .block(block)
        .wrap(Wrap { trim: false });
    frame.render_widget(paragraph, dialog_area);
}

/// Greedy word-wrap to `width` columns. Sufficient for the
/// short prose modals carry; we don't try to be Unicode-aware
/// (display-width-wise) since the strings we generate are
/// ASCII-friendly.
fn wrap_lines(s: &str, width: usize) -> Vec<String> {
    if width == 0 {
        return vec![s.to_string()];
    }
    let mut lines: Vec<String> = Vec::new();
    let mut current = String::new();
    for word in s.split_whitespace() {
        if !current.is_empty() && current.len() + 1 + word.len() <= width {
            current.push(' ');
        } else if !current.is_empty() {
            lines.push(std::mem::take(&mut current));
        }
        current.push_str(word);
    }
    if !current.is_empty() {
        lines.push(current);
    }
    if lines.is_empty() {
        lines.push(String::new());
    }
    lines
}

/// Maximum content rows the Hint panel may grow to before its last
/// visible row is ellipsis-truncated (issue #12). The panel starts
/// at one row and grows only as far as a wrapped hint needs, up to
/// this cap, reclaiming the space when the hint is short.
const MAX_HINT_ROWS: usize = 3;

/// Word-wrap `text` to `width`, then clamp to at most `max_rows`
/// rows. If wrapping produced more rows than the cap, the last kept
/// row is truncated to end with an ellipsis so the overflow is
/// signalled rather than silently dropped (issue #12). Every
/// returned row fits within `width`.
fn clamp_wrapped(text: &str, width: usize, max_rows: usize) -> Vec<String> {
    let mut lines = wrap_lines(text, width);
    if lines.len() <= max_rows {
        return lines;
    }
    lines.truncate(max_rows.max(1));
    if let Some(last) = lines.last_mut() {
        // Reserve one column for the ellipsis.
        let budget = width.saturating_sub(1);
        let mut chars: Vec<char> = last.chars().collect();
        while chars.len() > budget {
            chars.pop();
        }
        chars.push('…');
        *last = chars.into_iter().collect();
    }
    lines
}

fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
    let label_style = Style::default().fg(theme.muted);
    let value_style = Style::default()
        .fg(theme.fg)
        .add_modifier(Modifier::BOLD);
    let bar_style = Style::default().bg(theme.bg).fg(theme.muted);

    let no_project = crate::t!("status.no_project");
    let display = app.project_name.as_deref().unwrap_or(no_project.as_str());
    let mut spans: Vec<Span<'_>> = vec![Span::styled(
        crate::t!("status.project_label"),
        label_style,
    )];
    if app.project_is_temp {
        spans.push(Span::styled(
            "[TEMP] ",
            Style::default()
                .fg(theme.muted)
                .add_modifier(Modifier::BOLD),
        ));
    }
    spans.push(Span::styled(display.to_string(), value_style));
    let line = Line::from(spans);
    let paragraph = Paragraph::new(line).style(bar_style);
    frame.render_widget(paragraph, area);
}

fn render_right_column(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
    // Resolve the hint first so the layout can size the Hint panel to
    // the wrapped hint (issue #12): one content row by default,
    // growing up to MAX_HINT_ROWS, reclaiming the space when short.
    // The hint panel spans the full column width, so `area.width` is
    // its width too.
    let hint_lines = resolve_hint_lines(app, theme, area.width);
    let hint_content =
        (hint_lines.len().clamp(1, MAX_HINT_ROWS) as u16).saturating_add(2);

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Min(5),               // Output panel
            Constraint::Length(3),            // Input panel
            Constraint::Length(hint_content), // Hint panel (dynamic)
        ])
        .split(area);

    render_output_panel(app, theme, frame, rows[0]);
    render_input_panel(app, theme, frame, rows[1]);
    render_hint_panel(theme, frame, rows[2], hint_lines);
}

fn paint_background(theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
    let block = Block::default().style(Style::default().bg(theme.bg).fg(theme.fg));
    frame.render_widget(block, area);
}

fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .border_style(Style::default().fg(theme.border))
        .title(Span::styled(
            format!(" {} ", crate::t!("panel.tables_title")),
            Style::default()
                .fg(theme.fg)
                .add_modifier(Modifier::BOLD),
        ))
        .style(Style::default().bg(theme.bg).fg(theme.fg));

    if app.tables.is_empty() {
        let placeholder = Paragraph::new(Line::from(Span::styled(
            crate::t!("panel.tables_empty"),
            Style::default()
                .fg(theme.muted)
                .add_modifier(Modifier::ITALIC),
        )))
        .block(block);
        frame.render_widget(placeholder, area);
        return;
    }

    let highlight = app
        .current_table
        .as_ref()
        .map(|t| t.name.as_str())
        .unwrap_or_default();
    // Nested tables / per-table indexes (S2, ADR-0025): each
    // table line, with its index names indented beneath it.
    let mut lines: Vec<Line<'_>> = Vec::new();
    for name in &app.tables {
        let style = if name == highlight {
            Style::default()
                .fg(theme.fg)
                .add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(theme.fg)
        };
        lines.push(Line::from(Span::styled(name.as_str(), style)));
        if let Some(indexes) = app.schema_cache.table_indexes.get(name) {
            for index in indexes {
                // Mark a UNIQUE index so the panel distinguishes it from
                // a performance-only index (ADR-0035 §4d).
                let unique = if index.unique { " [unique]" } else { "" };
                lines.push(Line::from(Span::styled(
                    format!("  {}{unique}", index.name),
                    Style::default().fg(theme.muted),
                )));
            }
        }
    }
    let paragraph = Paragraph::new(lines).block(block);
    frame.render_widget(paragraph, area);
}

fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .border_style(Style::default().fg(theme.border))
        .title(Span::styled(
            format!(" {} ", crate::t!("panel.output_title")),
            Style::default()
                .fg(theme.fg)
                .add_modifier(Modifier::BOLD),
        ))
        .style(Style::default().bg(theme.bg).fg(theme.fg));

    let inner = area.inner(Margin {
        horizontal: 1,
        vertical: 1,
    });

    // Render every output line into a wrapped Paragraph and let
    // ratatui handle the wrapping; we then use the wrapped row
    // count to cap scroll correctly. Bottom-anchoring (most
    // recent visible by default) is achieved by computing the
    // scroll offset relative to the bottom of the wrapped view.
    let visible = inner.height as usize;

    // Compute the total wrapped row count first, working from
    // OutputLines directly (so the borrow ends before the
    // mutable `note_output_viewport` call below).
    let total_wrapped = approximate_wrapped_rows_from_output(&app.output, inner.width);
    app.note_output_viewport(visible, total_wrapped);

    let lines: Vec<Line<'_>> = app
        .output
        .iter()
        .map(|line| render_output_line(line, theme))
        .collect();
    let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });

    let max_scroll = total_wrapped.saturating_sub(visible);
    let effective_scroll = app.output_scroll.min(max_scroll);
    // Paragraph::scroll((y, _)) sets the topmost visible row.
    // We want bottom-anchored: y = max_scroll - effective_scroll
    // so scroll==0 shows the bottom and PageUp moves y down to
    // reveal older content.
    let scroll_y = max_scroll.saturating_sub(effective_scroll);
    let scroll_y_u16 = u16::try_from(scroll_y).unwrap_or(u16::MAX);

    frame.render_widget(block, area);
    frame.render_widget(paragraph.scroll((scroll_y_u16, 0)), inner);
}

/// Approximate the number of display rows the output buffer
/// will occupy after width-wrapping. Computed directly from
/// `OutputLine`s (rather than the rendered `Line` objects) so
/// the calculation can run before we hand `&mut App` to the
/// scroll-cap update. Ratatui's exact `line_count` is gated
/// behind an unstable feature; this character-based
/// approximation is close enough for scroll capping — off-by-one
/// at boundaries is acceptable.
fn approximate_wrapped_rows_from_output(
    output: &std::collections::VecDeque<OutputLine>,
    width: u16,
) -> usize {
    if width == 0 {
        return output.len();
    }
    let w = usize::from(width);
    output
        .iter()
        .map(|line| {
            // Tag width matches `render_output_line` exactly so
            // the row count is right when the panel is too narrow
            // for the natural line.
            let tag_len = match line.kind {
                OutputKind::Echo => match line.mode_at_submission {
                    Mode::Simple => "[simple] ".len(),
                    Mode::Advanced => "[advanced] ".len(),
                },
                OutputKind::System | OutputKind::TeachingEcho => "[system] ".len(),
                OutputKind::Error => "[error]  ".len(),
            };
            // ADR-0040: a completed echo renders `<input> ✓/✗` —
            // the `running: ` prefix is dropped and a 2-column marker
            // (space + glyph) appended; everything else renders its
            // text verbatim.
            let content_chars = if matches!(
                (line.kind, line.status),
                (OutputKind::Echo, Some(EchoStatus::Ok | EchoStatus::Err))
            ) {
                line.text
                    .strip_prefix(crate::dsl::ECHO_PREFIX)
                    .unwrap_or(line.text.as_str())
                    .chars()
                    .count()
                    + 2
            } else {
                line.text.chars().count()
            };
            let total = tag_len + content_chars;
            if total == 0 { 1 } else { total.div_ceil(w) }
        })
        .sum()
}

/// Resolve a semantic [`OutputStyleClass`] to a concrete style
/// against the active theme (ADR-0028 §5/§6).
const fn output_span_style(class: OutputStyleClass, theme: &Theme) -> Style {
    match class {
        OutputStyleClass::Neutral => Style::new().fg(theme.fg),
        OutputStyleClass::Efficient => Style::new().fg(theme.plan_efficient),
        OutputStyleClass::Expensive => Style::new().fg(theme.warning),
        OutputStyleClass::AutomaticIndex => Style::new()
            .fg(theme.warning)
            .add_modifier(Modifier::BOLD),
        // ADR-0038 §4 / §6: de-emphasised text — the `Executing SQL:`
        // prefix and every category-3 prose line (caveat + the
        // existing `client_side.*` notes). `theme.muted` is the
        // established dim foreground.
        OutputStyleClass::Hint => Style::new().fg(theme.muted),
    }
}

fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> {
    // ADR-0037 Amendment (issue #10): the output tag is colour-coded by
    // the message's STATUS (its kind), not the submission mode — so the
    // leftmost glyph the eye lands on says "ok" vs "error" at a glance,
    // and a routine `[system]` line never looks identical to an `[error]`.
    // The echo line is the sole exception: its tag's whole job is to label
    // the submission mode (ADR-0037's stated purpose), so it keeps the
    // mode tint (per-command success rides the trailing ✓/✗ — ADR-0040).
    let tag_style = match line.kind {
        OutputKind::Echo => match line.mode_at_submission {
            Mode::Simple => Style::default().fg(theme.mode_simple),
            Mode::Advanced => Style::default().fg(theme.mode_advanced),
        },
        OutputKind::System | OutputKind::TeachingEcho => Style::default().fg(theme.system),
        OutputKind::Error => Style::default().fg(theme.error),
    };
    let tag = match line.kind {
        OutputKind::Echo => format!("[{}] ", line.mode_at_submission.label().to_lowercase()),
        OutputKind::System | OutputKind::TeachingEcho => "[system] ".to_string(),
        OutputKind::Error => "[error]  ".to_string(),
    };

    // ADR-0040: an echo renders `running: <input>` while pending
    // (and for untracked parse/pre-flight rejections), and
    // `<input> ✓` / `<input> ✗` once an executed command completes
    // — the marker replaces the old `[ok]`/`failed:` summary line.
    // Simple-mode input keeps its token-class highlighting (ADR-0022
    // §5); advanced-mode input renders plain, as before. The body
    // shape is `<ECHO_PREFIX><input>`; the prefix is pinned to the
    // catalog template by
    // `dsl::tests::echo_prefix_matches_catalog_template`.
    if line.kind == OutputKind::Echo {
        let input = line
            .text
            .strip_prefix(crate::dsl::ECHO_PREFIX)
            .unwrap_or(line.text.as_str());
        let mut spans: Vec<Span<'a>> = Vec::with_capacity(3 + input.len() / 4);
        spans.push(Span::styled(tag, tag_style));
        // Pending / untracked → keep the `running: ` prefix;
        // completed → drop it (the marker carries the outcome).
        if !matches!(line.status, Some(EchoStatus::Ok | EchoStatus::Err)) {
            spans.push(Span::styled(
                crate::dsl::ECHO_PREFIX,
                Style::default().fg(theme.fg),
            ));
        }
        if line.mode_at_submission == Mode::Simple {
            for run in crate::input_render::lex_to_runs(input, theme) {
                spans.push(Span::styled(
                    &input[run.byte_range.0..run.byte_range.1],
                    run.style,
                ));
            }
        } else {
            spans.push(Span::styled(input, Style::default().fg(theme.fg)));
        }
        match line.status {
            Some(EchoStatus::Ok) => {
                spans.push(Span::styled(" ✓", Style::default().fg(theme.system)));
            }
            Some(EchoStatus::Err) => {
                spans.push(Span::styled(" ✗", Style::default().fg(theme.error)));
            }
            _ => {}
        }
        return Line::from(spans);
    }

    // ADR-0038 §4 styled-runs polish — the DSL → SQL teaching echo
    // gets the same syntactic treatment as the input echo, but with
    // a dimmed `Executing SQL: ` prefix instead of the mode label,
    // and the SQL portion lexed in `Mode::Advanced` (the echoed SQL
    // is always advanced syntax, regardless of submission mode).
    if line.kind == OutputKind::TeachingEcho
        && let Some(rest) = line.text.strip_prefix(crate::echo::TEACHING_ECHO_LABEL)
    {
        let prefix_len = crate::echo::TEACHING_ECHO_LABEL.len();
        let mut spans: Vec<Span<'a>> = Vec::with_capacity(2 + rest.len() / 4);
        spans.push(Span::styled(tag, tag_style));
        spans.push(Span::styled(
            &line.text[..prefix_len],
            Style::default().fg(theme.muted),
        ));
        for run in
            crate::input_render::lex_to_runs_in_mode(rest, theme, Mode::Advanced)
        {
            spans.push(Span::styled(
                &rest[run.byte_range.0..run.byte_range.1],
                run.style,
            ));
        }
        return Line::from(spans);
    }
    // A TeachingEcho line missing the canonical prefix is a builder
    // bug; it falls through to the plain rendering below.

    // ADR-0028 §5: a line carrying a styled-runs payload is
    // rendered span-by-span, each run's semantic class resolved
    // to a colour from the active theme. The tag keeps its
    // kind styling. (Echo lines never carry runs, so this never
    // collides with the branch above.)
    if let Some(runs) = &line.styled_runs {
        let mut spans: Vec<Span<'a>> = Vec::with_capacity(runs.len() + 1);
        spans.push(Span::styled(tag, tag_style));
        for run in runs {
            let (start, end) = run.byte_range;
            spans.push(Span::styled(
                &line.text[start..end],
                output_span_style(run.class, theme),
            ));
        }
        return Line::from(spans);
    }

    // ADR-0037 Amendment (issue #10): bodies are neutral — the status
    // colour lives on the tag, not flooded across the message text. An
    // error body keeps weight via BOLD (rustc-style: severity-coloured
    // label, readable bold message) rather than a hard-to-read wall of
    // red; a system body is plain `theme.fg`.
    let body_style = match line.kind {
        // TeachingEcho without a prefix reaches here only as a builder
        // bug; it shares the neutral system body so it degrades
        // gracefully rather than crashing.
        OutputKind::Echo | OutputKind::System | OutputKind::TeachingEcho => {
            Style::default().fg(theme.fg)
        }
        OutputKind::Error => Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
    };
    Line::from(vec![
        Span::styled(tag, tag_style),
        Span::styled(line.text.as_str(), body_style),
    ])
}

fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
    let effective = app.effective_mode();
    let (border_color, mode_color, label) = match effective {
        EffectiveMode::Simple => (
            theme.border,
            theme.mode_simple,
            crate::t!("mode.label_simple"),
        ),
        EffectiveMode::AdvancedPersistent => (
            theme.border_advanced,
            theme.mode_advanced,
            crate::t!("mode.label_advanced"),
        ),
        // Mixed-case label distinguishes the one-shot (`:`-triggered)
        // state from a persistent advanced mode at a glance.
        EffectiveMode::AdvancedOneShot => (
            theme.border_advanced,
            theme.mode_advanced,
            crate::t!("mode.label_advanced_one_shot"),
        ),
    };

    let title = Line::from(vec![
        Span::raw(" "),
        Span::styled(
            label,
            Style::default()
                .fg(mode_color)
                .add_modifier(Modifier::BOLD),
        ),
        Span::raw(" "),
    ]);

    let block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .border_style(Style::default().fg(border_color))
        .title(title)
        .style(Style::default().bg(theme.bg).fg(theme.fg));

    // Cursor block: render the character at the cursor position
    // inverted so the cursor is visible without enabling a real
    // terminal cursor.
    //
    // Per-token colouring (ADR-0022 §3 / ADR-0030 §8) in both
    // modes — `render_input_runs_in_mode` runs the highlight
    // walker with the active mode so SQL keywords / operators /
    // CASE / function calls colour correctly in Advanced mode.
    let cursor = app.input_cursor.min(app.input.len());
    let mode_for_render = match effective {
        EffectiveMode::Simple => crate::mode::Mode::Simple,
        EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => {
            crate::mode::Mode::Advanced
        }
    };
    let runs = crate::input_render::render_input_runs_in_mode(
        &app.input,
        cursor,
        theme,
        &app.schema_cache,
        mode_for_render,
    );
    let spans = runs_to_spans(&app.input, &runs);
    // ADR-0027 §4: the rightmost six columns of the input row
    // (a five-column label plus a one-column gap) are reserved
    // unconditionally, so the text area is always
    // `inner.width - 6` and the typed command never shifts
    // sideways when the validity indicator appears or hides.
    let inner = block.inner(area);
    frame.render_widget(block, area);
    let text_area = Rect {
        width: inner.width.saturating_sub(6),
        ..inner
    };
    frame.render_widget(Paragraph::new(Line::from(spans)), text_area);

    if let Some(severity) = app.input_indicator {
        let (indicator_label, color) = match severity {
            crate::dsl::walker::Severity::Error => ("[ERR]", theme.error),
            crate::dsl::walker::Severity::Warning => ("[WRN]", theme.warning),
        };
        let label_area = Rect {
            x: inner.x + inner.width.saturating_sub(5),
            width: 5.min(inner.width),
            ..inner
        };
        frame.render_widget(
            Paragraph::new(Line::from(Span::styled(
                indicator_label,
                Style::default().fg(color).add_modifier(Modifier::BOLD),
            ))),
            label_area,
        );
    }
}

/// Convert `StyledRun`s into ratatui `Span`s borrowed from
/// `input`. The end-of-input cursor sentinel (empty range) is
/// rendered as an inverted space.
fn runs_to_spans<'a>(
    input: &'a str,
    runs: &[crate::input_render::StyledRun],
) -> Vec<Span<'a>> {
    runs.iter()
        .map(|r| {
            if r.byte_range.0 == r.byte_range.1 {
                Span::styled(" ", r.style)
            } else {
                Span::styled(&input[r.byte_range.0..r.byte_range.1], r.style)
            }
        })
        .collect()
}

/// Strip a leading one-shot `:` sigil (and the whitespace after
/// it) from `input`, returning the advanced command slice and the
/// cursor remapped into it. Mirrors `App::submit`'s `:` handling
/// so the hint panel hints at the command, not the sigil
/// (ADR-0022 Amendment 1). Used only when the effective mode is
/// `AdvancedOneShot`, where `input` is guaranteed to start (after
/// any leading whitespace) with `:`.
fn strip_one_shot_prefix(input: &str, cursor: usize) -> (&str, usize) {
    let lead_ws = input.len() - input.trim_start().len();
    let after_colon = lead_ws + 1; // skip the `:`
    let ws_after = input[after_colon..].len() - input[after_colon..].trim_start().len();
    let prefix_len = (after_colon + ws_after).min(input.len());
    let effective = &input[prefix_len..];
    let effective_cursor = cursor.saturating_sub(prefix_len).min(effective.len());
    (effective, effective_cursor)
}

/// Resolve the Hint panel body into its rendered lines, pre-wrapped
/// to the panel's inner width and clamped to `MAX_HINT_ROWS` with an
/// ellipsis backstop (issue #12). The returned line count is the
/// content-row count `render_right_column` allocates for, so the
/// panel grows for a long hint and reclaims the space for a short
/// one.
///
/// Resolution order for the body:
///   1. An explicit app-set hint (e.g. modal contexts) wins.
///   2. Otherwise, with non-empty input, the ambient
///      typing-assistance hint (ADR-0022 §6) in the effective mode.
///   3. Otherwise, the empty-state placeholder.
///
/// Prose hints (1, the ambient `Prose` arm, and the placeholder)
/// word-wrap across up to `MAX_HINT_ROWS` rows. The candidate list
/// stays a single row and scrolls horizontally with `<` / `>`
/// markers (`render_candidate_line`) — it already self-fits, so it
/// is not wrapped.
///
/// ADR-0022 Amendment 1: advanced mode no longer skips ambient
/// hinting. The original §12 carve-out predated the unified
/// mode-aware walker (ADR-0030/0031/0032); the walker now speaks
/// SQL, so `ambient_hint_in_mode` surfaces SQL slot hints +
/// completion candidates in advanced mode too.
fn resolve_hint_lines(app: &App, theme: &Theme, area_width: u16) -> Vec<Line<'static>> {
    let inner = area_width.saturating_sub(2) as usize;
    let muted = Style::default().fg(theme.muted);
    let prose = |text: &str| {
        clamp_wrapped(text, inner, MAX_HINT_ROWS)
            .into_iter()
            .map(|l| Line::from(Span::styled(l, muted)))
            .collect::<Vec<Line<'static>>>()
    };

    // In one-shot advanced mode (`:` prefix in simple mode) the
    // raw input carries the `:` sigil, which is not part of the
    // grammar. Strip it for the ambient computation so the hint
    // reflects the advanced command — mirroring `App::submit`.
    let (hint_input, hint_cursor) = match app.effective_mode() {
        EffectiveMode::AdvancedOneShot => {
            strip_one_shot_prefix(&app.input, app.input_cursor)
        }
        _ => (app.input.as_str(), app.input_cursor),
    };
    let ambient = crate::input_render::ambient_hint_in_mode(
        hint_input,
        hint_cursor,
        app.last_completion.as_ref(),
        &app.schema_cache,
        app.effective_mode().as_mode(),
    );
    match (app.hint.as_deref(), ambient) {
        (Some(set), _) => prose(set),
        (None, Some(crate::input_render::AmbientHint::Prose(text))) => prose(&text),
        (None, Some(crate::input_render::AmbientHint::Candidates { items, selected })) => {
            vec![render_candidate_line(&items, selected, inner, theme)]
        }
        (None, None) => prose(&crate::t!("panel.hint_empty")),
    }
}

fn render_hint_panel(
    theme: &Theme,
    frame: &mut Frame<'_>,
    area: Rect,
    lines: Vec<Line<'static>>,
) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .border_style(Style::default().fg(theme.border))
        .title(Span::styled(
            format!(" {} ", crate::t!("panel.hint_title")),
            Style::default()
                .fg(theme.fg)
                .add_modifier(Modifier::BOLD),
        ))
        .style(Style::default().bg(theme.bg).fg(theme.fg));

    // Lines are already wrapped to the inner width by
    // `resolve_hint_lines`, so no Paragraph-level wrapping is needed.
    let paragraph = Paragraph::new(lines).block(block);
    frame.render_widget(paragraph, area);
}

/// Render the candidate-list line for the hint panel
/// (ADR-0022 §7 + the user's #2). Items are space-separated.
/// Each candidate is colour-coded by kind — keywords in
/// `tok_keyword`, identifiers in `tok_identifier` — so the
/// user can tell command grammar apart from schema names at
/// a glance (post-stage-8 user feedback). The selected item
/// (if any) gets bolded; when the items overflow `width`,
/// scroll markers `<` / `>` appear at the edges with the
/// window centred on the selection (or item 0 with no
/// selection).
///
/// Returns `Line<'static>` (each item cloned into its span)
/// so the caller doesn't have to manage the items' lifetime.
fn render_candidate_line(
    items: &[crate::completion::Candidate],
    selected: Option<usize>,
    width: usize,
    theme: &Theme,
) -> Line<'static> {
    if items.is_empty() {
        return Line::default();
    }
    let separator_style = Style::default().fg(theme.muted);
    let marker_style = Style::default().fg(theme.fg);
    // (ADR-0035 §4i e) When a shared entry word merged simple + advanced
    // continuations, the list mixes mode-classes — colour SQL-only
    // (`Advanced`) and DSL-only (`Simple`) continuations with the mode
    // palette so a learner sees which is which; `Both` (and every
    // single-mode list) keeps the token-kind colour, so the tint appears
    // only where it is informative.
    let mixed = {
        let mut seen = std::collections::HashSet::new();
        for c in items {
            seen.insert(c.mode.block_order());
        }
        seen.len() > 1
    };
    let style_for = |i: usize| {
        let kind_fg = match items[i].kind {
            crate::completion::CandidateKind::Keyword => theme.tok_keyword,
            crate::completion::CandidateKind::Identifier => theme.tok_identifier,
            crate::completion::CandidateKind::Flag => theme.tok_flag,
            crate::completion::CandidateKind::Punct => theme.tok_punct,
            crate::completion::CandidateKind::Function => theme.tok_function,
        };
        let base_fg = if mixed {
            match items[i].mode {
                crate::completion::ModeClass::Both => kind_fg,
                crate::completion::ModeClass::Advanced => theme.mode_advanced,
                crate::completion::ModeClass::Simple => theme.mode_simple,
            }
        } else {
            kind_fg
        };
        let mut s = Style::default().fg(base_fg);
        if Some(i) == selected {
            s = s.add_modifier(Modifier::BOLD);
        }
        s
    };

    let total_width: usize = items
        .iter()
        .map(|c| c.text.len() + 1)
        .sum::<usize>()
        .saturating_sub(1);
    if total_width <= width {
        let mut spans: Vec<Span<'static>> = Vec::with_capacity(items.len() * 2);
        for (i, item) in items.iter().enumerate() {
            if i > 0 {
                spans.push(Span::styled(" ".to_string(), separator_style));
            }
            spans.push(Span::styled(item.text.clone(), style_for(i)));
        }
        return Line::from(spans);
    }

    // Overflow path: window centred on `selected` (or item 0).
    // Reserve 4 chars for the `< ` / ` >` markers we may end
    // up using.
    let center = selected.unwrap_or(0);
    let mut left = center;
    let mut right = center;
    let mut used = items[center].text.len();
    let avail = width.saturating_sub(4);
    while (left > 0 || right + 1 < items.len()) && used < avail {
        if right + 1 < items.len() {
            let cost = items[right + 1].text.len() + 1;
            if used + cost <= avail {
                right += 1;
                used += cost;
                continue;
            }
        }
        if left > 0 {
            let cost = items[left - 1].text.len() + 1;
            if used + cost <= avail {
                left -= 1;
                used += cost;
                continue;
            }
        }
        break;
    }
    let need_left_marker = left > 0;
    let need_right_marker = right + 1 < items.len();

    let mut spans: Vec<Span<'static>> = Vec::with_capacity((right - left + 1) * 2 + 4);
    if need_left_marker {
        spans.push(Span::styled("< ".to_string(), marker_style));
    }
    for (offset, item) in items[left..=right].iter().enumerate() {
        if offset > 0 {
            spans.push(Span::styled(" ".to_string(), separator_style));
        }
        spans.push(Span::styled(item.text.clone(), style_for(left + offset)));
    }
    if need_right_marker {
        spans.push(Span::styled(" >".to_string(), marker_style));
    }
    Line::from(spans)
}

fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
    let key_style = Style::default()
        .fg(theme.fg)
        .add_modifier(Modifier::BOLD);
    let sep_style = Style::default().fg(theme.muted);
    let label_style = Style::default().fg(theme.muted);
    let bar_style = Style::default().bg(theme.bg).fg(theme.muted);

    let separator = Span::styled("  ·  ", sep_style);
    let mut spans: Vec<Span<'_>> = Vec::new();

    let push_shortcut = |spans: &mut Vec<Span<'_>>, key: &'static str, label: &str| {
        if !spans.is_empty() {
            spans.push(separator.clone());
        }
        spans.push(Span::styled(key, key_style));
        spans.push(Span::raw(" "));
        spans.push(Span::styled(label.to_string(), label_style));
    };

    let submit = crate::t!("shortcut.submit");
    push_shortcut(&mut spans, "Enter", &submit);
    let switch = crate::t!("shortcut.switch");
    let advanced_once = crate::t!("shortcut.advanced_once");
    let cancel_one_shot = crate::t!("shortcut.cancel_one_shot");
    let quit = crate::t!("shortcut.quit");
    match app.effective_mode() {
        EffectiveMode::Simple => {
            push_shortcut(&mut spans, ":", &advanced_once);
            push_shortcut(&mut spans, "mode advanced", &switch);
        }
        EffectiveMode::AdvancedPersistent => {
            push_shortcut(&mut spans, "mode simple", &switch);
        }
        EffectiveMode::AdvancedOneShot => {
            push_shortcut(&mut spans, "Backspace", &cancel_one_shot);
        }
    }
    push_shortcut(&mut spans, "Ctrl-C", &quit);

    let paragraph = Paragraph::new(Line::from(spans)).style(bar_style);
    frame.render_widget(paragraph, area);
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::{App, OutputSpan};
    use ratatui::Terminal;
    use ratatui::backend::TestBackend;

    #[test]
    fn styled_runs_payload_renders_each_span_with_its_class_colour() {
        // ADR-0028 §5: an OutputLine carrying a styled-runs
        // payload renders span-by-span, each run's semantic
        // class resolved to a theme colour.
        let theme = Theme::dark();
        let line = OutputLine::styled(
            "SCAN Customers".to_string(),
            OutputKind::System,
            Mode::Simple,
            vec![
                OutputSpan {
                    byte_range: (0, 4),
                    class: OutputStyleClass::Expensive,
                },
                OutputSpan {
                    byte_range: (4, 14),
                    class: OutputStyleClass::Neutral,
                },
            ],
        );
        let rendered = render_output_line(&line, &theme);
        // tag span + 2 run spans.
        assert_eq!(rendered.spans.len(), 3);
        assert_eq!(rendered.spans[1].content.as_ref(), "SCAN");
        assert_eq!(rendered.spans[1].style.fg, Some(theme.warning));
        assert_eq!(rendered.spans[2].content.as_ref(), " Customers");
        assert_eq!(rendered.spans[2].style.fg, Some(theme.fg));
    }

    #[test]
    fn hint_class_resolves_to_muted_foreground() {
        // ADR-0038 §4 / §6: the dim style class used for the
        // `Executing SQL:` prefix, the DontConvert caveat, and every
        // category-3 prose note. Pins the `theme.muted` resolution
        // across both palettes.
        for theme in [Theme::dark(), Theme::light()] {
            let style = output_span_style(OutputStyleClass::Hint, &theme);
            assert_eq!(
                style.fg,
                Some(theme.muted),
                "Hint must resolve to theme.muted on {:?} background",
                theme.background,
            );
        }
    }

    #[test]
    fn teaching_echo_line_renders_dim_prefix_and_lexed_sql() {
        // ADR-0038 §4 styled-runs polish: a TeachingEcho line is laid
        // out as [tag][dim prefix][lexed SQL spans]. The tag is the green
        // status colour (a `[system]` line — ADR-0037 Amendment, issue
        // #10); the `Executing SQL: ` prefix is `theme.muted`; the SQL
        // portion is re-lexed in advanced mode so it picks up keyword /
        // identifier / literal colours.
        let theme = Theme::dark();
        let line = OutputLine {
            text: format!(
                "{}{}",
                crate::echo::TEACHING_ECHO_LABEL,
                "CREATE TABLE T (id serial PRIMARY KEY)"
            ),
            kind: OutputKind::TeachingEcho,
            mode_at_submission: Mode::Advanced,
            styled_runs: None,
            status: None,
        };
        let rendered = render_output_line(&line, &theme);
        // [system] tag, then the dim prefix, then ≥1 SQL spans.
        assert!(rendered.spans.len() >= 3, "tag + prefix + sql: {:?}", rendered.spans);
        assert_eq!(rendered.spans[0].content.as_ref(), "[system] ");
        assert_eq!(rendered.spans[1].content.as_ref(), crate::echo::TEACHING_ECHO_LABEL);
        assert_eq!(
            rendered.spans[1].style.fg,
            Some(theme.muted),
            "prefix is dimmed (theme.muted)",
        );
        // At least one SQL span carries a keyword colour — `CREATE` is
        // the leading keyword and gets `tok_keyword`. Pinning this
        // also confirms the lexer ran in advanced mode (the bare
        // `CREATE` keyword is only highlighted past the entry word in
        // advanced — ADR-0030 §8).
        let has_keyword_span = rendered
            .spans
            .iter()
            .any(|s| s.style.fg == Some(theme.tok_keyword));
        assert!(
            has_keyword_span,
            "expected at least one keyword-coloured SQL span: {:?}",
            rendered.spans
        );
    }

    #[test]
    fn plain_text_matches_rendered_line_content() {
        // ADR-0041 drift-lock: `OutputLine::plain_text()` (the `copy`
        // payload) must equal the visible content `render_output_line`
        // produces — the concatenation of its span texts — for every
        // line shape. If the renderer changes how a line reads, this
        // fails until `plain_text` is brought back in step, so the
        // clipboard can never silently diverge from the screen.
        let theme = Theme::dark();
        let label = crate::echo::TEACHING_ECHO_LABEL;

        let mut pending = OutputLine::echo("create table T", Mode::Simple);
        pending.status = Some(EchoStatus::Pending);
        let mut ok = OutputLine::echo("create table T", Mode::Simple);
        ok.status = Some(EchoStatus::Ok);
        let mut err = OutputLine::echo("insert into T values (1)", Mode::Advanced);
        err.status = Some(EchoStatus::Err);

        let lines = vec![
            pending,
            ok,
            err,
            OutputLine {
                text: "  T".to_string(),
                kind: OutputKind::System,
                mode_at_submission: Mode::Simple,
                styled_runs: None,
                status: None,
            },
            OutputLine {
                text: "no such table".to_string(),
                kind: OutputKind::Error,
                mode_at_submission: Mode::Simple,
                styled_runs: None,
                status: None,
            },
            OutputLine {
                text: format!("{label}CREATE TABLE T (id serial)"),
                kind: OutputKind::TeachingEcho,
                mode_at_submission: Mode::Advanced,
                styled_runs: None,
                status: None,
            },
            OutputLine::styled(
                "SCAN Customers".to_string(),
                OutputKind::System,
                Mode::Simple,
                vec![
                    OutputSpan {
                        byte_range: (0, 4),
                        class: OutputStyleClass::Expensive,
                    },
                    OutputSpan {
                        byte_range: (4, 14),
                        class: OutputStyleClass::Neutral,
                    },
                ],
            ),
        ];

        for line in &lines {
            let rendered: String = render_output_line(line, &theme)
                .spans
                .iter()
                .map(|s| s.content.as_ref())
                .collect();
            assert_eq!(
                line.plain_text(),
                rendered,
                "plain_text drifted from render for a {:?} line",
                line.kind,
            );
        }
    }

    #[test]
    fn category_three_prose_line_renders_all_dim() {
        // ADR-0038 §6: the existing illuminating client_side notes and
        // the new --dont-convert caveat are de-emphasised prose. A
        // styled-runs payload with a single Hint span over the whole
        // text yields one dim body span (plus the [system] tag).
        let theme = Theme::dark();
        let text = "[client-side] 5 row(s) were transformed".to_string();
        let line = OutputLine::styled(
            text.clone(),
            OutputKind::System,
            Mode::Advanced,
            vec![OutputSpan {
                byte_range: (0, text.len()),
                class: OutputStyleClass::Hint,
            }],
        );
        let rendered = render_output_line(&line, &theme);
        assert_eq!(rendered.spans.len(), 2, "tag + one Hint span");
        assert_eq!(rendered.spans[0].content.as_ref(), "[system] ");
        assert_eq!(rendered.spans[1].content.as_ref(), text.as_str());
        assert_eq!(
            rendered.spans[1].style.fg,
            Some(theme.muted),
            "the whole prose line is dim",
        );
    }

    #[test]
    fn candidate_line_colours_mixed_mode_continuations() {
        // ADR-0035 §4i (e): when a shared entry word's completions mix
        // mode-classes, Advanced (SQL-only) → mode_advanced, Simple
        // (DSL-only) → mode_simple, Both → token-kind colour. Spans
        // alternate candidate / separator, so candidate `i` is span `2*i`.
        use crate::completion::{Candidate, CandidateKind, ModeClass};
        let theme = Theme::dark();
        let items = vec![
            Candidate { text: "table".into(), kind: CandidateKind::Keyword, mode: ModeClass::Both },
            Candidate { text: "index".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
            Candidate { text: "relationship".into(), kind: CandidateKind::Keyword, mode: ModeClass::Simple },
        ];
        let line = render_candidate_line(&items, None, 100, &theme);
        assert_eq!(line.spans[0].content.as_ref(), "table");
        assert_eq!(line.spans[0].style.fg, Some(theme.tok_keyword), "Both keeps the kind colour");
        assert_eq!(line.spans[2].content.as_ref(), "index");
        assert_eq!(line.spans[2].style.fg, Some(theme.mode_advanced), "Advanced → advanced colour");
        assert_eq!(line.spans[4].content.as_ref(), "relationship");
        assert_eq!(line.spans[4].style.fg, Some(theme.mode_simple), "Simple → simple colour");
    }

    #[test]
    fn candidate_line_single_mode_keeps_kind_colour() {
        // The mode tint applies ONLY when the list mixes classes. An
        // all-one-mode list (the common case, e.g. deep inside a SQL
        // statement) keeps the token-kind colours — no redundant tint.
        use crate::completion::{Candidate, CandidateKind, ModeClass};
        let theme = Theme::dark();
        let items = vec![
            Candidate { text: "values".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
            Candidate { text: "select".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
        ];
        let line = render_candidate_line(&items, None, 100, &theme);
        assert_eq!(
            line.spans[0].style.fg,
            Some(theme.tok_keyword),
            "an all-Advanced list is not tinted (would be redundant noise)"
        );
    }

    #[test]
    fn system_line_renders_green_tag_and_neutral_body() {
        // ADR-0037 Amendment (issue #10): the status-coloured-tag model.
        // A `[system]` line's TAG carries the green status colour; its
        // BODY is neutral `theme.fg`, not flooded green. The mode tint
        // no longer leaks onto system lines (it belongs to the echo line
        // alone — ADR-0037's stated purpose). `mode_at_submission` is
        // Advanced here precisely to prove the tag is NOT the mode tint.
        let theme = Theme::dark();
        let line = OutputLine {
            text: "plain system line".to_string(),
            kind: OutputKind::System,
            mode_at_submission: Mode::Advanced,
            styled_runs: None,
            status: None,
        };
        let rendered = render_output_line(&line, &theme);
        // tag span + single whole-line body span.
        assert_eq!(rendered.spans.len(), 2);
        assert_eq!(rendered.spans[0].content.as_ref(), "[system] ");
        assert_eq!(
            rendered.spans[0].style.fg,
            Some(theme.system),
            "the [system] tag is green (status), not the mode tint",
        );
        assert_eq!(rendered.spans[1].content.as_ref(), "plain system line");
        assert_eq!(
            rendered.spans[1].style.fg,
            Some(theme.fg),
            "the system body is neutral, not flooded green",
        );
    }

    #[test]
    fn error_line_renders_red_tag_and_bold_neutral_body() {
        // ADR-0037 Amendment (issue #10): the `[error]` TAG carries the
        // red status colour (the leftmost glyph the eye lands on), while
        // the BODY renders in neutral `theme.fg` + BOLD (rustc-style:
        // severity-coloured label, readable bold message). A wall of red
        // prose is hard to read; the red lives on the tag instead. The
        // mode tint does not leak onto error lines.
        let theme = Theme::dark();
        let line = OutputLine {
            text: "no such column: agx".to_string(),
            kind: OutputKind::Error,
            mode_at_submission: Mode::Advanced,
            styled_runs: None,
            status: None,
        };
        let rendered = render_output_line(&line, &theme);
        assert_eq!(rendered.spans.len(), 2);
        assert_eq!(rendered.spans[0].content.as_ref(), "[error]  ");
        assert_eq!(
            rendered.spans[0].style.fg,
            Some(theme.error),
            "the [error] tag is red (status), not the mode tint",
        );
        assert_eq!(rendered.spans[1].content.as_ref(), "no such column: agx");
        assert_eq!(
            rendered.spans[1].style.fg,
            Some(theme.fg),
            "the error body is neutral fg, not flooded red",
        );
        assert!(
            rendered.spans[1].style.add_modifier.contains(Modifier::BOLD),
            "the error body is bold for weight without the red-wall readability cost",
        );
    }

    #[test]
    fn echo_tag_keeps_the_mode_tint_not_a_status_colour() {
        // The echo line is the sole exception to the status-tag model:
        // its tag's whole job is to label the submission mode (ADR-0037),
        // so it keeps the mode tint. Per-command success rides the trailing
        // ✓/✗ marker (ADR-0040), not the tag. Locked for both modes so a
        // future refactor of `tag_style` cannot regress the echo.
        let theme = Theme::dark();
        for (mode, want) in [
            (Mode::Simple, theme.mode_simple),
            (Mode::Advanced, theme.mode_advanced),
        ] {
            let line = OutputLine {
                text: format!("{}create table T", crate::dsl::ECHO_PREFIX),
                kind: OutputKind::Echo,
                mode_at_submission: mode,
                styled_runs: None,
                status: None,
            };
            let rendered = render_output_line(&line, &theme);
            assert_eq!(
                rendered.spans[0].style.fg,
                Some(want),
                "echo tag must stay the {mode:?} mode tint",
            );
        }
    }

    #[test]
    fn teaching_echo_tag_is_green_like_other_system_lines() {
        // A TeachingEcho is a `[system]`-tagged line, so under the
        // status-tag model its tag is green, not the mode tint. The dim
        // prefix + lexed-SQL body are unchanged (covered separately).
        let theme = Theme::dark();
        let line = OutputLine {
            text: format!(
                "{}{}",
                crate::echo::TEACHING_ECHO_LABEL,
                "CREATE TABLE T (id serial PRIMARY KEY)"
            ),
            kind: OutputKind::TeachingEcho,
            mode_at_submission: Mode::Advanced,
            styled_runs: None,
            status: None,
        };
        let rendered = render_output_line(&line, &theme);
        assert_eq!(rendered.spans[0].content.as_ref(), "[system] ");
        assert_eq!(
            rendered.spans[0].style.fg,
            Some(theme.system),
            "the teaching-echo tag is green (a [system] line), not the mode tint",
        );
    }

    #[test]
    fn error_and_system_tags_are_distinguishable_in_both_themes() {
        // Issue #10 regression guard, stated directly: the `[error]` and
        // `[system]` tags must NOT render in the same colour, and neither
        // may collapse to the mode tint. Asserted on both palettes — the
        // render logic is theme-agnostic, but locking both proves the
        // colours themselves stay distinct end to end.
        for theme in [Theme::dark(), Theme::light()] {
            let tag_fg = |kind| {
                render_output_line(
                    &OutputLine {
                        text: "x".to_string(),
                        kind,
                        mode_at_submission: Mode::Advanced,
                        styled_runs: None,
                        status: None,
                    },
                    &theme,
                )
                .spans[0]
                    .style
                    .fg
            };
            let error_tag = tag_fg(OutputKind::Error);
            let system_tag = tag_fg(OutputKind::System);
            assert_ne!(
                error_tag, system_tag,
                "[error] and [system] tags must differ ({:?})",
                theme.background,
            );
            assert_ne!(
                error_tag,
                Some(theme.mode_advanced),
                "the error tag must not be the mode tint ({:?})",
                theme.background,
            );
            assert_ne!(
                system_tag,
                Some(theme.mode_advanced),
                "the system tag must not be the mode tint ({:?})",
                theme.background,
            );
        }
    }

    fn render_to_string(app: &mut App, theme: &Theme, width: u16, height: u16) -> String {
        // Snapshot tests need realistic state, not the boot
        // fallback "(no project)" — every real session has a
        // project. Set a representative name unless the test
        // already set one.
        if app.project_name.is_none() {
            app.project_name = Some("Term Planner".to_string());
        }
        let backend = TestBackend::new(width, height);
        let mut terminal = Terminal::new(backend).expect("create terminal");
        terminal
            .draw(|f| render(app, theme, f))
            .expect("draw frame");
        let buffer = terminal.backend().buffer().clone();
        let mut out = String::new();
        for y in 0..buffer.area.height {
            for x in 0..buffer.area.width {
                out.push_str(buffer[(x, y)].symbol());
            }
            out.push('\n');
        }
        out
    }

    // ---- Issue #12: long hints no longer clipped to one row -----

    const LONG_HINT: &str = "(id, created_at auto-generated — skipped here; list columns explicitly, e.g. `insert into T (...) values (...)`, to set it.)";

    #[test]
    fn long_prose_hint_shows_tail_across_multiple_rows() {
        // Before the fix the Hint panel was a fixed 1 content row,
        // so this hint's useful tail was clipped. Now the panel
        // grows (to MAX_HINT_ROWS) so the tail is visible.
        let mut app = App::new();
        app.hint = Some(LONG_HINT.to_string());
        let theme = Theme::dark();
        let out = render_to_string(&mut app, &theme, 80, 20);
        assert!(
            out.contains("list columns explicitly"),
            "the hint tail must be visible, not clipped:\n{out}"
        );
    }

    #[test]
    fn short_hint_keeps_panel_at_one_content_row() {
        // Reclaim: a short hint must not inflate the panel.
        let mut app = App::new();
        app.hint = Some("Type a command".to_string());
        let theme = Theme::dark();
        let out = render_to_string(&mut app, &theme, 80, 20);
        assert!(
            out.lines().any(|l| l.contains("Type a command")),
            "short hint visible:\n{out}"
        );
        assert_eq!(
            hint_content_rows(&out),
            1,
            "short hint should occupy exactly one content row:\n{out}"
        );
    }

    #[test]
    fn long_hint_grows_panel_but_caps_at_max_rows() {
        let mut app = App::new();
        app.hint = Some(LONG_HINT.to_string());
        let theme = Theme::dark();
        // Narrow width forces more wrapped lines than the cap.
        let out = render_to_string(&mut app, &theme, 44, 20);
        assert_eq!(
            hint_content_rows(&out),
            MAX_HINT_ROWS,
            "long hint caps at MAX_HINT_ROWS content rows:\n{out}"
        );
    }

    /// Count the content rows inside the Hint panel of a rendered
    /// screen: the rows between the `╭ Hint …` title border and the
    /// next `╰…╯` bottom border.
    fn hint_content_rows(out: &str) -> usize {
        let lines: Vec<&str> = out.lines().collect();
        let top = lines
            .iter()
            .position(|l| l.contains("Hint") && l.contains('╭'))
            .expect("hint title border present");
        // Rows strictly between the title border and the next
        // bottom border == the content-row count.
        lines[top + 1..]
            .iter()
            .position(|l| l.contains('╰'))
            .expect("hint bottom border present")
    }

    #[test]
    fn clamp_wrapped_truncates_with_ellipsis_past_max() {
        // ≤ max rows: untouched.
        let two = clamp_wrapped("alpha beta gamma delta", 11, 3);
        assert_eq!(two, vec!["alpha beta", "gamma delta"]);
        // > max rows: clamp to max, last row ends with an ellipsis,
        // and every row stays within the width.
        let many = clamp_wrapped(
            "alpha beta gamma delta epsilon zeta eta theta iota",
            11,
            3,
        );
        assert_eq!(many.len(), 3);
        assert!(many[2].ends_with('…'), "last row ellipsized: {many:?}");
        for row in &many {
            assert!(row.chars().count() <= 11, "row within width: {row:?}");
        }
    }

    #[test]
    fn dark_theme_default_view_snapshot() {
        let mut app = App::new();
        let theme = Theme::dark();
        let snapshot = render_to_string(&mut app, &theme, 80, 24);
        insta::assert_snapshot!("default_simple_dark", snapshot);
    }

    #[test]
    fn light_theme_default_view_snapshot() {
        let mut app = App::new();
        let theme = Theme::light();
        let snapshot = render_to_string(&mut app, &theme, 80, 24);
        insta::assert_snapshot!("default_simple_light", snapshot);
    }

    #[test]
    fn advanced_mode_default_view_snapshot() {
        let mut app = App::new();
        app.mode = Mode::Advanced;
        let theme = Theme::dark();
        let snapshot = render_to_string(&mut app, &theme, 80, 24);
        insta::assert_snapshot!("default_advanced_dark", snapshot);
    }

    #[test]
    fn advanced_mode_hint_panel_surfaces_sql_candidates() {
        // Regression reproduction (ADR-0022 Amendment 1): in
        // advanced mode the hint panel must surface ambient
        // assistance for SQL — here the FROM-slot table candidate
        // `Customers` — not the empty placeholder. Before the fix
        // `render_hint_panel` returned `None` for advanced mode and
        // the hint resolver/completion ran in simple mode, so a SQL
        // statement got the "this is SQL" gate and no candidates.
        let mut app = App::new();
        app.mode = Mode::Advanced;
        app.schema_cache.tables.push("Customers".to_string());
        app.input.push_str("select * from ");
        app.input_cursor = app.input.len();
        let theme = Theme::dark();
        let rendered = render_to_string(&mut app, &theme, 80, 24);
        assert!(
            rendered.contains("Customers"),
            "advanced-mode hint panel should surface the FROM-slot \
             candidate `Customers`; got:\n{rendered}",
        );
    }

    #[test]
    fn highlighted_input_all_token_classes_snapshot() {
        // ADR-0022 stage 2: representative input that exercises
        // every token class — keyword (insert / into / values
        // / null), identifier (T), number (1), string ('hi'),
        // punct (parens, comma), flag (--all-rows), lex error
        // ($ at the end). The snapshot captures the rendered
        // text symbols only — `render_to_string` does not record
        // ratatui style — so this test is a regression net for
        // text layout, not for colour mappings. Colour mappings
        // are unit-tested in `input_render::tests`.
        let mut app = App::new();
        app.input
            .push_str("insert into T values (1, 'hi', null) --all-rows $");
        app.input_cursor = app.input.len();
        let theme = Theme::dark();
        let snapshot = render_to_string(&mut app, &theme, 80, 24);
        insta::assert_snapshot!("highlighted_input_all_token_classes_dark", snapshot);
    }

    #[test]
    fn one_shot_advanced_prompt_snapshot() {
        // Typing `:sel` in simple mode should flip the input panel
        // label to `Advanced:` while the persistent mode stays simple.
        // The visible input includes the auto-inserted space after `:`.
        // With the cursor after `sel` (ADR-0022 Amendment 1), the hint
        // panel now offers the advanced `select` completion — the `:`
        // sigil is stripped before the ambient walk.
        let mut app = App::new();
        app.input.push_str(": sel");
        app.input_cursor = app.input.len();
        let theme = Theme::dark();
        let snapshot = render_to_string(&mut app, &theme, 80, 24);
        insta::assert_snapshot!("one_shot_advanced_dark", snapshot);
    }

    #[test]
    fn rebuild_confirm_modal_snapshot() {
        use crate::app::{Modal, RebuildConfirmModal};
        let mut app = App::new();
        app.modal = Some(Modal::RebuildConfirm(RebuildConfirmModal {
            summary: "3 tables and 47 rows will be reconstructed; \
                      the existing playground.db will be replaced"
                .to_string(),
        }));
        let theme = Theme::dark();
        let snapshot = render_to_string(&mut app, &theme, 80, 24);
        insta::assert_snapshot!("rebuild_confirm_modal_dark", snapshot);
    }

    // ---- ADR-0040: echo completion marker -----------------------

    #[test]
    fn echo_renders_running_then_marker_per_status() {
        use crate::app::EchoStatus;
        let mut app = App::new();
        // Pending → `running: <input>` (current look).
        app.output
            .push_back(OutputLine::echo("drop table Orders", Mode::Advanced));
        // Ok → `<input> ✓`, no `running:`.
        let mut ok = OutputLine::echo("create table T with pk", Mode::Simple);
        ok.status = Some(EchoStatus::Ok);
        app.output.push_back(ok);
        // Err → `<input> ✗`, no `running:`.
        let mut err = OutputLine::echo("insert into T values (1)", Mode::Advanced);
        err.status = Some(EchoStatus::Err);
        app.output.push_back(err);

        let out = render_to_string(&mut app, &Theme::dark(), 100, 20);
        assert!(out.contains("running: drop table Orders"), "pending keeps running::\n{out}");
        assert!(out.contains("create table T with pk ✓"), "ok shows ✓:\n{out}");
        assert!(out.contains("insert into T values (1) ✗"), "err shows ✗:\n{out}");
        assert!(
            !out.contains("running: create table"),
            "a completed echo drops the running: prefix:\n{out}"
        );
    }

    // ---- Issue #13: undo confirm dialog -------------------------

    #[test]
    fn format_local_datetime_renders_fixed_human_form() {
        // Deterministic: a fixed offset (not Local) so the output
        // does not depend on the test machine's timezone.
        let dt = chrono::DateTime::parse_from_rfc3339("2026-05-24T11:05:00+02:00")
            .expect("valid rfc3339");
        assert_eq!(format_local_datetime(dt), "24 May 2026, 11:05");
        // Single-digit day: no leading zero on the day, but zero-
        // padded hour.
        let dt = chrono::DateTime::parse_from_rfc3339("2026-05-04T09:05:00+00:00")
            .expect("valid rfc3339");
        assert_eq!(format_local_datetime(dt), "4 May 2026, 09:05");
    }

    #[test]
    fn format_snapshot_timestamp_drops_machine_syntax() {
        // The stored UTC string is reformatted: no 'T'/'Z' machine
        // syntax survives, and the year is preserved. (Day/month
        // can shift across the date line depending on local TZ, so
        // we assert only the stable parts.)
        let out = format_snapshot_timestamp("2026-07-24T10:00:00Z");
        assert!(!out.contains('T'), "no date/time 'T' separator: {out}");
        assert!(!out.contains('Z'), "no UTC 'Z' suffix: {out}");
        assert!(out.contains("2026"), "year preserved: {out}");
    }

    #[test]
    fn format_snapshot_timestamp_falls_back_on_garbage() {
        assert_eq!(format_snapshot_timestamp("not a timestamp"), "not a timestamp");
    }

    #[test]
    fn undo_dialog_width_grows_to_fit_and_clamps() {
        // Grows to the widest line + 4 (borders + padding).
        assert_eq!(undo_dialog_width([50usize], 120), 54);
        // Floors at MIN (34) for tiny content.
        assert_eq!(undo_dialog_width([3usize], 120), 34);
        // Caps at MAX (100) for absurdly long content.
        assert_eq!(undo_dialog_width([400usize], 120), 100);
        // Never exceeds the available area, and never panics when
        // the area is narrower than MIN.
        assert_eq!(undo_dialog_width([50usize], 40), 40);
        assert_eq!(undo_dialog_width([50usize], 10), 10);
    }

    #[test]
    fn undo_modal_command_does_not_wrap_on_wide_terminal() {
        use crate::app::{Modal, UndoConfirmModal};
        let mut app = App::new();
        app.modal = Some(Modal::UndoConfirm(UndoConfirmModal {
            command: "insert into Customers values (1, 'Oliver Sturm')".to_string(),
            timestamp: "2026-05-24T10:00:00Z".to_string(),
            is_redo: false,
        }));
        let theme = Theme::dark();
        let out = render_to_string(&mut app, &theme, 120, 30);
        assert!(
            out.lines().any(|l| l.contains(
                "This will undo: insert into Customers values (1, 'Oliver Sturm')"
            )),
            "command must sit on one row on a wide terminal:\n{out}"
        );
    }

    #[test]
    fn undo_modal_uses_capitalized_labels_and_formatted_time() {
        use crate::app::{Modal, UndoConfirmModal};
        let mut app = App::new();
        app.modal = Some(Modal::UndoConfirm(UndoConfirmModal {
            command: "delete from T where id = 1".to_string(),
            timestamp: "2026-05-24T10:00:00Z".to_string(),
            is_redo: false,
        }));
        let theme = Theme::dark();
        let out = render_to_string(&mut app, &theme, 120, 30);
        assert!(out.contains("Snapshot taken"), "capitalized Snapshot:\n{out}");
        assert!(out.contains("[Y] Yes"), "capitalized Yes:\n{out}");
        assert!(out.contains("[N] No"), "capitalized No:\n{out}");
        assert!(
            !out.contains("2026-05-24T10:00:00Z"),
            "raw ISO timestamp must not appear:\n{out}"
        );
    }

    #[test]
    fn populated_with_table_snapshot() {
        // Items panel lists tables; output panel shows the
        // structure of the current table.
        use crate::app::{OutputKind, OutputLine};
        use crate::db::{ColumnDescription, TableDescription};

        let mut app = App::new();
        app.tables = vec!["Customers".to_string(), "Orders".to_string()];
        use crate::dsl::Type;
        let desc = TableDescription {
            name: "Customers".to_string(),
            columns: vec![
                ColumnDescription {
                    name: "id".to_string(),
                    user_type: Some(Type::Serial),
                    sqlite_type: "INTEGER".to_string(),
                    notnull: false,
                    primary_key: true,
                    unique: false,
                    default: None,
                    check: None,
                },
                ColumnDescription {
                    name: "Name".to_string(),
                    user_type: Some(Type::Text),
                    sqlite_type: "TEXT".to_string(),
                    notnull: false,
                    primary_key: false,
                    unique: false,
                    default: None,
                    check: None,
                },
            ],
            outbound_relationships: Vec::new(),
            inbound_relationships: Vec::new(),
            indexes: Vec::new(),
            unique_constraints: Vec::new(),
            check_constraints: Vec::new(),
        };
        app.current_table = Some(desc);
        // Mirror what the App writes when a DSL command succeeds
        // (ADR-0040): the command's echo line resolves to a ✓ marker —
        // there is no separate `[ok]` summary line.
        let mut echo = OutputLine::echo("create table Customers", Mode::Simple);
        echo.status = Some(crate::app::EchoStatus::Ok);
        app.output.push_back(echo);
        app.output.push_back(OutputLine {
            text: "  Customers".to_string(),
            kind: OutputKind::System,
            mode_at_submission: Mode::Simple,
            styled_runs: None,
            status: None,
        });
        app.output.push_back(OutputLine {
            text: "    id serial [PK]".to_string(),
            kind: OutputKind::System,
            mode_at_submission: Mode::Simple,
            styled_runs: None,
            status: None,
        });
        app.output.push_back(OutputLine {
            text: "    Name text".to_string(),
            kind: OutputKind::System,
            mode_at_submission: Mode::Simple,
            styled_runs: None,
            status: None,
        });

        let theme = Theme::dark();
        let snapshot = render_to_string(&mut app, &theme, 80, 24);
        insta::assert_snapshot!("populated_with_table_dark", snapshot);
    }

    #[test]
    fn items_panel_nests_indexes_under_their_table() {
        // S2 (ADR-0025): the items panel renders each table
        // with its index names indented beneath it. A UNIQUE index is
        // marked `[unique]` (ADR-0035 §4d).
        use crate::completion::IndexEntry;
        let mut app = App::new();
        app.tables = vec!["Customers".to_string(), "Orders".to_string()];
        app.schema_cache.table_indexes.insert(
            "Customers".to_string(),
            vec![
                IndexEntry { name: "idx_email".to_string(), unique: false },
                IndexEntry { name: "uidx_login".to_string(), unique: true },
            ],
        );
        let theme = Theme::dark();
        let out = render_to_string(&mut app, &theme, 80, 24);
        assert!(out.contains("Customers"), "table listed:\n{out}");
        assert!(out.contains("Orders"), "table listed:\n{out}");
        assert!(out.contains("idx_email"), "index nested in panel:\n{out}");
        assert!(out.contains("uidx_login [unique]"), "unique index marked:\n{out}");
    }
}
