//! Frame layout and widget rendering for the inline composer viewport.
//!
//! The transcript itself is *not* drawn here — finalized blocks are committed
//! to the terminal's native scrollback (see `app::commit_scrollback`) so the
//! user can select and copy them with the mouse like any command output. This
//! module only paints the fixed-height bottom viewport: the composer (or, while
//! a turn streams, the live in-flight tail / an MCP approval panel) plus the
//! one-line status bar.

pub(crate) mod markdown;
pub(crate) mod transcript;

use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Widget, Wrap};

use crate::app::{App, McpLogin, Mode, Picker, PickerKind, PickerRow};
use crate::slash::SlashMenu;
use hagent_core::config::ToolKind;
use hagent_core::{Settings, Totals};

const SPINNER: [&str; 4] = ["⠋", "⠙", "⠸", "⠴"];

pub fn draw(buf: &mut Buffer, area: Rect, app: &App) {
    let [main, status] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(area);

    if app.awaiting_bootstrap() {
        draw_bootstrap(buf, app, main);
    } else if app.awaiting_mcp_login() {
        draw_mcp_login(buf, app, main);
    } else if app.awaiting_approval() {
        draw_approval(buf, app, main);
    } else if app.mode == Mode::Picker {
        if let Some(picker) = app.picker() {
            draw_picker(buf, picker, main);
        }
    } else if app.is_streaming() {
        // A turn is streaming: show the in-flight tail in the composer's place
        // (it commits to scrollback when the turn settles), and — when the user
        // is composing/queuing a follow-up — the queue and composer below it.
        draw_streaming(buf, app, main);
    } else if let Some(menu) = &app.slash {
        // The slash-command popup sits directly above the composer.
        let popup_h = app.slash_popup_height();
        let [popup, composer] =
            Layout::vertical([Constraint::Length(popup_h), Constraint::Min(1)]).areas(main);
        draw_slash_menu(buf, menu, popup);
        draw_input(buf, app, composer);
    } else {
        draw_input(buf, app, main);
    }
    draw_status(buf, app, status);
}

/// The slash-command autocomplete popup: a bordered list of filtered commands
/// with the selected row marked. Drawn just above the composer.
fn draw_slash_menu(buf: &mut Buffer, menu: &SlashMenu, area: Rect) {
    if area.height == 0 {
        return;
    }
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::Cyan))
        .title(" commands ");
    let inner = block.inner(area);
    block.render(area, buf);

    let dim = Style::default().fg(Color::DarkGray);
    let lines: Vec<Line> = menu
        .items
        .iter()
        .enumerate()
        .map(|(i, cmd)| {
            let selected = i == menu.selected;
            let (marker, name_style) = if selected {
                (
                    "❯ ",
                    Style::default()
                        .fg(Color::White)
                        .add_modifier(Modifier::BOLD),
                )
            } else {
                ("  ", Style::default().fg(Color::Cyan))
            };
            Line::from(vec![
                Span::styled(marker, Style::default().fg(Color::Cyan)),
                Span::styled(format!("/{}", cmd.name), name_style),
                Span::styled(format!("  {}", cmd.description), dim),
            ])
        })
        .collect();
    Paragraph::new(lines).render(inner, buf);
}

