//! 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, NavFocus, 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.
/// Minimum terminal width at which the schema sidebar (the left items
/// column) is shown by default (ADR-0046 DB1). At or below this the
/// sidebar is hidden so the output/input panels get the full width —
/// notably the 90-column screencasts. Tunable.
const SIDEBAR_MIN_WIDTH: u16 = 90;

/// Whether the schema sidebar is visible — a pure function of terminal
/// width (ADR-0046 DB1). Phase C will also reveal it while a sidebar
/// panel is focused via the Ctrl-O peek.
const fn sidebar_visible(total_width: u16) -> bool {
    total_width > SIDEBAR_MIN_WIDTH
}

/// Height (including borders) of the Relationships sub-panel within the
/// left column (ADR-0046 DB4): floored at 5 rows (so an empty panel
/// shows "(none)"), grown with `content_rows` up to half the column,
/// and never so tall that the Tables panel above drops below 3 rows.
const fn relationships_panel_height(col_h: u16, content_rows: u16) -> u16 {
    let want = content_rows + 2; // + top/bottom borders
    let mut h = if want < 5 { 5 } else { want };
    let cap = col_h / 2; // never more than half the column
    if h > cap {
        h = cap;
    }
    let max_h = col_h.saturating_sub(3); // leave Tables at least 3 rows
    if h > max_h {
        h = max_h;
    }
    h
}

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);

    // ADR-0046 DB1: on a wide terminal the schema sidebar takes a fixed
    // left column; at or below SIDEBAR_MIN_WIDTH it is hidden and the
    // right column spans the full width.
    if sidebar_visible(area.width) {
        let columns = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Length(28), Constraint::Min(20)])
            .split(outer[0]);
        // ADR-0046 DB4: the sidebar stacks Tables (top) over a
        // Relationships panel (bottom), the latter content-sized within
        // [5 rows, half the column].
        let rel_content = (app.relationships.len() as u16).saturating_mul(3);
        let rel_h = relationships_panel_height(columns[0].height, rel_content);
        let sidebar = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Min(3), Constraint::Length(rel_h)])
            .split(columns[0]);
        render_items_panel(app, theme, frame, sidebar[0]);
        render_relationships_panel(app, theme, frame, sidebar[1]);
        render_right_column(app, theme, frame, columns[1]);
    } else {
        render_right_column(app, theme, frame, outer[0]);
    }
    render_project_label(app, theme, frame, outer[1]);
    render_status_bar(app, theme, frame, outer[2]);

    // ADR-0046 DC2: in navigation mode, draw the focused sidebar as an
    // expanded overlay over the (unchanged) base render — revealing it
    // if it was hidden (peek) and widening it for browsing. Drawn below
    // the modal layer; a modal can't open in navigation mode, but if one
    // is somehow up it still wins.
    if app.nav_focus.in_sidebar() {
        render_nav_sidebar_overlay(app, theme, frame, outer[0]);
    }

    // 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);
    }

    // ADR-0047 D4: the demo overlays draw last of all — over modals — so
    // a keystroke badge (and, in Phase C, a step caption) stays visible
    // while the load picker (the #24 cast) or any modal is up.
    if app.demo_mode {
        render_demo_overlays(app, frame);
    }
}

/// The fixed high-contrast style for every demo overlay (ADR-0047 D4):
/// bold black text on a yellow background.
fn demo_overlay_style() -> Style {
    Style::default()
        .bg(crate::theme::DEMO_OVERLAY_BG)
        .fg(crate::theme::DEMO_OVERLAY_FG)
        .add_modifier(Modifier::BOLD)
}

/// Draw the demonstration-mode overlays anchored to the output panel's
/// inner bottom-right corner (ADR-0047 D4): the step caption (if any) at
/// the bottom, the keystroke badge stacked directly above it (or at the
/// bottom when there is no caption). Both are inset one cell and skipped
/// rather than overflowing when the area is too small.
fn render_demo_overlays(app: &App, frame: &mut Frame<'_>) {
    let area = app.last_output_area;
    if area.width == 0 || area.height == 0 {
        return; // not measured yet
    }
    // Caption first — it owns the bottom-right corner. The badge then
    // stacks above whatever the caption actually occupied.
    let caption_rect = app
        .demo_caption
        .as_deref()
        .and_then(|text| render_caption_box(text, area, frame));
    if let Some(label) = app.demo_badge {
        render_badge_box(label, area, caption_rect, frame);
    }
}

/// Paint a flat filled overlay rectangle — a solid yellow block with no
/// border glyphs (ADR-0047 D4) — and lay `body` inside a one-cell
/// margin. The borderless solid block is deliberately *unlike* the app's
/// bordered panels, so the demo overlays read as a distinct callout.
fn fill_overlay_rect(rect: Rect, body: String, frame: &mut Frame<'_>) {
    frame.render_widget(ratatui::widgets::Clear, rect);
    // `Block` with no borders fills the whole rect with the overlay
    // background (same mechanism as `paint_background`).
    frame.render_widget(Block::default().style(demo_overlay_style()), rect);
    let inner = rect.inner(Margin {
        horizontal: 1,
        vertical: 1,
    });
    frame.render_widget(Paragraph::new(body).style(demo_overlay_style()), inner);
}

/// A small high-contrast keystroke badge (`[TAB]`, `[ENTER]`, …) inset
/// one cell from the bottom-right of `area` (ADR-0047 D2/D4) — the label
/// on a flat yellow rectangle with a one-cell margin. When a caption box
/// is present (`above`), the badge sits directly on top of it, right
/// edges aligned; otherwise it takes the bottom-right corner. Skipped
/// rather than overflowing if it cannot fit.
fn render_badge_box(label: &str, area: Rect, above: Option<Rect>, frame: &mut Frame<'_>) {
    let box_w = label.chars().count() as u16 + 2; // one-cell margin each side
    let box_h = 3; // text row + a margin row above and below
    if box_w + 1 > area.width {
        return;
    }
    let x = area.x + area.width - box_w - 1;
    let y = match above {
        // Directly above the caption, right edges aligned.
        Some(c) => {
            if c.y < area.y + box_h {
                return; // no room above the caption
            }
            c.y - box_h
        }
        None => {
            if box_h + 1 > area.height {
                return;
            }
            area.y + area.height - box_h - 1
        }
    };
    fill_overlay_rect(
        Rect {
            x,
            y,
            width: box_w,
            height: box_h,
        },
        label.to_string(),
        frame,
    );
}

/// A step-caption box inset one cell from the bottom-right of `area`
/// (ADR-0047 D3/D4): the text word-wrapped to at most 3 lines within a
/// corner-sized width, bold black on a flat yellow rectangle. Returns
/// the rect it drew, or `None` if it was too small to place (so the
/// badge can fall back to the bottom-right corner).
fn render_caption_box(text: &str, area: Rect, frame: &mut Frame<'_>) -> Option<Rect> {
    // Content width capped so the box stays corner-sized; the caption
    // wraps to ≤ 3 lines and ellipsises beyond (D4).
    let content_w = 40.min(area.width.saturating_sub(4)) as usize;
    if content_w < 4 {
        return None; // output too narrow for a useful caption
    }
    let lines = clamp_wrapped(text, content_w, 3);
    let inner_w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
    let box_w = inner_w as u16 + 2; // one-cell margin each side
    let box_h = lines.len() as u16 + 2; // text rows + a margin row above and below
    if box_w + 1 > area.width || box_h + 1 > area.height {
        return None;
    }
    let rect = Rect {
        x: area.x + area.width - box_w - 1,
        y: area.y + area.height - box_h - 1,
        width: box_w,
        height: box_h,
    };
    fill_overlay_rect(rect, lines.join("\n"), frame);
    Some(rect)
}