/// A modal list picker (`/resume`, `/rewind`): a bordered, scrolling list with
/// the selected row marked, keeping the selection in view.
fn draw_picker(buf: &mut Buffer, picker: &Picker, area: Rect) {
    let verb = match picker.kind {
        PickerKind::Resume => "resume",
        PickerKind::Rewind => "rewind",
        PickerKind::Model => "model",
        PickerKind::Effort => "effort",
        PickerKind::McpAddDest => "mcp add — where to save",
        PickerKind::McpMenu => "mcp",
        PickerKind::McpServerActions => "mcp server",
    };
    // Echo the live filter in the title so type-to-filter is discoverable. When
    // the model list failed to load, point the user at typing/pasting a name.
    let title = if !picker.filter.is_empty() {
        format!(" {verb} — filter: {} ", picker.filter)
    } else if picker.error.is_some() {
        format!(" {verb} — type or paste a name · ⏎ choose · esc cancel ")
    } else {
        format!(" {verb} — ↑↓ select · ⏎ choose · type to filter · esc cancel ")
    };
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::Cyan))
        .title(title);
    let inner = block.inner(area);
    block.render(area, buf);

    let dim = Style::default().fg(Color::DarkGray);
    if picker.loading {
        Paragraph::new(Line::styled("  fetching models…", dim)).render(inner, buf);
        return;
    }
    let lines: Vec<Line> = picker
        .rows()
        .into_iter()
        .enumerate()
        .map(|(row, pr)| {
            let selected = row == picker.selected;
            let marker = if selected { "❯ " } else { "  " };
            let label_style = if selected {
                Style::default()
                    .fg(Color::White)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(Color::Gray)
            };
            match pr {
                PickerRow::Custom(filter) => Line::from(vec![
                    Span::styled(marker, Style::default().fg(Color::Cyan)),
                    Span::styled(
                        format!("use \"{filter}\""),
                        if selected {
                            label_style
                        } else {
                            Style::default().fg(Color::Cyan)
                        },
                    ),
                    Span::styled("  (not in list)", dim),
                ]),
                PickerRow::Item(item) => {
                    let mut spans = vec![
                        Span::styled(marker, Style::default().fg(Color::Cyan)),
                        Span::styled(item.label.clone(), label_style),
                    ];
                    if !item.detail.is_empty() {
                        spans.push(Span::styled(format!("  {}", item.detail), dim));
                    }
                    Line::from(spans)
                }
            }
        })
        .collect();
    if lines.is_empty() {
        // A failed/empty model fetch keeps the picker open: show the reason plus a
        // hint that a name can still be typed or pasted (the freeform row appears
        // once the filter is non-empty).
        let msg = if let Some(err) = &picker.error {
            vec![
                Line::styled(format!("  {err}"), Style::default().fg(Color::Yellow)),
                Line::styled("  type or paste a model name, then press ⏎", dim),
            ]
        } else {
            vec![Line::styled("  (no matches)", dim)]
        };
        Paragraph::new(msg).render(inner, buf);
        return;
    }
    // Keep the selected row visible when the list is taller than the panel.
    let height = inner.height.max(1) as usize;
    let offset = picker.selected.saturating_sub(height - 1);
    Paragraph::new(lines)
        .scroll((offset as u16, 0))
        .render(inner, buf);
}

/// The 5 rows of the stylised large `H` block mascot. Spaces are gaps; every
/// other cell is a filled block whose colour is decided per-column by
/// [`h_style`] so a highlight can sweep across it during the startup shimmer.
const H_ART: [&str; 5] = ["██   ██", "██   ██", "███████", "██   ██", "██   ██"];

/// Colour one column of the `H`. With `frame = None` the `H` is settled (a solid
/// bright cyan, as committed to scrollback). With `frame = Some(f)` a white
/// highlight sweeps left→right across the seven columns (the startup shimmer),
/// flanked by lighter cyan, over a bold-cyan base.
fn h_style(col: usize, frame: Option<usize>) -> Style {
    let base = Style::default()
        .fg(Color::Cyan)
        .add_modifier(Modifier::BOLD);
    let Some(f) = frame else {
        return base;
    };
    // Sweep position runs 0..11 (period 12); past column 6 it sits off the `H`,
    // giving a brief rest between passes.
    let sweep = (f % 12) as i32;
    match (col as i32 - sweep).abs() {
        0 => Style::default()
            .fg(Color::White)
            .add_modifier(Modifier::BOLD),
        1 => Style::default().fg(Color::LightCyan),
        _ => base,
    }
}

/// The startup banner, settled (committed to scrollback by `app::commit_welcome`):
/// the stylised `H` mascot with the title and session values (model / server /
/// tools / reasoning) stacked to its right — no headings, no box. Delegates to
/// [`welcome_frame`] with no shimmer frame.
pub(crate) fn welcome_lines(settings: &Settings) -> Vec<Line<'static>> {
    welcome_frame(settings, None)
}

/// One frame of the startup banner. `frame` drives the `H`'s shimmer
/// ([`h_style`]); `None` is the settled banner. The shimmer animates in the live
/// viewport at launch (`app::intro_animate`), then the settled frame commits to
/// scrollback — so the animation plays once and idle stays zero-redraw.
///
/// One info line sits to the right of each of the `H`'s five rows: the title,
/// the tagline, then the unlabelled session values — the model and reasoning
/// effort share the crossbar row (`model · effort`), with the server and tools
/// below. No border box.
pub(crate) fn welcome_frame(settings: &Settings, frame: Option<usize>) -> Vec<Line<'static>> {
    let title = Style::default()
        .fg(Color::Cyan)
        .add_modifier(Modifier::BOLD);
    let dim = Style::default().fg(Color::DarkGray);
    let plain = Style::default();

    let right: [Vec<Span<'static>>; 5] = [
        vec![Span::styled("hagent", title)],
        vec![Span::styled("a thin terminal client for Hadrian", dim)],
        vec![
            Span::styled(settings.model.clone(), Style::default().fg(Color::Green)),
            Span::styled(" · ", dim),
            Span::styled(reasoning_effort(settings), dim),
        ],
        vec![Span::styled(display_server(&settings.base_url), plain)],
        vec![Span::styled(declared_tools(settings), plain)],
    ];

    H_ART
        .iter()
        .zip(right)
        .map(|(glyphs, info)| {
            let mut spans = vec![Span::raw("   ")];
            for (col, ch) in glyphs.chars().enumerate() {
                if ch == ' ' {
                    spans.push(Span::raw(" "));
                } else {
                    spans.push(Span::styled(ch.to_string(), h_style(col, frame)));
                }
            }
            spans.push(Span::raw("   "));
            spans.extend(info);
            Line::from(spans)
        })
        .collect()
}

/// Strip the scheme from a base URL for a compact banner (`localhost:8080/api`).
fn display_server(base_url: &str) -> String {
    base_url
        .strip_prefix("https://")
        .or_else(|| base_url.strip_prefix("http://"))
        .unwrap_or(base_url)
        .to_string()
}

/// The tools that will *actually* be declared, mirroring
/// `Conversation::build_tools`: `file_search`/`mcp` are dropped without their
/// prerequisites, so the banner never advertises a tool the model won't get.
fn declared_tools(settings: &Settings) -> String {
    let mut names: Vec<&str> = Vec::new();
    for kind in &settings.tools {
        match kind {
            ToolKind::Shell => names.push("shell"),
            ToolKind::WebSearch => names.push("web_search"),
            ToolKind::FileSearch if !settings.vector_store_ids.is_empty() => {
                names.push("file_search")
            }
            ToolKind::Mcp if !settings.mcp_servers.is_empty() => names.push("mcp"),
            _ => {}
        }
    }
    if names.is_empty() {
        "none".to_string()
    } else {
        names.join(" · ")
    }
}

/// The reasoning effort to display, or `off` when reasoning is disabled.
fn reasoning_effort(settings: &Settings) -> String {
    settings
        .reasoning
        .as_ref()
        .and_then(|r| r.effort.clone())
        .unwrap_or_else(|| "off".to_string())
}

/// Render the in-flight tail bottom-anchored, so the newest line is always
/// visible even when the content is taller than the area.
fn draw_live_tail(buf: &mut Buffer, lines: Vec<Line<'static>>, area: Rect) {
    let para = Paragraph::new(lines).wrap(Wrap { trim: false });
    let total = para.line_count(area.width.max(1)) as u16;
    let offset = total.saturating_sub(area.height);
    para.scroll((offset, 0)).render(area, buf);
}

/// The streaming-turn viewport: the in-flight tail, plus — when the user is
/// composing or has messages queued — the queued previews and the composer at
/// the bottom so typing the next message is visible (it's queued, not sent, on
/// Enter; see `App::submit`).
fn draw_streaming(buf: &mut Buffer, app: &App, area: Rect) {
    if !app.composing() {
        if let Some(tail) = app.live_tail_lines() {
            draw_live_tail(buf, tail, area);
        }
        return;
    }
    let queued_h = app.queued().len().min(crate::app::MAX_QUEUE_PREVIEW) as u16;
    let composer_h = (app.input.lines().len() as u16).max(1) + 2;
    let [tail_area, queued_area, composer_area] = Layout::vertical([
        Constraint::Min(1),
        Constraint::Length(queued_h),
        Constraint::Length(composer_h),
    ])
    .areas(area);
    if let Some(tail) = app.live_tail_lines() {
        draw_live_tail(buf, tail, tail_area);
    }
    draw_queued(buf, app, queued_area);
    draw_input(buf, app, composer_area);
}