/// Width (columns) of the navigation-mode expanded sidebar overlay
/// (ADR-0046 DC2). Wide enough that most relationship endpoints fit on
/// one line, turning horizontal truncation into vertical scrolling.
const NAV_EXPANDED_WIDTH: u16 = 45;

/// Blank columns cleared to the right of the expanded sidebar overlay
/// (ADR-0046 DC2), separating it from the base panels left visible
/// behind it so the overlay's right edge reads cleanly.
const NAV_OVERLAY_GUTTER: u16 = 1;

/// Draw the focused sidebar, expanded, as an overlay over the left of
/// the main content area (ADR-0046 DC2/DC3). `Clear` + a background
/// repaint hide the base render underneath; the two panels keep the
/// DB4 split. The focused panel is accent-bordered (DC3).
fn render_nav_sidebar_overlay(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
    // ADR-0046 DC2: clear the sidebar strip plus a one-column gutter and
    // paint the expanded sidebar over it. The base output / input / hint
    // stay visible to the right — unchanged, just partially occluded —
    // and the gutter keeps them from butting against the sidebar's
    // border. They are restored fully on the next frame when navigation
    // mode exits.
    let width = NAV_EXPANDED_WIDTH.min(area.width);
    let cleared_w = (width + NAV_OVERLAY_GUTTER).min(area.width);
    let cleared = Rect {
        x: area.x,
        y: area.y,
        width: cleared_w,
        height: area.height,
    };
    frame.render_widget(ratatui::widgets::Clear, cleared);
    paint_background(theme, frame, cleared);
    let sidebar = Rect {
        x: area.x,
        y: area.y,
        width,
        height: area.height,
    };
    let rel_content = (app.relationships.len() as u16).saturating_mul(3);
    let rel_h = relationships_panel_height(sidebar.height, rel_content);
    let parts = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(3), Constraint::Length(rel_h)])
        .split(sidebar);
    render_items_panel(app, theme, frame, parts[0]);
    render_relationships_panel(app, theme, frame, parts[1]);
}

/// Border style for a sidebar panel: a non-bold **accent colour**
/// border when it holds navigation focus (ADR-0046 DC3, refined by
/// Amendment 1 / issue #25), the muted border otherwise. The focus
/// cue is the accent hue, NOT `Modifier::BOLD` — bold box-drawing
/// glyphs render as broken/gapped line-art in the asciinema player
/// and are fragile in some terminals.
fn panel_border_style(theme: &Theme, focused: bool) -> Style {
    if focused {
        Style::default().fg(theme.mode_simple)
    } else {
        Style::default().fg(theme.border)
    }
}

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
}

/// Absolute ceiling on Hint-panel content rows. Per ADR-0046 (DA1/DA2)
/// the panel's height is no longer driven by the hint *content* — it is
/// a pure function of terminal geometry (`hint_rows`), fixed between
/// resizes, so it cannot resize mid-typing and shove the input/output
/// panels (#20). This is the most rows `hint_rows` will ever allocate;
/// a hint longer than the allocation is ellipsis-truncated (issue #12's
/// overflow signalling is retained — only the *sizing* changed).
const MAX_HINT_ROWS: usize = 3;

/// Terminal heights below this are "compact" (covers the ~25-row
/// screencasts); at or above it the terminal is "comfortable" and can
/// afford taller panels (ADR-0046 DA2). Tunable.
const COMFORTABLE_MIN_HEIGHT: u16 = 40;

/// A 3rd hint row is only ever needed when the hint column's inner
/// width is narrow enough to wrap the longest catalog hint past two
/// lines; at or above this width two rows always suffice (ADR-0046 DA2,
/// measured against `src/friendly/strings/en-US.yaml`).
const HINT_THIRD_ROW_MAX_INNER: u16 = 54;

/// Input- and hint-panel content-row counts as a pure function of the
/// right column's geometry (ADR-0046 DA1/DA2/DA4) — NOT of the hint or
/// input text. That is the point of #20: heights fixed per terminal
/// size cannot jump as the user types. Returns `(input_rows,
/// hint_rows)`; add 2 to each for the panel borders.
///
/// - Compact height (`< COMFORTABLE_MIN_HEIGHT`): input 1 row, hint 2.
/// - Comfortable height: input 2 rows (DA4 two-row display); hint 2, or
///   3 when the column is narrow enough (`inner <
///   HINT_THIRD_ROW_MAX_INNER`) that the longest hint needs a third
///   line.
/// - Degradation: on a terminal too short to honour the output panel's
///   `Min(5)` plus both panels, the hint shrinks first, then the input,
///   so the output keeps its floor.
const fn panel_heights(area: Rect) -> (u16, u16) {
    let comfortable = area.height >= COMFORTABLE_MIN_HEIGHT;
    let inner_w = area.width.saturating_sub(2);
    let mut input_c: u16 = if comfortable { 2 } else { 1 };
    let mut hint_c: u16 = if !comfortable {
        2
    } else if inner_w < HINT_THIRD_ROW_MAX_INNER {
        MAX_HINT_ROWS as u16
    } else {
        2
    };
    // Honour the output panel's Min(5) first on a very short terminal:
    // 5 (output) + (input_c + 2) + (hint_c + 2) must fit in the column.
    // Shrink the hint first, then the input.
    while 5 + (input_c + 2) + (hint_c + 2) > area.height {
        if hint_c > 1 {
            hint_c -= 1;
        } else if input_c > 1 {
            input_c -= 1;
        } else {
            break;
        }
    }
    (input_c, hint_c)
}

/// 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) {
    // ADR-0046 DA1/DA2: the Hint panel's height is a pure function of
    // the column geometry, fixed between resizes — it no longer tracks
    // the hint content, so typing cannot make it resize and shove the
    // input/output panels (#20). The hint is then clamped to that fixed
    // row budget. The hint panel spans the full column width, so
    // `area.width` is its width too.
    let (input_c, hint_c) = panel_heights(area);
    let hint_lines = resolve_hint_lines(app, theme, area.width, hint_c as usize);

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Min(5),              // Output panel
            Constraint::Length(input_c + 2), // Input panel (1 row, or 2 when tall)
            Constraint::Length(hint_c + 2),  // Hint panel (geometry-fixed)
        ])
        .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: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .border_style(panel_border_style(
            theme,
            app.nav_focus == NavFocus::SidebarTables,
        ))
        .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));

    // ADR-0046 DC3: clamp + store the scroll before the (borrowing)
    // lines are built. Visible rows and the content total are computed
    // by counting (one row per table + one per index), so the `&mut
    // app` writes finish before the immutable line borrows begin.
    let visible = area.height.saturating_sub(2) as usize;
    app.last_tables_visible = visible;

    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 total: usize = app
        .tables
        .iter()
        .map(|t| 1 + app.schema_cache.table_indexes.get(t).map_or(0, Vec::len))
        .sum();
    let offset = app.tables_scroll.min(total.saturating_sub(visible));
    app.tables_scroll = offset;

    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)
        .scroll((offset as u16, 0));
    frame.render_widget(paragraph, area);
}