/// The queued-message previews shown below the streaming tail (first line of
/// each, up to [`crate::app::MAX_QUEUE_PREVIEW`]).
fn draw_queued(buf: &mut Buffer, app: &App, area: Rect) {
    if area.height == 0 {
        return;
    }
    let dim = Style::default().fg(Color::DarkGray);
    let lines: Vec<Line> = app
        .queued()
        .iter()
        .take(crate::app::MAX_QUEUE_PREVIEW)
        .map(|m| {
            let first = m.lines().next().unwrap_or("");
            Line::styled(format!("  ⏎ queued: {first}"), dim)
        })
        .collect();
    Paragraph::new(lines).render(area, buf);
}

/// Inline one-time bootstrap panel: offered on first contact with a server that
/// has no cached file-editing skill. Mirrors the approval panel's look.
fn draw_bootstrap(buf: &mut Buffer, app: &App, area: Rect) {
    let uploading = app.bootstrap_uploading();
    let force = app.bootstrap_force;
    let title = if uploading {
        " file editing — uploading… "
    } else if force {
        " file editing — [y] delete & re-upload · [k] keep · [n] cancel "
    } else {
        " file editing — [y]es enable · [n]o skip "
    };
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::Cyan))
        .title(title);
    let inner = block.inner(area);
    block.render(area, buf);

    let bold = Style::default()
        .fg(Color::White)
        .add_modifier(Modifier::BOLD);
    let dim = Style::default().fg(Color::DarkGray);

    let mut lines = if force {
        vec![
            Line::from(Span::styled(
                "Re-bootstrap the file-editing skill on this server?",
                bold,
            )),
            Line::from(Span::styled(display_server(&app.settings.base_url), dim)),
            Line::default(),
            Line::from(Span::styled(
                "[y] delete the existing skill, then upload a fresh copy",
                dim,
            )),
            Line::from(Span::styled(
                "[k] keep it — just re-activate the cached skill",
                dim,
            )),
            Line::from(Span::styled(
                "Re-upload to pick up an updated editor (scripts/edit.py).",
                dim,
            )),
        ]
    } else {
        vec![
            Line::from(Span::styled(
                "Bootstrap the file-editing skill on this server?",
                bold,
            )),
            Line::from(Span::styled(display_server(&app.settings.base_url), dim)),
            Line::default(),
            Line::from(Span::styled(
                "Uploads a small editor (scripts/edit.py) so the model can view and",
                dim,
            )),
            Line::from(Span::styled(
                "precisely edit files in the shell sandbox. One-time per server.",
                dim,
            )),
        ]
    };
    if uploading {
        lines.push(Line::from(Span::styled(
            format!("{} uploading…", SPINNER[app.spinner % SPINNER.len()]),
            Style::default().fg(Color::Yellow),
        )));
    }
    Paragraph::new(lines)
        .wrap(Wrap { trim: false })
        .render(inner, buf);
}

/// Inline MCP-login panel: offered when a turn needs an OAuth server the user
/// hasn't authenticated yet. Mirrors the bootstrap panel's look.
fn draw_mcp_login(buf: &mut Buffer, app: &App, area: Rect) {
    let running = app.mcp_login_running();
    let label = match app.mcp_login() {
        McpLogin::Prompt(l) | McpLogin::Running(l) => l.as_str(),
        McpLogin::Inactive => "",
    };
    let title = if running {
        " mcp login — authenticating… "
    } else {
        " mcp login — [y] open browser · [n] skip "
    };
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::Cyan))
        .title(title);
    let inner = block.inner(area);
    block.render(area, buf);

    let bold = Style::default()
        .fg(Color::White)
        .add_modifier(Modifier::BOLD);
    let dim = Style::default().fg(Color::DarkGray);
    let mut lines = vec![
        Line::from(Span::styled(
            format!("MCP server '{label}' requires authentication."),
            bold,
        )),
        Line::default(),
        Line::from(Span::styled("[y] open your browser to log in (OAuth)", dim)),
        Line::from(Span::styled("[n] skip — cancels this turn", dim)),
    ];
    if running {
        lines.push(Line::from(Span::styled(
            format!(
                "{} waiting for the browser… (or run `hagent mcp login {label}`)",
                SPINNER[app.spinner % SPINNER.len()]
            ),
            Style::default().fg(Color::Yellow),
        )));
    }
    Paragraph::new(lines)
        .wrap(Wrap { trim: false })
        .render(inner, buf);
}

/// Inline MCP approval panel (replaces the old centered modal — there is no
/// fullscreen to center within once history lives in native scrollback).
fn draw_approval(buf: &mut Buffer, app: &App, area: Rect) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::Yellow))
        .title(" mcp approval — [a]pprove · [d]eny ");
    let inner = block.inner(area);
    block.render(area, buf);

    let Some(approval) = app.conv.current_approval() else {
        return;
    };
    let pretty_args = serde_json::from_str::<serde_json::Value>(&approval.arguments)
        .ok()
        .and_then(|v| serde_json::to_string_pretty(&v).ok())
        .unwrap_or_else(|| approval.arguments.clone());

    let mut lines = vec![Line::from(Span::styled(
        format!("{} · {}", approval.server_label, approval.name),
        Style::default()
            .fg(Color::White)
            .add_modifier(Modifier::BOLD),
    ))];
    for l in pretty_args.lines() {
        lines.push(Line::styled(
            format!("  {l}"),
            Style::default().fg(Color::DarkGray),
        ));
    }
    Paragraph::new(lines)
        .wrap(Wrap { trim: false })
        .render(inner, buf);
}

fn draw_input(buf: &mut Buffer, app: &App, area: Rect) {
    // Claude Code / Copilot–style composer: full-width rules above and below the
    // input (no side borders) with a `❯` prompt gutter on the left, rather than a
    // boxed-in field. The arrow takes the accent colour; the rules stay dim.
    let accent = if app.is_streaming() {
        Color::Yellow
    } else {
        Color::Cyan
    };
    let block = Block::default()
        .borders(Borders::TOP | Borders::BOTTOM)
        .border_style(Style::default().fg(Color::DarkGray));
    let inner = block.inner(area);
    block.render(area, buf);

    // A two-column gutter holds the prompt arrow; the textarea fills the rest.
    // The arrow sits on the first row only, so wrapped and multi-line input
    // aligns under the text with a blank gutter on continuation rows.
    let [gutter, text] =
        Layout::horizontal([Constraint::Length(2), Constraint::Min(1)]).areas(inner);
    if gutter.height > 0 {
        buf.set_string(
            gutter.x,
            gutter.y,
            "❯",
            Style::default().fg(accent).add_modifier(Modifier::BOLD),
        );
    }
    (&app.input).render(text, buf);
}

fn draw_status(buf: &mut Buffer, app: &App, area: Rect) {
    let dim = Style::default().fg(Color::DarkGray);

    // The key hint lives in its own right-aligned region so it stays visible no
    // matter how wide the (lower-priority) left content grows.
    let hint = " ^T stats · ^C quit";
    let hint_w = hint.chars().count() as u16;
    let [left, right] =
        Layout::horizontal([Constraint::Min(0), Constraint::Length(hint_w)]).areas(area);

    // Left content, ordered most-important-first: ratatui truncates a
    // non-wrapping Line at the right edge, so the least useful piece (base_url)
    // sits last and is the first to clip on a narrow terminal.
    let mut spans: Vec<Span> = Vec::new();
    // A pending "press again to exit" takes priority on the left so it's seen.
    if let Some(hint) = app.quit_hint() {
        spans.push(Span::styled(
            format!("{hint}  "),
            Style::default()
                .fg(Color::Yellow)
                .add_modifier(Modifier::BOLD),
        ));
    }
    if app.is_streaming() {
        // Live progress straight from the stream: a spinner, elapsed time, and an
        // output-token estimate (~4 chars/token) of what's arrived so far.
        let secs = app.turn_start.map(|t| t.elapsed().as_secs()).unwrap_or(0);
        let est = (app.conv.current_output_chars as u64).div_ceil(4);
        // "~" because the count is estimated from streamed text (≈4 chars/token);
        // the exact figure only arrives with the final usage.
        spans.push(Span::styled(
            format!(
                "{} {} · ↓ ~{} tok  ",
                SPINNER[app.spinner % SPINNER.len()],
                fmt_elapsed(secs),
                fmt_count(est),
            ),
            Style::default().fg(Color::Yellow),
        ));
        // Messages queued behind this turn (typed while it streams).
        let q = app.queued_len();
        if q > 0 {
            spans.push(Span::styled(
                format!("· {q} queued  "),
                Style::default().fg(Color::Yellow),
            ));
        }
    }
    spans.push(Span::styled(
        app.settings.model.clone(),
        Style::default()
            .fg(Color::Green)
            .add_modifier(Modifier::BOLD),
    ));
    // Cumulative totals only when idle — while streaming the live readout above
    // is the relevant figure, and showing both is noise.
    let t = &app.conv.totals;
    if !app.is_streaming() && (t.messages > 0 || t.responses > 0) {
        if app.show_stats {
            push_detailed_stats(&mut spans, app, dim);
        } else {
            push_compact_stats(&mut spans, t, dim);
        }
    }
    spans.push(Span::styled(format!("  {}", app.settings.base_url), dim));

    Paragraph::new(Line::from(spans)).render(left, buf);
    Paragraph::new(Line::from(Span::styled(hint, dim)))
        .alignment(Alignment::Right)
        .render(right, buf);
}