/// The Relationships sub-panel below the Tables list (ADR-0046 DB2). In
/// the narrow (unfocused) column each relationship is three lines — its
/// name, then the endpoints broken at the arrow to fit — every line
/// ellipsized past the inner width. Phase C adds focus + scroll for the
/// overflow; for now content beyond the panel's height is clipped.
fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .border_style(panel_border_style(
            theme,
            app.nav_focus == NavFocus::SidebarRelationships,
        ))
        .title(Span::styled(
            format!(" {} ", crate::t!("panel.relationships_title")),
            Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
        ))
        .style(Style::default().bg(theme.bg).fg(theme.fg));

    // ADR-0046 DC3: clamp + store the scroll before the borrowing lines
    // (three rows per relationship).
    let visible = area.height.saturating_sub(2) as usize;
    app.last_relationships_visible = visible;

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

    let total = app.relationships.len() * 3;
    let offset = app.relationships_scroll.min(total.saturating_sub(visible));
    app.relationships_scroll = offset;

    let inner_w = area.width.saturating_sub(2) as usize;
    let name_style = Style::default().fg(theme.fg);
    let detail_style = Style::default().fg(theme.muted);
    let mut lines: Vec<Line<'static>> = Vec::new();
    for rel in &app.relationships {
        lines.push(Line::from(Span::styled(
            ellipsize(&rel.name, inner_w),
            name_style,
        )));
        let parent = format!(
            "  {}.{} ->",
            rel.parent_table,
            rel.parent_columns.join(", ")
        );
        lines.push(Line::from(Span::styled(
            ellipsize(&parent, inner_w),
            detail_style,
        )));
        let child = format!("    {}.{}", rel.child_table, rel.child_columns.join(", "));
        lines.push(Line::from(Span::styled(
            ellipsize(&child, inner_w),
            detail_style,
        )));
    }
    let paragraph = Paragraph::new(lines)
        .block(block)
        .scroll((offset as u16, 0));
    frame.render_widget(paragraph, area);
}

/// Truncate `s` to `width` display columns, appending an ellipsis when
/// it overflows (ADR-0046 DB2). Assumes one column per character.
fn ellipsize(s: &str, width: usize) -> String {
    if width == 0 {
        return String::new();
    }
    if s.chars().count() <= width {
        return s.to_string();
    }
    let mut out: String = s.chars().take(width.saturating_sub(1)).collect();
    out.push('…');
    out
}

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);
    // ADR-0044 §3: record the panel width so a later `show relationship`
    // diagram (rendered App-side) can choose side-by-side vs vertical.
    app.last_output_width = inner.width;
    // ADR-0047 D4: record the full inner area so the top-level draw can
    // anchor the demo overlays to the output panel's bottom-right corner.
    app.last_output_area = inner;

    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),
        // ADR-0044 relationship diagrams. Reuse existing theme colours
        // (no new Theme fields): the table name stands out via weight,
        // keys + cardinality take accent colours, connectors are muted.
        OutputStyleClass::DiagramTableName => {
            Style::new().fg(theme.fg).add_modifier(Modifier::BOLD)
        }
        OutputStyleClass::DiagramKey => Style::new()
            .fg(theme.plan_efficient)
            .add_modifier(Modifier::BOLD),
        OutputStyleClass::DiagramCardinality => Style::new()
            .fg(theme.tok_number)
            .add_modifier(Modifier::BOLD),
        OutputStyleClass::DiagramConnector => 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),
    ])
}

/// Horizontal scroll offset (in display columns) for a single-line
/// input that may overflow its `text_width`-column viewport (ADR-0046
/// DA3). Keeps the cursor visible; when the line overflows, reserves one
/// column on each side for the `<` / `>` overflow markers so a marker
/// never hides the cursor. `cursor_col` is the column of the cursor
/// cell (which can be `line_cols`, one past the last char, when the
/// cursor sits at the end). Returns the new offset given the previous
/// one, so the view only scrolls when the cursor would leave the window.
const fn input_scroll_offset(
    line_cols: usize,
    cursor_col: usize,
    text_width: usize,
    offset: usize,
) -> usize {
    // The line (including the cursor-at-end cell) fits: no scroll.
    if line_cols < text_width || text_width == 0 {
        return 0;
    }
    // Reserve a column each side for the `<` / `>` markers.
    let eff = if text_width > 2 { text_width - 2 } else { 1 };
    let mut off = offset;
    if cursor_col < off {
        off = cursor_col;
    } else if cursor_col >= off + eff {
        off = cursor_col + 1 - eff;
    }
    // Never scroll past the point where the cursor-at-end cell shows.
    let max_off = (line_cols + 1).saturating_sub(eff);
    if off > max_off {
        off = max_off;
    }
    off
}

fn render_input_panel(app: &mut 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));

    let cursor = app.input_cursor.min(app.input.len());

    // 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 first row's text area is always
    // `inner.width - 6` and the typed command never shifts sideways
    // when the validity indicator appears or hides. A two-row input
    // (DA4) lets the *second* row use the full width.
    let inner = block.inner(area);
    let text_area = Rect {
        width: inner.width.saturating_sub(6),
        ..inner
    };

    // Per-token colouring (ADR-0022 §3 / ADR-0030 §8) in both modes —
    // the highlight walker runs with the active mode so SQL keywords /
    // operators / CASE / function calls colour correctly. The cursor
    // cell is rendered inverted (an empty-range run) so it is visible
    // without a real terminal cursor.
    let mode_for_render = match effective {
        EffectiveMode::Simple => crate::mode::Mode::Simple,
        EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => {
            crate::mode::Mode::Advanced
        }
    };

    frame.render_widget(block, area);

    // ADR-0046 DA3/DA4: render the single logical line across one row
    // (compact terminals) or two (comfortable, height ≥ 40), scrolling
    // horizontally in either case so the cursor stays visible.
    if inner.height >= 2 {
        render_input_two_rows(app, theme, frame, inner, text_area, cursor, mode_for_render);
    } else {
        render_input_one_row(app, theme, frame, text_area, cursor, mode_for_render);
    }

    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,
        );
    }
}

/// One-row input rendering (ADR-0046 DA3): the single logical line is
/// horizontally scrolled so the cursor stays visible, with `<` / `>`
/// markers (muted) at the reserved edge columns signalling hidden
/// content. The offset is stored *before* the highlight spans borrow
/// `app.input`, so the `&mut app` write does not clash.
fn render_input_one_row(
    app: &mut App,
    theme: &Theme,
    frame: &mut Frame<'_>,
    text_area: Rect,
    cursor: usize,
    mode_for_render: crate::mode::Mode,
) {
    let line_cols = app.input.chars().count();
    let cursor_col = app.input[..cursor].chars().count();
    let tw = text_area.width as usize;
    let offset = input_scroll_offset(line_cols, cursor_col, tw, app.input_scroll_offset);
    app.input_scroll_offset = offset;

    // Strip the `:` one-shot prefix for the SQL highlighting/overlays
    // (ADR-0003); the `:` itself renders as plain text. Identity for
    // non-one-shot input.
    let (fb_view, fb_cursor, fb_off) = app.feedback_view();
    let runs = crate::input_render::render_input_runs_feedback(
        &app.input,
        cursor,
        theme,
        &app.schema_cache,
        mode_for_render,
        fb_view,
        fb_cursor,
        fb_off,
    );
    let spans = runs_to_spans(&app.input, &runs);

    if line_cols > tw || offset > 0 {
        // Overflow: reserve one column each side for `<` / `>` markers,
        // render the windowed text between them, then draw the markers
        // for whichever side still has hidden content.
        let eff = if tw > 2 { tw - 2 } else { 1 };
        let mid = Rect {
            x: text_area.x + 1,
            width: eff as u16,
            ..text_area
        };
        frame.render_widget(
            Paragraph::new(Line::from(spans)).scroll((0, offset as u16)),
            mid,
        );
        let marker = Style::default().fg(theme.muted);
        if offset > 0 {
            frame.render_widget(
                Paragraph::new(Span::styled("<", marker)),
                Rect {
                    width: 1,
                    ..text_area
                },
            );
        }
        if offset + eff < line_cols {
            frame.render_widget(
                Paragraph::new(Span::styled(">", marker)),
                Rect {
                    x: text_area.x + text_area.width.saturating_sub(1),
                    width: 1,
                    ..text_area
                },
            );
        }
    } else {
        frame.render_widget(Paragraph::new(Line::from(spans)), text_area);
    }
}