/// Low-real-estate running total: cumulative tokens (and cost, if priced). The
/// token span only appears once there's been real usage, so a just-sent first
/// turn doesn't flash a misleading "Σ 0 tok".
fn push_compact_stats(spans: &mut Vec<Span<'static>>, t: &Totals, dim: Style) {
    if t.total_tokens > 0 {
        spans.push(Span::styled(
            format!("  Σ {} tok", fmt_count(t.total_tokens)),
            dim,
        ));
    }
    if let Some(c) = t.cost {
        spans.push(Span::styled(format!("  {}", fmt_cost(c)), dim));
    }
}

/// Full breakdown. Ordered headline-first (messages · total · cost) so the most
/// useful figures survive truncation, with the sent/received (and cached/
/// reasoning) breakdown and last-turn usage trailing.
fn push_detailed_stats(spans: &mut Vec<Span<'static>>, app: &App, dim: Style) {
    let t = &app.conv.totals;
    spans.push(Span::styled(format!("  {} msg", t.messages), dim));
    if t.total_tokens > 0 {
        spans.push(Span::styled(
            format!(" · Σ {} tok", fmt_count(t.total_tokens)),
            dim,
        ));
    }
    if let Some(c) = t.cost {
        spans.push(Span::styled(format!(" · {}", fmt_cost(c)), dim));
    }
    spans.push(Span::styled(
        format!("  ↑{}", fmt_count(t.input_tokens)),
        dim,
    ));
    if t.cached_tokens > 0 {
        spans.push(Span::styled(
            format!(" (cached {})", fmt_count(t.cached_tokens)),
            dim,
        ));
    }
    spans.push(Span::styled(
        format!(" ↓{}", fmt_count(t.output_tokens)),
        dim,
    ));
    if t.reasoning_tokens > 0 {
        spans.push(Span::styled(
            format!(" (reason {})", fmt_count(t.reasoning_tokens)),
            dim,
        ));
    }
    if let Some(u) = app.conv.last_usage {
        spans.push(Span::styled(
            format!("  [last ↑{} ↓{}]", u.input_tokens, u.output_tokens),
            dim,
        ));
    }
}

/// Compact token counts: `1.2k`, `34k`, `1.5M`. Buckets are chosen on the
/// rounded value so boundaries don't render oddly (e.g. 9999 → `10k`, not
/// `10.0k`; 999_900 → `1.0M`, not `1000k`).
fn fmt_count(n: u64) -> String {
    let f = n as f64;
    if f >= 999_500.0 {
        format!("{:.1}M", f / 1_000_000.0)
    } else if f >= 9_950.0 {
        format!("{:.0}k", f / 1_000.0)
    } else if f >= 1_000.0 {
        format!("{:.1}k", f / 1_000.0)
    } else {
        n.to_string()
    }
}

/// Elapsed turn time: `5s`, `1m05s`.
fn fmt_elapsed(secs: u64) -> String {
    if secs >= 60 {
        format!("{}m{:02}s", secs / 60, secs % 60)
    } else {
        format!("{secs}s")
    }
}