/// Two-row input rendering (ADR-0046 DA4): on a comfortable terminal the
/// single logical line is soft-wrapped across two visual rows — the
/// first row stops 6 columns short (the ADR-0027 indicator reserve), the
/// second uses the full width. When the line overflows both rows it
/// scrolls horizontally (one column each side reserved for `<` / `>`
/// markers) so the cursor stays visible. `text_area` is the first
/// (narrower) row; `inner` spans both rows.
fn render_input_two_rows(
    app: &mut App,
    theme: &Theme,
    frame: &mut Frame<'_>,
    inner: Rect,
    text_area: Rect,
    cursor: usize,
    mode_for_render: crate::mode::Mode,
) {
    let row0_w = text_area.width as usize; // first row reserves the indicator
    let row1_w = inner.width as usize; // second row uses the full width
    let capacity = row0_w + row1_w;
    let line_cols = app.input.chars().count();
    let cursor_col = app.input[..cursor].chars().count();
    let offset = input_scroll_offset(line_cols, cursor_col, capacity, app.input_scroll_offset);
    app.input_scroll_offset = offset;

    // Strip the `:` one-shot prefix for the SQL highlighting/overlays
    // (ADR-0003); the `:` itself renders as plain text. Identity for
    // non-one-shot input.
    let (fb_view, fb_cursor, fb_off) = app.feedback_view();
    let runs = crate::input_render::render_input_runs_feedback(
        &app.input,
        cursor,
        theme,
        &app.schema_cache,
        mode_for_render,
        fb_view,
        fb_cursor,
        fb_off,
    );
    let cells = expand_runs_to_cells(&app.input, &runs);
    let len = cells.len();

    // Overflowing both rows reserves a marker column on each row's
    // outer edge; otherwise both rows use their full text width.
    let overflow = line_cols >= capacity;
    let row0_text_w = if overflow {
        row0_w.saturating_sub(1)
    } else {
        row0_w
    };
    let row1_text_w = if overflow {
        row1_w.saturating_sub(1)
    } else {
        row1_w
    };
    let eff_cap = row0_text_w + row1_text_w;

    let start = offset.min(len);
    let end = (offset + eff_cap).min(len);
    let window = &cells[start..end];
    let split = row0_text_w.min(window.len());
    let to_line = |cs: &[(String, Style)]| {
        Line::from(
            cs.iter()
                .map(|(s, st)| Span::styled(s.clone(), *st))
                .collect::<Vec<_>>(),
        )
    };

    let row0_x = if overflow {
        text_area.x + 1
    } else {
        text_area.x
    };
    frame.render_widget(
        Paragraph::new(to_line(&window[..split])),
        Rect {
            x: row0_x,
            y: inner.y,
            width: row0_text_w as u16,
            height: 1,
        },
    );
    frame.render_widget(
        Paragraph::new(to_line(&window[split..])),
        Rect {
            x: inner.x,
            y: inner.y + 1,
            width: row1_text_w as u16,
            height: 1,
        },
    );

    let marker = Style::default().fg(theme.muted);
    if overflow && offset > 0 {
        frame.render_widget(
            Paragraph::new(Span::styled("<", marker)),
            Rect {
                x: text_area.x,
                y: inner.y,
                width: 1,
                height: 1,
            },
        );
    }
    if overflow && end < len {
        frame.render_widget(
            Paragraph::new(Span::styled(">", marker)),
            Rect {
                x: inner.x + inner.width.saturating_sub(1),
                y: inner.y + 1,
                width: 1,
                height: 1,
            },
        );
    }
}

/// Expand styled runs into one owned `(grapheme, style)` cell per
/// display column, including the inverted cursor cell (ADR-0046 DA4).
/// The two-row renderer places cells across two visual rows and so
/// needs them individually rather than as byte-range spans.
fn expand_runs_to_cells(
    input: &str,
    runs: &[crate::input_render::StyledRun],
) -> Vec<(String, Style)> {
    let mut cells = Vec::new();
    for r in runs {
        if r.byte_range.0 == r.byte_range.1 {
            // Cursor sentinel (empty range) → inverted space cell.
            cells.push((" ".to_string(), r.style));
        } else {
            for ch in input[r.byte_range.0..r.byte_range.1].chars() {
                cells.push((ch.to_string(), r.style));
            }
        }
    }
    cells
}

/// 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()
}

/// Resolve the Hint panel body into its rendered lines, pre-wrapped
/// to the panel's inner width and clamped to `max_rows` with an
/// ellipsis backstop (issue #12). `max_rows` is the geometry-fixed row
/// budget chosen by `hint_rows` (ADR-0046 DA1/DA2); the panel does not
/// resize to the hint, so a short hint simply leaves the spare rows
/// blank and a long one is ellipsized at the budget.
///
/// 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,
    max_rows: usize,
) -> 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_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. The shared feedback view strips it so the hint reflects
    // the advanced command — mirroring `App::submit` (ADR-0003).
    let (hint_input, hint_cursor, _off) = app.feedback_view();
    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)]
        }
        // Empty input: the base prompt, plus — in simple mode only — a
        // pointer to advanced mode (ADR-0051, issue #27), since the
        // `mode advanced` switch left the keybinding strip. Advanced
        // mode shows no pointer: users know how they reached it, and
        // `help` covers the way back. (One-shot never reaches here — its
        // `:` makes the input non-empty → ambient path.)
        (None, None) => {
            let mut text = crate::t!("panel.hint_empty");
            if matches!(app.effective_mode(), EffectiveMode::Simple) {
                text.push_str(&crate::t!("panel.hint_mode_advanced"));
            }
            prose(&text)
        }
    }
}

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)
}

/// The keybinding strip is keystrokes-only and **state-selected**
/// (ADR-0051, issue #27): it advertises the keys for the user's *current*
/// interaction, chosen by priority — first matching state wins.
///
/// Returns `(key, label)` pairs (label localised via `t!`); the renderer
/// is a thin span builder over this list, so the binding sets are
/// unit-testable without a `Frame`. Mode-switch / `:` advertisements
/// deliberately leave the strip — they are typed commands, not
/// keystrokes — and move to the empty-input hint (`resolve_hint_lines`).
fn status_bar_bindings(app: &App) -> Vec<(&'static str, String)> {
    // 1. Sidebar focus (Ctrl-O): the input is occluded by the overlay,
    //    so the panel-scroll keys win outright (ADR-0046).
    if app.nav_focus.in_sidebar() {
        return vec![
            ("Ctrl-O", crate::t!("shortcut.next_pane")),
            ("↑↓/PgUp/PgDn", crate::t!("shortcut.scroll")),
            ("Esc", crate::t!("shortcut.to_input")),
        ];
    }
    // 2. A live Tab-completion memo (ADR-0022): cycling keys. Pressing
    //    Up clears the memo, so this never co-occurs with state 3.
    if app.last_completion.is_some() {
        return vec![
            ("Tab/Shift-Tab", crate::t!("shortcut.cycle")),
            ("Esc", crate::t!("shortcut.cancel")),
            ("Enter", crate::t!("shortcut.run")),
        ];
    }
    // 3. Browsing recalled history (unedited): browse keys. Editing the
    //    recalled line ends navigation, dropping to state 4.
    if app.is_browsing_history() {
        return vec![
            ("↑↓", crate::t!("shortcut.browse")),
            ("Esc", crate::t!("shortcut.clear")),
            ("Enter", crate::t!("shortcut.run")),
        ];
    }
    // 4. Editing — the input has text: F1 (the contextual hint for what
    //    you're typing, ADR-0053) leads, then the readline edit keys
    //    (ADR-0049). Ctrl-K/U remain unadvertised muscle memory.
    if !app.input.is_empty() {
        return vec![
            ("F1", crate::t!("shortcut.hint")),
            ("Esc", crate::t!("shortcut.clear")),
            ("Ctrl-A/E", crate::t!("shortcut.home_end")),
            ("Ctrl-W", crate::t!("shortcut.del_word")),
            ("Enter", crate::t!("shortcut.run")),
        ];
    }
    // 5. Default — empty input, Input focus. F1 here expands on the most
    //    recent error, or points the user at getting started (ADR-0053).
    vec![
        ("Ctrl-O", crate::t!("shortcut.nav")),
        ("Tab", crate::t!("shortcut.complete")),
        ("↑", crate::t!("shortcut.history")),
        ("F1", crate::t!("shortcut.hint")),
        ("Enter", crate::t!("shortcut.run")),
    ]
}

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();
    for (key, label) in status_bar_bindings(app) {
        if !spans.is_empty() {
            spans.push(separator.clone());
        }
        spans.push(Span::styled(key, key_style));
        spans.push(Span::raw(" "));
        spans.push(Span::styled(label, label_style));
    }

    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() {
        // A multi-row hint panel (here 2 rows at a compact 80×20) shows
        // the hint's useful tail rather than clipping it to one row.
        // (Pre-#12 the panel was a fixed 1 row; ADR-0046 keeps it
        // multi-row but now sizes by geometry, not content.)
        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 hint_panel_height_is_fixed_by_geometry_not_content() {
        // ADR-0046 DA1/DA2 (#20): the panel no longer shrinks to a
        // short hint (the issue #12 "reclaim" behaviour is deliberately
        // reversed). At a compact (height < 40) terminal it is a fixed
        // 2 content rows whether the hint is short or long, so it never
        // resizes mid-typing and shoves the input/output panels.
        let theme = Theme::dark();

        let mut short = App::new();
        short.hint = Some("Type a command".to_string());
        let short_out = render_to_string(&mut short, &theme, 80, 20);
        assert!(
            short_out.lines().any(|l| l.contains("Type a command")),
            "short hint visible:\n{short_out}"
        );

        let mut long = App::new();
        long.hint = Some(LONG_HINT.to_string());
        let long_out = render_to_string(&mut long, &theme, 80, 20);

        assert_eq!(
            hint_content_rows(&short_out),
            2,
            "compact terminal fixes the hint at 2 rows:\n{short_out}"
        );
        assert_eq!(
            hint_content_rows(&short_out),
            hint_content_rows(&long_out),
            "the hint panel height must not differ between a short and a \
             long hint at the same terminal size (#20 anti-jump):\n\
             short:\n{short_out}\nlong:\n{long_out}"
        );
    }

    #[test]
    fn narrow_comfortable_terminal_allows_a_third_hint_row() {
        // ADR-0046 DA2: a 3rd hint row appears only on a comfortable
        // (height ≥ 40) terminal whose hint column is narrow enough
        // (inner < 54) to wrap the longest hint past two lines; the
        // ellipsis backstop still caps it at MAX_HINT_ROWS. (At a
        // compact height the same hint is held to 2 rows.)
        let mut app = App::new();
        app.hint = Some(LONG_HINT.to_string());
        let theme = Theme::dark();
        let out = render_to_string(&mut app, &theme, 44, 50);
        assert_eq!(
            hint_content_rows(&out),
            MAX_HINT_ROWS,
            "narrow + tall terminal caps the long hint at MAX_HINT_ROWS \
             content rows:\n{out}"
        );
    }

    #[test]
    fn panel_heights_are_geometry_driven() {
        // ADR-0046 DA1/DA2/DA4: the pure helper the renderer and these
        // tests share. Height picks the bucket (input 1→2, hint floor);
        // width gates the hint's 3rd row; a tiny terminal degrades hint
        // then input to protect output `Min(5)`.
        let at = |w: u16, h: u16| panel_heights(Rect::new(0, 0, w, h));
        // Compact height → input 1, hint 2, regardless of width.
        assert_eq!(at(90, 25), (1, 2));
        assert_eq!(at(40, 25), (1, 2));
        // Comfortable height → input 2; hint 2 when wide (inner ≥ 54).
        assert_eq!(at(90, 45), (2, 2));
        assert_eq!(at(56, 45), (2, 2)); // inner == 54 is "wide enough"
        // Comfortable + narrow (inner < 54) → hint 3.
        assert_eq!(at(55, 45), (2, 3)); // inner == 53
        assert_eq!(at(50, 45), (2, 3));
        // Very short terminal degrades hint first, then input, to keep
        // the output panel's Min(5).
        assert_eq!(at(90, 11), (1, 1));
    }

    // ---- ADR-0046 DA3: input horizontal scroll -------------------

    #[test]
    fn input_scroll_offset_keeps_the_cursor_in_view() {
        // Fits (line shorter than the viewport) → never scrolls.
        assert_eq!(input_scroll_offset(10, 10, 20, 0), 0);
        assert_eq!(input_scroll_offset(19, 19, 20, 5), 0);
        // Overflow, cursor at end → window shows the tail, reserving the
        // two marker columns (eff = tw - 2 = 18): 50 + 1 - 18 = 33.
        assert_eq!(input_scroll_offset(50, 50, 20, 0), 33);
        // Cursor jumped left of the window → scroll left to the cursor.
        assert_eq!(input_scroll_offset(50, 5, 20, 33), 5);
        // Cursor still inside the current window → stable, no change.
        assert_eq!(input_scroll_offset(50, 40, 20, 33), 33);
        // Never scroll past the cursor-at-end cell, even from a stale
        // over-large offset.
        assert_eq!(input_scroll_offset(50, 50, 20, 999), 33);
    }

    const LONG_INPUT: &str =
        "select * from Customers where id = 12345 and name = 'Alice Wonderland'";

    #[test]
    fn long_input_scrolls_to_keep_the_tail_and_cursor_visible() {
        // #23: a command longer than the input field must not clip the
        // cursor off the right edge — it scrolls so the tail is visible,
        // with a `<` marker for the hidden head.
        let mut app = App::new();
        app.input.push_str(LONG_INPUT);
        app.input_cursor = app.input.len();
        let theme = Theme::dark();
        // Narrow (sidebar hidden, DB1) so the line overflows the field.
        let out = render_to_string(&mut app, &theme, 60, 24);
        assert!(
            out.contains("'Alice Wonderland'"),
            "the tail around the cursor must be visible:\n{out}"
        );
        assert!(
            !out.lines()
                .any(|l| l.contains("select * from Customers where")),
            "the head must be scrolled off:\n{out}"
        );
        assert!(
            out.contains('<'),
            "a left scroll marker signals the hidden head:\n{out}"
        );
    }

    #[test]
    fn input_at_home_shows_the_head_with_a_right_marker() {
        // With the cursor at Home, the head is visible and a `>` marker
        // signals the hidden tail.
        let mut app = App::new();
        app.input.push_str(LONG_INPUT);
        app.input_cursor = 0;
        let theme = Theme::dark();
        // Narrow (sidebar hidden, DB1) so the line overflows the field.
        let out = render_to_string(&mut app, &theme, 60, 24);
        assert!(
            out.contains("select * from"),
            "head visible at Home:\n{out}"
        );
        assert!(
            out.contains('>'),
            "a right scroll marker signals the hidden tail:\n{out}"
        );
        assert!(
            !out.contains("Wonderland"),
            "the tail must be scrolled off:\n{out}"
        );
    }

    // ---- ADR-0046 DA4: two-row input on tall terminals -----------

    #[test]
    fn comfortable_terminal_wraps_input_across_two_rows() {
        // On a tall (height ≥ 40) terminal the input shows two rows, so
        // a medium command wraps instead of scrolling — the whole
        // command is visible at once, head above tail.
        let mut app = App::new();
        app.input.push_str(LONG_INPUT);
        app.input_cursor = app.input.len();
        let theme = Theme::dark();
        // Narrow (sidebar hidden, DB1) so the line wraps across the two
        // rows rather than fitting on the first.
        let out = render_to_string(&mut app, &theme, 60, 44);
        let head = out
            .lines()
            .position(|l| l.contains("select * from Customers"));
        let tail = out.lines().position(|l| l.contains("'Alice Wonderland'"));
        assert!(
            head.is_some() && tail.is_some(),
            "both head and tail are visible across two rows:\n{out}"
        );
        assert!(
            tail.unwrap() > head.unwrap(),
            "the tail wraps onto a row below the head:\n{out}"
        );
    }

    #[test]
    fn two_row_input_scrolls_when_it_overflows_both_rows() {
        // A narrow-but-tall terminal: two rows, but the line is longer
        // than both can hold, so it scrolls to keep the tail/cursor
        // visible with a `<` marker for the hidden head.
        let mut app = App::new();
        app.input.push_str(LONG_INPUT);
        app.input_cursor = app.input.len();
        let theme = Theme::dark();
        // Very narrow + tall: two rows, but the line exceeds both.
        let out = render_to_string(&mut app, &theme, 38, 44);
        assert!(
            out.contains("Wonderland"),
            "the tail/cursor stays visible:\n{out}"
        );
        assert!(
            out.contains('<'),
            "a left marker signals the hidden head:\n{out}"
        );
    }

    #[test]
    fn two_row_input_keeps_the_indicator_on_the_first_row() {
        // ADR-0046 DA4 / ADR-0027: the [ERR]/[WRN] indicator stays
        // anchored to the *first* input row (whose 6-column reserve it
        // occupies); the wrapped tail on the second row is untouched.
        let mut app = App::new();
        app.input.push_str(LONG_INPUT);
        app.input_cursor = app.input.len();
        app.input_indicator = Some(crate::dsl::walker::Severity::Error);
        let theme = Theme::dark();
        // Narrow (sidebar hidden, DB1) so the line wraps across two rows.
        let out = render_to_string(&mut app, &theme, 60, 44);
        let err_line = out
            .lines()
            .position(|l| l.contains("[ERR]"))
            .expect("indicator visible");
        let head_line = out
            .lines()
            .position(|l| l.contains("select * from Customers"))
            .expect("head visible");
        assert_eq!(
            err_line, head_line,
            "the indicator shares the first input row with the head:\n{out}"
        );
        assert!(
            out.contains("'Alice Wonderland'"),
            "the wrapped tail on the second row is intact:\n{out}"
        );
    }

    #[test]
    fn two_row_input_snapshot() {
        // Locks the DA4 two-row layout: head on the first (indicator-
        // reserved) row, tail on the full-width second row.
        let mut app = App::new();
        app.input.push_str(LONG_INPUT);
        app.input_cursor = app.input.len();
        let theme = Theme::dark();
        // Narrow (sidebar hidden, DB1) so the command wraps across rows.
        let snapshot = render_to_string(&mut app, &theme, 60, 44);
        insta::assert_snapshot!("two_row_input_dark", snapshot);
    }

    /// 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")
    }

    // ---- ADR-0051 (issue #27): context- and state-aware strip ----

    fn key_event(code: crossterm::event::KeyCode) -> crate::event::AppEvent {
        crate::event::AppEvent::Key(crossterm::event::KeyEvent::new(
            code,
            crossterm::event::KeyModifiers::NONE,
        ))
    }

    /// The `key` column of the strip's bindings, in order.
    fn strip_keys(app: &App) -> Vec<&'static str> {
        status_bar_bindings(app)
            .into_iter()
            .map(|(k, _)| k)
            .collect()
    }

    /// The full rendered strip text (keys + labels + separators).
    fn strip_text(app: &App) -> String {
        status_bar_bindings(app)
            .iter()
            .map(|(k, l)| format!("{k} {l}"))
            .collect::<Vec<_>>()
            .join("  ·  ")
    }

    fn hint_text(lines: &[Line<'_>]) -> String {
        lines
            .iter()
            .map(|l| {
                l.spans
                    .iter()
                    .map(|s| s.content.clone())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    #[test]
    fn strip_default_state_is_nav_complete_history_run() {
        let app = App::new();
        assert_eq!(strip_keys(&app), vec!["Ctrl-O", "Tab", "↑", "F1", "Enter"]);
    }

    #[test]
    fn strip_editing_state_surfaces_readline_keys() {
        // Input has text (no completion/history transient) → the #29
        // editing keys (ADR-0049).
        let mut app = App::new();
        app.input.push_str("create ta");
        assert_eq!(
            strip_keys(&app),
            vec!["F1", "Esc", "Ctrl-A/E", "Ctrl-W", "Enter"],
        );
    }

    #[test]
    fn strip_sidebar_focus_state_is_pane_scroll_input() {
        let mut app = App::new();
        app.nav_focus = NavFocus::SidebarTables;
        assert_eq!(strip_keys(&app), vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"],);
        // ...and the relationships sidebar is the same state.
        app.nav_focus = NavFocus::SidebarRelationships;
        assert_eq!(strip_keys(&app), vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"]);
    }

    #[test]
    fn strip_completion_memo_state_is_cycle_cancel_run() {
        // Drive the real flow: `show ` + Tab leaves a multi-candidate
        // memo (ADR-0022). The strip must win over the editing state.
        let mut app = App::new();
        for c in "show ".chars() {
            app.update(key_event(crossterm::event::KeyCode::Char(c)));
        }
        app.update(key_event(crossterm::event::KeyCode::Tab));
        assert!(app.last_completion.is_some(), "memo set by Tab");
        assert!(!app.input.is_empty(), "input non-empty — would be editing");
        assert_eq!(
            strip_keys(&app),
            vec!["Tab/Shift-Tab", "Esc", "Enter"],
            "completion state wins over editing",
        );
    }

    #[test]
    fn strip_history_navigation_state_is_browse_clear_run() {
        // Submit a command, then Up to recall it — `history_cursor` is
        // set, input is the (non-empty) recalled line, no memo.
        let mut app = App::new();
        for c in "drop table T".chars() {
            app.update(key_event(crossterm::event::KeyCode::Char(c)));
        }
        app.update(key_event(crossterm::event::KeyCode::Enter)); // submit
        app.update(key_event(crossterm::event::KeyCode::Up)); // recall
        assert!(app.is_browsing_history(), "browsing recalled history");
        assert!(app.last_completion.is_none(), "no completion memo");
        assert_eq!(
            strip_keys(&app),
            vec!["↑↓", "Esc", "Enter"],
            "history state wins over editing",
        );
    }

    #[test]
    fn every_strip_state_fits_the_eighty_column_budget() {
        // ADR-0051 §3: the strips are kept lean by construction — the
        // longest must fit an 80-col status line, so no graceful-drop
        // machinery is needed. A future over-long strip fails here.
        let sidebar = {
            let mut a = App::new();
            a.nav_focus = NavFocus::SidebarTables;
            a
        };
        let editing = {
            let mut a = App::new();
            a.input.push('x');
            a
        };
        for app in [&App::new(), &sidebar, &editing] {
            let text = strip_text(app);
            assert!(
                text.chars().count() <= 80,
                "strip {} cols > 80: {text:?}",
                text.chars().count(),
            );
        }
    }

    #[test]
    fn empty_hint_advertises_advanced_mode_in_simple() {
        let app = App::new();
        // Wide width so the pointer never wrap-splits.
        let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3));
        assert!(
            text.contains("`mode advanced` for SQL"),
            "simple empty hint carries the advanced pointer:\n{text}",
        );
    }

    #[test]
    fn advanced_mode_empty_hint_has_no_mode_pointer() {
        // ADR-0051: advanced mode shows no mode pointer (users know how
        // they got there; `help` covers the way back).
        let mut app = App::new();
        app.mode = Mode::Advanced;
        let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3));
        assert!(
            !text.contains("mode simple") && !text.contains("mode advanced"),
            "advanced empty hint carries no mode pointer:\n{text}",
        );
    }

    #[test]
    fn typing_replaces_the_empty_hint_mode_pointer() {
        // Non-empty input → ambient hint path, not the empty-hint
        // mode pointer.
        let mut app = App::new();
        app.input.push_str("create table");
        app.input_cursor = app.input.len();
        let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3));
        assert!(
            !text.contains("for SQL"),
            "no mode pointer once typing:\n{text}",
        );
    }

    #[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();
        // Width > SIDEBAR_MIN_WIDTH so the sidebar (tables list) shows
        // alongside the output panel (DB1).
        let snapshot = render_to_string(&mut app, &theme, 110, 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();
        // Width > SIDEBAR_MIN_WIDTH so the sidebar is shown (DB1).
        let out = render_to_string(&mut app, &theme, 110, 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}"
        );
    }

    #[test]
    fn sidebar_visible_is_width_gated() {
        // ADR-0046 DB1: shown above SIDEBAR_MIN_WIDTH, hidden at/below.
        assert!(!sidebar_visible(80));
        assert!(!sidebar_visible(90)); // the 90-col screencast: hidden
        assert!(sidebar_visible(91));
        assert!(sidebar_visible(120));
    }

    #[test]
    fn sidebar_hidden_at_or_below_threshold_width() {
        // The Tables panel disappears at a narrow width (the output
        // panel then spans the full width) and returns when wide.
        let mut app = App::new();
        app.tables = vec!["Customers".to_string()];
        let theme = Theme::dark();
        let narrow = render_to_string(&mut app, &theme, 80, 24);
        assert!(
            !narrow.contains("Tables"),
            "sidebar hidden at 80 wide:\n{narrow}"
        );
        let wide = render_to_string(&mut app, &theme, 110, 24);
        assert!(
            wide.contains("Tables"),
            "sidebar shown at 110 wide:\n{wide}"
        );
        assert!(
            wide.contains("Customers"),
            "tables listed when shown:\n{wide}"
        );
    }

    #[test]
    fn relationships_panel_height_is_content_sized_within_bounds() {
        // ADR-0046 DB4: empty floors at 5; grows with content; capped at
        // half the column; leaves the Tables panel at least 3 rows.
        assert_eq!(relationships_panel_height(40, 0), 5); // empty floor
        assert_eq!(relationships_panel_height(40, 6), 8); // 6 content + borders
        assert_eq!(relationships_panel_height(40, 30), 20); // capped at half
        assert_eq!(relationships_panel_height(7, 0), 3); // tiny: Tables keeps 3
    }

    fn one_relationship() -> crate::persistence::RelationshipSchema {
        use crate::dsl::action::ReferentialAction;
        crate::persistence::RelationshipSchema {
            name: "Customers_Orders".to_string(),
            parent_table: "Customers".to_string(),
            parent_columns: vec!["id".to_string()],
            child_table: "Orders".to_string(),
            child_columns: vec!["customer_id".to_string()],
            on_delete: ReferentialAction::Cascade,
            on_update: ReferentialAction::Cascade,
        }
    }

    #[test]
    fn relationships_panel_lists_each_relationship() {
        // ADR-0046 DB2: name, then endpoints broken at the arrow.
        let mut app = App::new();
        app.tables = vec!["Customers".to_string(), "Orders".to_string()];
        app.relationships = vec![one_relationship()];
        let theme = Theme::dark();
        let out = render_to_string(&mut app, &theme, 110, 24);
        assert!(out.contains("Relationships"), "panel title present:\n{out}");
        assert!(
            out.contains("Customers_Orders"),
            "relationship name:\n{out}"
        );
        assert!(
            out.lines().any(|l| l.contains("Customers.id ->")),
            "parent endpoint, broken at the arrow:\n{out}"
        );
        assert!(
            out.lines().any(|l| l.contains("Orders.customer_id")),
            "child endpoint, indented:\n{out}"
        );
    }

    #[test]
    fn empty_relationships_panel_shows_none() {
        let mut app = App::new();
        app.tables = vec!["Customers".to_string()];
        let theme = Theme::dark();
        let out = render_to_string(&mut app, &theme, 110, 24);
        assert!(out.contains("Relationships"), "panel title present:\n{out}");
        assert!(out.contains("(none)"), "empty placeholder:\n{out}");
    }

    #[test]
    fn relationships_panel_snapshot() {
        let mut app = App::new();
        app.tables = vec!["Customers".to_string(), "Orders".to_string()];
        app.relationships = vec![one_relationship()];
        let theme = Theme::dark();
        let snapshot = render_to_string(&mut app, &theme, 110, 24);
        insta::assert_snapshot!("relationships_panel_dark", snapshot);
    }

    #[test]
    fn navigation_mode_reveals_and_expands_the_sidebar() {
        // ADR-0046 DC1/DC2: at a narrow width the sidebar is hidden, but
        // focusing a sidebar panel peeks it open as an expanded overlay.
        let mut app = App::new();
        app.tables = vec!["Customers".to_string()];
        app.relationships = vec![one_relationship()];
        let theme = Theme::dark();
        let normal = render_to_string(&mut app, &theme, 80, 24);
        assert!(
            !normal.contains("Tables"),
            "sidebar hidden at 80 wide when not browsing:\n{normal}"
        );

        app.nav_focus = NavFocus::SidebarTables;
        let focused = render_to_string(&mut app, &theme, 80, 24);
        assert!(
            focused.contains("Tables"),
            "sidebar revealed in nav mode:\n{focused}"
        );
        assert!(
            focused.contains("Customers"),
            "tables in the overlay:\n{focused}"
        );
        assert!(
            focused.contains("Relationships"),
            "relationships panel in the overlay:\n{focused}"
        );
        assert!(
            focused.contains("Customers_Orders"),
            "relationship listed in the overlay:\n{focused}"
        );
    }

    #[test]
    fn focused_panel_gets_an_accent_border() {
        // ADR-0046 DC3 (Amendment 1, issue #25): the focused sidebar
        // panel is marked by a non-bold accent COLOUR, not bold. Bold
        // box-drawing glyphs render as broken/gapped line-art in the
        // asciinema player (and are fragile in some terminals), so the
        // focus cue is the accent hue against the muted unfocused
        // border — never a `Modifier::BOLD` on the border.
        let theme = Theme::dark();
        let focused = panel_border_style(&theme, true);
        let normal = panel_border_style(&theme, false);
        assert_eq!(focused.fg, Some(theme.mode_simple));
        assert!(
            !focused.add_modifier.contains(Modifier::BOLD),
            "the focused border must NOT be bold (issue #25)",
        );
        assert_eq!(normal.fg, Some(theme.border));
        assert!(!normal.add_modifier.contains(Modifier::BOLD));
    }

    #[test]
    fn focused_panel_border_cells_are_accent_colour_not_bold() {
        // Full-stack guard for issue #25: the accent colour (and the
        // absence of bold) must reach the actual rendered border cells,
        // not just `panel_border_style` in isolation. With the Tables
        // panel focused, its box-drawing border cells carry
        // `theme.mode_simple` and never `Modifier::BOLD`; with no panel
        // focused, no border cell wears the accent colour.
        const BOX_DRAWING: &[char] = &['╭', '╮', '╰', '╯', '─', '│'];
        let is_border = |sym: &str| sym.chars().all(|c| BOX_DRAWING.contains(&c));
        let theme = Theme::dark();

        let mut app = App::new();
        app.tables = vec!["Customers".to_string(), "Orders".to_string()];
        app.nav_focus = NavFocus::SidebarTables;
        let buf = render_to_buffer(&mut app, &theme, 110, 24);
        let mut accent_border_cells = 0;
        for y in 0..buf.area.height {
            for x in 0..buf.area.width {
                let cell = &buf[(x, y)];
                if is_border(cell.symbol()) && cell.fg == theme.mode_simple {
                    accent_border_cells += 1;
                    assert!(
                        !cell.modifier.contains(Modifier::BOLD),
                        "focused border cell at ({x},{y}) must not be bold (issue #25)",
                    );
                }
            }
        }
        assert!(
            accent_border_cells > 0,
            "the focused Tables panel must render accent-coloured border cells",
        );

        // With nothing focused (Input), no border cell wears the accent.
        let mut app2 = App::new();
        app2.tables = vec!["Customers".to_string()];
        app2.nav_focus = NavFocus::Input;
        let buf2 = render_to_buffer(&mut app2, &theme, 110, 24);
        for y in 0..buf2.area.height {
            for x in 0..buf2.area.width {
                let cell = &buf2[(x, y)];
                if is_border(cell.symbol()) {
                    assert_ne!(
                        cell.fg, theme.mode_simple,
                        "no border cell may wear the focus accent when nothing is focused (at {x},{y})",
                    );
                }
            }
        }
    }

    #[test]
    fn focused_tables_panel_scrolls_and_clamps() {
        // ADR-0046 DC3: more tables than fit → a large offset reveals the
        // lower entries and clamps so it can't scroll past the end.
        let mut app = App::new();
        app.tables = (0..30).map(|i| format!("Table{i:02}")).collect();
        app.nav_focus = NavFocus::SidebarTables;
        app.tables_scroll = 1000; // way past the end
        let theme = Theme::dark();
        let out = render_to_string(&mut app, &theme, 80, 24);
        assert!(
            out.contains("Table29"),
            "the last table is visible after the offset clamps:\n{out}"
        );
        assert!(
            !out.contains("Table00"),
            "the top tables are scrolled off:\n{out}"
        );
        assert!(
            app.tables_scroll < 30,
            "the stored offset was clamped to the content: {}",
            app.tables_scroll
        );
    }

    #[test]
    fn navigation_overlay_snapshot() {
        // The expanded overlay over a full-width base (sidebar hidden at
        // 80), with the Relationships panel focused (accent border).
        let mut app = App::new();
        app.tables = vec!["Customers".to_string(), "Orders".to_string()];
        app.relationships = vec![one_relationship()];
        app.nav_focus = NavFocus::SidebarRelationships;
        let theme = Theme::dark();
        let snapshot = render_to_string(&mut app, &theme, 80, 24);
        insta::assert_snapshot!("nav_overlay_relationships_focused_dark", snapshot);
    }

    // ---- ADR-0047 (issue #22): demo-mode keystroke badge ----

    /// Render to a `TestBackend` buffer (for cell-level style checks the
    /// text-only `render_to_string` cannot make).
    fn render_to_buffer(
        app: &mut App,
        theme: &Theme,
        width: u16,
        height: u16,
    ) -> ratatui::buffer::Buffer {
        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");
        terminal.backend().buffer().clone()
    }

    #[test]
    fn demo_badge_box_renders_at_output_bottom_right() {
        // At the 90×26 cast geometry the sidebar is hidden and the badge
        // box sits inset in the output panel's bottom-right corner.
        let mut app = App::new();
        app.demo_mode = true;
        app.demo_badge = Some("[TAB]");
        let theme = Theme::dark();
        let snapshot = render_to_string(&mut app, &theme, 90, 26);
        insta::assert_snapshot!("demo_badge_tab_dark_90x26", snapshot);
    }

    #[test]
    fn demo_badge_box_renders_in_light_theme() {
        let mut app = App::new();
        app.demo_mode = true;
        app.demo_badge = Some("[ENTER]");
        let theme = Theme::light();
        let snapshot = render_to_string(&mut app, &theme, 90, 26);
        insta::assert_snapshot!("demo_badge_enter_light_90x26", snapshot);
    }

    #[test]
    fn demo_badge_box_is_black_on_yellow() {
        let mut app = App::new();
        app.demo_mode = true;
        app.demo_badge = Some("[TAB]");
        let theme = Theme::dark();
        let buffer = render_to_buffer(&mut app, &theme, 90, 26);
        // Collect the badge cells (the only ones painted with the fixed
        // overlay background) and confirm the high-contrast pairing.
        let mut badge_cells = 0;
        let mut row_text: std::collections::BTreeMap<u16, String> = Default::default();
        for y in 0..buffer.area.height {
            for x in 0..buffer.area.width {
                let cell = &buffer[(x, y)];
                if cell.bg == crate::theme::DEMO_OVERLAY_BG {
                    badge_cells += 1;
                    assert_eq!(
                        cell.fg,
                        crate::theme::DEMO_OVERLAY_FG,
                        "badge cell at ({x},{y}) must be black-on-yellow"
                    );
                    row_text.entry(y).or_default().push_str(cell.symbol());
                }
            }
        }
        assert!(badge_cells > 0, "expected a yellow badge box to be drawn");
        // The label appears on the box's middle (text) row.
        assert!(
            row_text.values().any(|line| line.contains("[TAB]")),
            "badge text not found among styled rows: {row_text:?}"
        );
    }

    #[test]
    fn demo_caption_box_renders_at_output_bottom_right() {
        let mut app = App::new();
        app.demo_mode = true;
        app.demo_caption = Some("Now press Tab to complete the table name".to_string());
        let theme = Theme::dark();
        let snapshot = render_to_string(&mut app, &theme, 90, 26);
        insta::assert_snapshot!("demo_caption_dark_90x26", snapshot);
    }

    #[test]
    fn demo_badge_stacks_above_caption() {
        let mut app = App::new();
        app.demo_mode = true;
        app.demo_badge = Some("[TAB]");
        app.demo_caption = Some("Completing the name".to_string());
        let theme = Theme::dark();
        let snapshot = render_to_string(&mut app, &theme, 90, 26);
        insta::assert_snapshot!("demo_badge_and_caption_stacked_90x26", snapshot);
    }

    #[test]
    fn demo_caption_wraps_to_three_lines_and_ellipsises() {
        let mut app = App::new();
        app.demo_mode = true;
        app.demo_caption = Some(
            "This is a deliberately long step caption that must wrap onto \
             several lines and then be clipped to three with an ellipsis \
             so the corner box never grows without bound."
                .to_string(),
        );
        let theme = Theme::dark();
        let snapshot = render_to_string(&mut app, &theme, 90, 26);
        insta::assert_snapshot!("demo_caption_wrapped_90x26", snapshot);
    }

    #[test]
    fn demo_badge_box_skipped_when_area_too_small() {
        // ADR-0047 D4 clamp guard: a box that cannot fit the given area
        // is not drawn rather than overflowing.
        let backend = TestBackend::new(40, 10);
        let mut terminal = Terminal::new(backend).expect("create terminal");
        terminal
            .draw(|f| super::render_badge_box("[SHIFT-TAB]", Rect::new(0, 0, 5, 3), None, f))
            .expect("draw frame");
        let buffer = terminal.backend().buffer();
        let drew_badge = (0..buffer.area.height).any(|y| {
            (0..buffer.area.width).any(|x| buffer[(x, y)].bg == crate::theme::DEMO_OVERLAY_BG)
        });
        assert!(!drew_badge, "badge must be skipped when it cannot fit");
    }
}