/// Dollar cost, with more precision for sub-cent amounts.
fn fmt_cost(c: f64) -> String {
    if c == 0.0 {
        "$0".to_string()
    } else if c >= 0.01 {
        format!("${c:.4}")
    } else {
        format!("${c:.6}")
    }
}

/// One-line session usage summary for the exit banner (plain text): messages,
/// cumulative tokens (with ↑in / ↓out and cached/reasoning when present), and
/// cost when the server priced it. Reuses the in-app formatters so the numbers
/// match the status line.
pub fn exit_summary(t: &Totals) -> String {
    let mut s = format!("{} msg · {} resp", t.messages, t.responses);
    if t.total_tokens > 0 {
        s.push_str(&format!(
            " · Σ {} tok (↑{} ↓{}",
            fmt_count(t.total_tokens),
            fmt_count(t.input_tokens),
            fmt_count(t.output_tokens),
        ));
        if t.cached_tokens > 0 {
            s.push_str(&format!(" cached {}", fmt_count(t.cached_tokens)));
        }
        if t.reasoning_tokens > 0 {
            s.push_str(&format!(" reason {}", fmt_count(t.reasoning_tokens)));
        }
        s.push(')');
    }
    match t.cost {
        Some(c) => s.push_str(&format!(" · {}", fmt_cost(c))),
        None => s.push_str(" · cost n/a"),
    }
    s
}

#[cfg(test)]
mod tests {
    use super::{exit_summary, fmt_cost, fmt_count, fmt_elapsed, welcome_lines};
    use hagent_core::config::{ContainerConfig, Settings, ToolKind};
    use hagent_core::protocol::request::ReasoningConfig;
    use hagent_core::Totals;

    fn banner_text(settings: &Settings) -> String {
        welcome_lines(settings)
            .iter()
            .flat_map(|l| l.spans.iter())
            .map(|s| s.content.as_ref())
            .collect()
    }

    #[test]
    fn welcome_banner_shows_mascot_and_declared_session_info() {
        let settings = Settings {
            base_url: "http://localhost:8080/api".into(),
            api_key: None,
            model: "gpt-5.2".into(),
            instructions: None,
            system_prompt: None,
            // file_search/mcp are requested but have no prerequisites configured.
            tools: ToolKind::all(),
            vector_store_ids: vec![],
            mcp_servers: vec![],
            compaction: None,
            container: ContainerConfig::default(),
            reasoning: Some(ReasoningConfig {
                effort: Some("high".into()),
                summary: Some("auto".into()),
            }),
            skill_id: None,
            log_dir: None,
            redact_logs: true,
        };
        let text = banner_text(&settings);

        // Stylised H mascot + title/tagline (no border box).
        assert!(!text.contains('╭'), "no border box:\n{text}");
        assert!(
            text.contains("███████") && text.contains("██   ██"),
            "stylised H mascot:\n{text}"
        );
        assert!(text.contains("hagent"), "title:\n{text}");
        assert!(
            text.contains("a thin terminal client for Hadrian"),
            "tagline:\n{text}"
        );

        // Session info, with the scheme stripped from the server URL.
        assert!(text.contains("gpt-5.2"), "model:\n{text}");
        assert!(text.contains("localhost:8080/api"), "server:\n{text}");
        assert!(!text.contains("http://"), "scheme stripped:\n{text}");
        assert!(text.contains("high"), "reasoning effort:\n{text}");

        // Tools mirror what's actually declared: shell/web_search survive, but
        // file_search/mcp are dropped here (no vector stores / no servers).
        assert!(text.contains("shell"), "shell:\n{text}");
        assert!(text.contains("web_search"), "web_search:\n{text}");
        assert!(
            !text.contains("file_search"),
            "file_search dropped:\n{text}"
        );
        assert!(!text.contains("mcp"), "mcp dropped:\n{text}");
    }

    #[test]
    fn welcome_banner_reports_no_tools_and_reasoning_off() {
        let settings = Settings {
            base_url: "https://api.example.com".into(),
            api_key: None,
            model: "test/smoke".into(),
            instructions: None,
            system_prompt: None,
            tools: vec![],
            vector_store_ids: vec![],
            mcp_servers: vec![],
            compaction: None,
            container: ContainerConfig::default(),
            reasoning: None,
            skill_id: None,
            log_dir: None,
            redact_logs: true,
        };
        let text = banner_text(&settings);
        assert!(text.contains("none"), "no tools -> none:\n{text}");
        assert!(text.contains("off"), "no reasoning -> off:\n{text}");
        assert!(
            text.contains("api.example.com"),
            "https scheme stripped:\n{text}"
        );
    }

    #[test]
    fn h_shimmer_highlight_sweeps_then_settles() {
        use super::h_style;
        use ratatui::style::{Color, Modifier};

        let fg = |s: ratatui::style::Style| s.fg;
        let bold = |s: ratatui::style::Style| s.add_modifier.contains(Modifier::BOLD);

        // Settled (no frame): every column is the same solid bold cyan.
        for col in 0..7 {
            let s = h_style(col, None);
            assert_eq!(fg(s), Some(Color::Cyan), "settled col {col}");
            assert!(bold(s), "settled col {col} bold");
        }

        // Frame 0: the white highlight is on column 0, lighter cyan on its
        // neighbour — and it moves with the frame.
        assert_eq!(fg(h_style(0, Some(0))), Some(Color::White), "peak at col 0");
        assert_eq!(fg(h_style(1, Some(0))), Some(Color::LightCyan), "flank");
        assert_eq!(
            fg(h_style(3, Some(3))),
            Some(Color::White),
            "peak moved to 3"
        );
        assert_eq!(
            fg(h_style(0, Some(3))),
            Some(Color::Cyan),
            "col 0 back to base"
        );
    }

    #[test]
    fn fmt_count_buckets_round_cleanly() {
        assert_eq!(fmt_count(0), "0");
        assert_eq!(fmt_count(999), "999");
        assert_eq!(fmt_count(1_000), "1.0k");
        assert_eq!(fmt_count(9_949), "9.9k");
        // Boundary: anything that would round to 10k uses the `{:.0}k` bucket,
        // so we never print "10.0k".
        assert_eq!(fmt_count(9_950), "10k");
        assert_eq!(fmt_count(9_999), "10k");
        assert_eq!(fmt_count(10_000), "10k");
        assert_eq!(fmt_count(34_000), "34k");
        assert_eq!(fmt_count(999_499), "999k");
        // Boundary: ~1M rounds up to "1.0M", not "1000k".
        assert_eq!(fmt_count(999_500), "1.0M");
        assert_eq!(fmt_count(1_000_000), "1.0M");
        assert_eq!(fmt_count(1_500_000), "1.5M");
    }

    #[test]
    fn fmt_elapsed_formats() {
        assert_eq!(fmt_elapsed(0), "0s");
        assert_eq!(fmt_elapsed(5), "5s");
        assert_eq!(fmt_elapsed(59), "59s");
        assert_eq!(fmt_elapsed(60), "1m00s");
        assert_eq!(fmt_elapsed(65), "1m05s");
        assert_eq!(fmt_elapsed(605), "10m05s");
    }

    #[test]
    fn fmt_cost_precision() {
        assert_eq!(fmt_cost(0.0), "$0");
        assert_eq!(fmt_cost(0.0012), "$0.001200");
        assert_eq!(fmt_cost(0.5), "$0.5000");
        assert_eq!(fmt_cost(1.25), "$1.2500");
    }

    #[test]
    fn exit_summary_shows_usage_and_cost() {
        let t = Totals {
            messages: 3,
            responses: 3,
            input_tokens: 1200,
            output_tokens: 300,
            total_tokens: 1500,
            cost: Some(0.0024),
            ..Default::default()
        };
        let s = exit_summary(&t);
        assert!(s.contains("3 msg"), "{s}");
        assert!(s.contains("tok"), "{s}");
        assert!(s.contains("↑1.2k"), "{s}");
        assert!(s.contains("$0.0024"), "{s}");

        // Unpriced providers report no cost.
        let unpriced = Totals {
            messages: 1,
            responses: 1,
            total_tokens: 0,
            cost: None,
            ..Default::default()
        };
        assert!(exit_summary(&unpriced).contains("cost n/a"));
    }
}
