use ansi_to_tui::IntoText;
use capo_agent::settings::UiSettings;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;

use crate::markdown;
use crate::state::{AppState, InFlight, MessageBlock};
use crate::theme;

/// Cap on inline tool output rows shown in the transcript. Codex's
/// `exec_cell` uses the same value (`render.rs:32 TOOL_CALL_MAX_LINES = 5`)
/// — beyond this, the bottom of stdout is shown and the middle is
/// replaced by a dim "… +N lines" hint so the cell stays scannable.
const TOOL_OUTPUT_MAX_ROWS: usize = 5;

/// v0.9 Phase A predicate: is this block rendered at all?
/// Only `Thinking` blocks can become invisible (via
/// `hide_thinking_blocks` setting). `expanded_all` overrides.
pub fn is_visible(block: &MessageBlock, settings: &UiSettings, expanded_all: bool) -> bool {
    if expanded_all {
        return true;
    }
    match block {
        MessageBlock::Thinking { .. } => !settings.hide_thinking_blocks,
        _ => true,
    }
}

/// v0.9 Phase A predicate: render the collapsed (1-line) view instead of
/// the full view? Per-block `collapsed: bool` wins over settings.
/// `expanded_all` overrides both.
pub fn is_collapsed_view(
    block: &MessageBlock,
    index: usize,
    total: usize,
    settings: &UiSettings,
    expanded_all: bool,
) -> bool {
    if expanded_all {
        return false;
    }
    match block {
        MessageBlock::ToolCall {
            collapsed: true, ..
        } => true,
        MessageBlock::ToolResult {
            collapsed: true, ..
        } => true,
        MessageBlock::Thinking {
            collapsed: true, ..
        } => true,
        MessageBlock::ToolResult { .. } if settings.collapse_tool_output_by_default => true,
        MessageBlock::ToolCall { args, .. }
            if settings.collapse_tool_args_max_lines > 0
                && arg_line_count(args) > settings.collapse_tool_args_max_lines as usize =>
        {
            true
        }
        // Per-block toggle wins (spec §2.3 priority order).
        MessageBlock::Assistant {
            collapsed: true, ..
        } => true,
        MessageBlock::Assistant { .. }
            if settings.collapse_history_after > 0
                && index + (settings.collapse_history_after as usize) < total =>
        {
            true
        }
        _ => false,
    }
}

/// Count pretty-printed JSON line count for collapse-args threshold.
fn arg_line_count(args: &serde_json::Value) -> usize {
    serde_json::to_string_pretty(args)
        .map(|s| s.lines().count())
        .unwrap_or(0)
}

/// v0.10 Phase B-3: approximate wrapped row count for one block.
///
/// This intentionally avoids extracting render's coalesced ToolCall +
/// ToolResult line building. Scroll-into-view only needs a close enough
/// off-screen/on-screen heuristic, so text length + terminal width is enough.
pub fn block_wrap_row_count(
    block: &MessageBlock,
    index: usize,
    total: usize,
    settings: &UiSettings,
    expanded_all: bool,
    width: u16,
) -> u16 {
    if !is_visible(block, settings, expanded_all) {
        return 0;
    }
    if is_collapsed_view(block, index, total, settings, expanded_all) {
        return 1;
    }

    let text = match block {
        MessageBlock::User { text, .. }
        | MessageBlock::Assistant { text, .. }
        | MessageBlock::Thinking { text, .. }
        | MessageBlock::Error(text) => text,
        MessageBlock::Notice { body, .. } => body,
        MessageBlock::ToolCall { args, .. } => return (arg_line_count(args).min(10)) as u16,
        MessageBlock::ToolResult { result, .. } => {
            return (result.text.lines().count().min(TOOL_OUTPUT_MAX_ROWS)) as u16;
        }
    };
    estimate_wrapped_rows(text, width.max(1))
}

fn estimate_wrapped_rows(text: &str, width: u16) -> u16 {
    text.lines()
        .map(|line| {
            let len = line.chars().count() as u16;
            (len.saturating_sub(1) / width).saturating_add(1)
        })
        .sum::<u16>()
        .max(1)
}

/// v0.10 Phase B-3: number of wrapped rows below selected block's bottom row.
pub fn row_position_from_bottom(
    blocks: &[MessageBlock],
    selected_index: usize,
    settings: &UiSettings,
    expanded_all: bool,
    width: u16,
) -> u16 {
    let total = blocks.len();
    let mut rows = 0_u16;
    for (i, block) in blocks.iter().enumerate().skip(selected_index + 1) {
        rows = rows.saturating_add(block_wrap_row_count(
            block,
            i,
            total,
            settings,
            expanded_all,
            width,
        ));
    }
    rows
}

fn display_tool_name(raw: &str) -> String {
    // MCP namespacing: `<server>__<tool>` → `<server>:<tool>` for humans.
    match raw.split_once("__") {
        Some((server, tool)) if !server.is_empty() && !tool.is_empty() => {
            format!("{server}:{tool}")
        }
        _ => raw.to_string(),
    }
}

/// Pick the single most identifying field from a tool call's args for
/// display in the exec-cell header — e.g. `Edit(foo.rs)` not just `Edit`.
///
/// Returns `None` for tools we can't classify (MCP / unknown), so the
/// renderer can fall back to the existing dim-JSON-preview path.
///
/// Handles both capo's builtin schema (`path`, `command`) and the
/// Anthropic-style schema (`file_path`) used by MCP servers that mirror
/// Claude's tool conventions. The local name (post-MCP-split) is
/// lowercased before matching so `Read` and `read` both classify.
fn summarize_tool_args(name: &str, args: &serde_json::Value) -> Option<String> {
    let local = name.split_once("__").map(|(_, t)| t).unwrap_or(name);
    let kind = local.to_ascii_lowercase();
    let path_field = args
        .get("file_path")
        .or_else(|| args.get("path"))
        .and_then(|v| v.as_str());
    let command = args.get("command").and_then(|v| v.as_str());
    let pattern = args.get("pattern").and_then(|v| v.as_str());
    let url = args.get("url").and_then(|v| v.as_str());
    match kind.as_str() {
        "read" | "write" | "edit" | "ls" | "notebookedit" | "notebookread" => {
            path_field.map(|p| summarize(p, 80))
        }
        "bash" | "shell" => command.map(|c| {
            let first = c.lines().next().unwrap_or("");
            summarize(first, 80)
        }),
        "grep" => pattern.map(|p| match path_field {
            Some(path) => summarize(&format!("\"{p}\" in {path}"), 80),
            None => summarize(&format!("\"{p}\""), 80),
        }),
        "find" | "glob" => pattern.map(|p| summarize(p, 80)),
        "webfetch" | "fetch" => url.map(|u| summarize(u, 80)),
        _ => None,
    }
}

pub fn render(state: &AppState, frame: &mut Frame, area: Rect) {
    state
        .last_messages_viewport
        .set(Some((area.width, area.height)));

    let mut lines: Vec<Line> = Vec::new();
    let ui_settings: UiSettings = state
        .settings_snapshot
        .as_ref()
        .map(|s| s.ui.clone())
        .unwrap_or_default();
    let expanded_all = state.expanded_all;
    let blocks = &state.messages;
    let total = blocks.len();
    let mut i = 0;
    while i < blocks.len() {
        if !is_visible(&blocks[i], &ui_settings, expanded_all) {
            i += 1;
            continue;
        }
        // Codex-style coalescing: when a ToolCall is immediately followed
        // by its matching ToolResult (same id), render as a single
        // exec-cell with status-colored bullet (`✓`/`✗`) and bounded
        // output. Standalone ToolCall (no result yet — usually a Phase F
        // race window) falls through to the neutral-bullet path.
        match (&blocks[i], blocks.get(i + 1)) {
            (
                MessageBlock::ToolCall { id, name, args, .. },
                Some(MessageBlock::ToolResult {
                    id: rid, result, ..
                }),
            ) if rid == id => {
                let call_collapsed = collapsed_view_for_state(
                    state,
                    &blocks[i],
                    i,
                    total,
                    &ui_settings,
                    expanded_all,
                );
                let result_collapsed = collapsed_view_for_state(
                    state,
                    &blocks[i + 1],
                    i + 1,
                    total,
                    &ui_settings,
                    expanded_all,
                );
                let line_start = lines.len();
                render_exec_cell_with_collapse(
                    name,
                    args,
                    Some(result),
                    call_collapsed,
                    result_collapsed,
                    &mut lines,
                );
                if state.nav_mode {
                    add_exec_nav_gutter(
                        &mut lines[line_start..],
                        state.nav_selected == Some(i),
                        state.nav_selected == Some(i + 1),
                    );
                }
                lines.push(Line::from(""));
                i += 2;
                continue;
            }
            _ => {}
        }
        let line_start = lines.len();
        let collapsed_view =
            collapsed_view_for_state(state, &blocks[i], i, total, &ui_settings, expanded_all);
        render_block(&blocks[i], collapsed_view, &mut lines);
        if state.nav_mode {
            add_nav_gutter(&mut lines[line_start..], state.nav_selected == Some(i));
        }
        lines.push(Line::from(""));
        i += 1;
    }

    if let Some(in_flight) = &state.in_flight {
        render_in_flight(in_flight, &mut lines);
    }

    // Auto-follow scroll: when transcript exceeds the visible area, anchor
    // to the bottom so the latest line is always on screen.
    //
    // ratatui's `Paragraph::scroll((y, _))` skips `y` *wrapped* rows, not raw
    // `Line` items — see `WrappedLine` handling in
    // ratatui-0.29/src/widgets/paragraph.rs::render_text. Counting raw lines
    // under-shoots whenever any message wraps, leaving the latest content
    // still off-screen. `Paragraph::line_count(width)` gives the true wrapped
    // row count (gated behind `unstable-rendered-line-info` in ratatui 0.29;
    // enabled in the workspace Cargo.toml).
    let base = Paragraph::new(lines).wrap(Wrap { trim: false });
    let wrapped_rows = base.line_count(area.width) as u16;
    let bottom = wrapped_rows.saturating_sub(area.height);
    // `scroll_lines_from_bottom = 0` means auto-follow (track `bottom`
    // every frame). Positive values pin the viewport that many rows above
    // the bottom; saturating-sub clamps to top so Home (large sentinel)
    // and over-scroll-up both land at row 0 naturally.
    let scroll_y = bottom.saturating_sub(state.scroll_lines_from_bottom);

    let para = base
        .scroll((scroll_y, 0))
        .block(Block::default().borders(Borders::NONE));
    frame.render_widget(para, area);
}

fn collapsed_view_for_state(
    state: &AppState,
    block: &MessageBlock,
    index: usize,
    total: usize,
    settings: &UiSettings,
    expanded_all: bool,
) -> bool {
    if expanded_all {
        return false;
    }
    state
        .collapse_overrides
        .get(&index)
        .copied()
        .unwrap_or_else(|| is_collapsed_view(block, index, total, settings, expanded_all))
}

fn add_nav_gutter(lines: &mut [Line<'_>], is_selected: bool) {
    for (idx, line) in lines.iter_mut().enumerate() {
        let gutter = if idx == 0 && is_selected {
            Span::styled(
                "▸ ",
                Style::default()
                    .fg(Color::Cyan)
                    .add_modifier(Modifier::BOLD),
            )
        } else {
            Span::raw("  ")
        };
        line.spans.insert(0, gutter);
    }
}

fn add_exec_nav_gutter(lines: &mut [Line<'_>], call_selected: bool, result_selected: bool) {
    for (idx, line) in lines.iter_mut().enumerate() {
        let result_line = line
            .spans
            .first()
            .is_some_and(|span| span.content == "  └ ");
        let is_selected = (idx == 0 && call_selected) || (result_line && result_selected);
        let gutter = if is_selected {
            Span::styled(
                "▸ ",
                Style::default()
                    .fg(Color::Cyan)
                    .add_modifier(Modifier::BOLD),
            )
        } else {
            Span::raw("  ")
        };
        line.spans.insert(0, gutter);
    }
}

fn render_block(block: &MessageBlock, collapsed_view: bool, out: &mut Vec<Line>) {
    match block {
        MessageBlock::User {
            text,
            attachment_filenames,
        } => {
            // Codex convention: bold+dim `› ` (U+203A) prefix on first
            // line, `  ` continuation indent. Body uses default fg.
            let prefix_style = Style::default().add_modifier(Modifier::BOLD | Modifier::DIM);
            let cont_style = Style::default().add_modifier(Modifier::DIM);
            let mut first_line_emitted = false;
            for line in text.lines() {
                let marker = if !first_line_emitted { "› " } else { "  " };
                first_line_emitted = true;
                out.push(Line::from(vec![
                    Span::styled(marker, prefix_style),
                    Span::raw(line.to_string()),
                ]));
            }
            // Attachment marker continuation (spec §5.3).
            if !attachment_filenames.is_empty() {
                let marker = if !first_line_emitted { "› " } else { "  " };
                let label = if attachment_filenames.len() == 1 {
                    format!("↳ image: {}", attachment_filenames[0])
                } else {
                    format!(
                        "↳ {} images: {}",
                        attachment_filenames.len(),
                        attachment_filenames.join(", "),
                    )
                };
                out.push(Line::from(vec![
                    Span::styled(marker, prefix_style),
                    Span::styled(label, cont_style),
                ]));
            }
        }
        MessageBlock::Assistant { text, .. } => {
            if collapsed_view {
                let first_line = text.lines().next().unwrap_or("");
                let rest_count = text.lines().count().saturating_sub(1);
                out.push(Line::from(vec![
                    Span::styled("• ", Style::default().add_modifier(Modifier::DIM)),
                    Span::raw(first_line.to_string()),
                    Span::styled(format!("  … +{rest_count} lines"), theme::dim()),
                ]));
            } else {
                // Render through pulldown-cmark → ratatui Lines so headings,
                // lists, code spans, links, etc. all style correctly. Prefix
                // first line with dim `• ` and continuation lines with `  `
                // (codex `AgentMarkdownCell` shape).
                let md_lines = markdown::render_markdown(text, 0);
                let prefix_style = Style::default().add_modifier(Modifier::DIM);
                for (idx, line) in md_lines.into_iter().enumerate() {
                    let marker = if idx == 0 { "• " } else { "  " };
                    let mut spans = Vec::with_capacity(line.spans.len() + 1);
                    spans.push(Span::styled(marker, prefix_style));
                    spans.extend(line.spans);
                    out.push(Line::from(spans));
                }
            }
        }
        MessageBlock::ToolCall { name, args, .. } => {
            // Standalone ToolCall (no matching ToolResult yet) — usually
            // a transient state during streaming. Render with neutral
            // bullet so it doesn't promise a status that hasn't landed.
            // When the ToolResult arrives, the coalescing path in
            // `render()` replaces this with `render_exec_cell`.
            render_exec_cell_with_collapse(name, args, None, collapsed_view, false, out);
        }
        MessageBlock::ToolResult { result, .. } => {
            // Bare ToolResult without preceding ToolCall — shouldn't
            // happen in practice. Degrade gracefully.
            let bullet_style = if result.is_error {
                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
            } else {
                Style::default()
                    .fg(Color::Green)
                    .add_modifier(Modifier::BOLD)
            };
            let bullet = if result.is_error { "✗" } else { "✓" };
            out.push(Line::from(vec![
                Span::styled(format!("{bullet} "), bullet_style),
                Span::styled("tool result", theme::dim()),
            ]));
            if collapsed_view {
                render_tool_output_collapsed(result, out);
            } else {
                render_tool_output_lines(&result.text, out);
            }
        }
        MessageBlock::Error(msg) => {
            out.push(Line::from(vec![
                Span::styled("[error] ", theme::error()),
                Span::styled(msg.clone(), theme::error()),
            ]));
        }
        MessageBlock::Notice { title, body } => {
            out.push(Line::from(Span::styled(title.clone(), theme::dim())));
            for line in body.lines() {
                out.push(Line::from(vec![
                    Span::styled("  ", theme::dim()),
                    Span::styled(line.to_string(), theme::dim()),
                ]));
            }
        }
        MessageBlock::Thinking { text, .. } => {
            let style = Style::default()
                .fg(Color::DarkGray)
                .add_modifier(Modifier::ITALIC);
            if collapsed_view {
                let line_count = text.lines().count();
                out.push(Line::from(Span::styled(
                    format!("  💭 thinking ({line_count} lines)"),
                    style,
                )));
            } else {
                for line in text.lines() {
                    out.push(Line::from(vec![
                        Span::raw("  "),
                        Span::styled(line.to_string(), style),
                    ]));
                }
            }
        }
    }
}

/// Coalesced render of `ToolCall` + optional `ToolResult` as a single
/// exec-cell-ish block. Mirrors codex's `ExecCell` shape:
/// `<status-bullet> <tool name>` header, then dim args preview, then up
/// to `TOOL_OUTPUT_MAX_ROWS` lines of output (tail-biased with a `… +N
/// lines` middle hint when truncated).
fn render_exec_cell_with_collapse(
    name: &str,
    args: &serde_json::Value,
    result: Option<&capo_agent::UiToolResult>,
    collapse_args: bool,
    collapse_output: bool,
    out: &mut Vec<Line>,
) {
    let (bullet, bullet_style) = match result {
        Some(r) if r.is_error => (
            "✗",
            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
        ),
        Some(_) => (
            "✓",
            Style::default()
                .fg(Color::Green)
                .add_modifier(Modifier::BOLD),
        ),
        None => ("•", theme::tool().add_modifier(Modifier::BOLD)),
    };
    let display_name = display_tool_name(name);
    let summary = summarize_tool_args(name, args);
    if collapse_args {
        let line_count = arg_line_count(args);
        let tail = match &summary {
            Some(s) => format!("({s}) +{line_count} lines"),
            None => format!("({{...}} +{line_count} lines)"),
        };
        out.push(Line::from(vec![
            Span::styled(format!("{bullet} "), bullet_style),
            Span::styled(display_name, theme::tool()),
            Span::styled(tail, theme::dim()),
        ]));
    } else if let Some(s) = summary {
        // Known tool: inline the semantic field directly in the header
        // line instead of a second dim row of raw JSON.
        out.push(Line::from(vec![
            Span::styled(format!("{bullet} "), bullet_style),
            Span::styled(display_name, theme::tool()),
            Span::styled(format!("({s})"), theme::dim()),
        ]));
    } else {
        // Unknown tool (MCP / unclassified): keep the v0.10 two-line
        // shape so the user still sees something useful for tools we
        // don't have a per-tool field mapping for.
        out.push(Line::from(vec![
            Span::styled(format!("{bullet} "), bullet_style),
            Span::styled(display_name, theme::tool()),
        ]));

        let preview = summarize(&args.to_string(), 80);
        if !preview.is_empty() {
            out.push(Line::from(vec![
                Span::styled("  ", theme::dim()),
                Span::styled(preview, theme::dim()),
            ]));
        }
    }

    if let Some(r) = result {
        if collapse_output {
            render_tool_output_collapsed(r, out);
        } else if r.text.trim().is_empty() {
            render_tool_output_empty(out);
        } else {
            render_tool_output_lines(&r.text, out);
        }
    }
}

fn render_tool_output_collapsed(result: &capo_agent::UiToolResult, out: &mut Vec<Line>) {
    let line_count = result.text.lines().count();
    let status = if result.is_error { "✗ error" } else { "✓" };
    out.push(Line::from(vec![
        Span::styled("  └ ", theme::dim()),
        Span::styled(format!("{status} ({line_count} lines)"), theme::dim()),
    ]));
}

fn render_tool_output_empty(out: &mut Vec<Line>) {
    out.push(Line::from(vec![
        Span::styled("  └ ", theme::dim()),
        Span::styled("(no output)", theme::dim()),
    ]));
}

/// Render tool output text with a `  └ ` / `    ` indent and a hard cap
/// at `TOOL_OUTPUT_MAX_ROWS` lines (tail-biased, with a dim middle hint
/// `… +N lines` when truncated). ANSI escape codes in the source text
/// are interpreted (via `IntoText`) so colored CLI output survives.
fn render_tool_output_lines(text: &str, out: &mut Vec<Line>) {
    if text.trim().is_empty() {
        return;
    }
    let parsed = text
        .to_string()
        .into_text()
        .map(|t| t.lines)
        .unwrap_or_else(|_| {
            text.lines()
                .map(|l| Line::from(Span::styled(l.to_string(), theme::dim())))
                .collect()
        });
    let total = parsed.len();
    if total <= TOOL_OUTPUT_MAX_ROWS {
        for (idx, line) in parsed.into_iter().enumerate() {
            out.push(indent_output_line(line, idx == 0));
        }
        return;
    }
    // Tail-biased truncation: show the LAST N rows with a `… +M lines`
    // hint as the first body row so the user sees the most recent /
    // most relevant output (errors, final status lines).
    let omitted = total - (TOOL_OUTPUT_MAX_ROWS - 1);
    out.push(Line::from(vec![
        Span::styled("  └ ", theme::dim()),
        Span::styled(format!("… +{omitted} lines"), theme::dim()),
    ]));
    let tail_start = total - (TOOL_OUTPUT_MAX_ROWS - 1);
    for line in parsed.into_iter().skip(tail_start) {
        out.push(indent_output_line(line, false));
    }
}

fn indent_output_line<'a>(line: Line<'a>, is_first: bool) -> Line<'a> {
    let prefix = if is_first { "  └ " } else { "    " };
    let mut spans = Vec::with_capacity(line.spans.len() + 1);
    spans.push(Span::styled(prefix, theme::dim()));
    for span in line.spans {
        // Force every output span dim so colored CLI output reads as
        // metadata, not foreground content. Matches codex's
        // `add_modifier(DIM)` over ANSI-decoded spans.
        let mut s = span;
        s.style = s.style.add_modifier(Modifier::DIM);
        spans.push(s);
    }
    Line::from(spans)
}

fn render_in_flight(in_flight: &InFlight, out: &mut Vec<Line>) {
    match in_flight {
        InFlight::Thinking => {
            out.push(Line::from(Span::styled("…thinking", theme::dim())));
        }
        InFlight::StreamingText { partial } => {
            for line in partial.lines() {
                out.push(Line::from(Span::styled(
                    line.to_string(),
                    theme::assistant(),
                )));
            }
        }
        InFlight::ToolCall { name, .. } => {
            out.push(Line::from(Span::styled(
                format!("● {name} …"),
                theme::tool(),
            )));
        }
        InFlight::ToolRunning {
            name,
            stdout,
            stderr,
            ..
        } => {
            out.push(Line::from(Span::styled(
                format!("● {name} (running)"),
                theme::tool(),
            )));
            for line in stdout
                .lines()
                .rev()
                .take(5)
                .collect::<Vec<_>>()
                .into_iter()
                .rev()
            {
                out.push(Line::from(Span::styled(line.to_string(), theme::dim())));
            }
            if !stderr.is_empty() {
                out.push(Line::from(Span::styled("stderr:", theme::error())));
                for line in stderr
                    .lines()
                    .rev()
                    .take(3)
                    .collect::<Vec<_>>()
                    .into_iter()
                    .rev()
                {
                    out.push(Line::from(Span::styled(line.to_string(), theme::error())));
                }
            }
        }
    }
}

fn summarize(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        s.to_string()
    } else {
        let mut out: String = s.chars().take(max).collect();
        out.push('…');
        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::state::{AppState, MessageBlock};
    use capo_agent::settings::UiSettings;
    use ratatui::backend::TestBackend;
    use ratatui::Terminal;

    fn default_ui_with(
        hide_thinking: bool,
        collapse_tool_out: bool,
        args_max_lines: u32,
        history_after: u32,
    ) -> UiSettings {
        UiSettings {
            hide_thinking_blocks: hide_thinking,
            collapse_tool_output_by_default: collapse_tool_out,
            collapse_tool_args_max_lines: args_max_lines,
            collapse_history_after: history_after,
            ..UiSettings::default()
        }
    }

    fn state_with_messages_and_settings(
        messages: Vec<MessageBlock>,
        settings: UiSettings,
    ) -> AppState {
        AppState {
            messages,
            settings_snapshot: Some(capo_agent::settings::Settings {
                ui: settings,
                ..capo_agent::settings::Settings::default()
            }),
            ..AppState::default()
        }
    }

    // ── summarize_tool_args ──────────────────────────────────────
    // Extract the single most identifying field from a tool's args
    // for display in the exec-cell header — e.g. `Edit(foo.rs)` not
    // just `Edit`. Returns None for tools we don't know how to
    // summarize (MCP / unknown) so the renderer can fall back to its
    // existing generic-JSON-preview path.

    #[test]
    fn summarize_read_uses_path_field() {
        let args = serde_json::json!({"path": "src/main.rs"});
        assert_eq!(
            summarize_tool_args("read", &args).as_deref(),
            Some("src/main.rs"),
        );
    }

    #[test]
    fn summarize_anthropic_style_read_uses_file_path_field() {
        // Claude-style schema uses `file_path` instead of capo's `path`.
        let args = serde_json::json!({"file_path": "src/main.rs"});
        assert_eq!(
            summarize_tool_args("Read", &args).as_deref(),
            Some("src/main.rs"),
        );
    }

    #[test]
    fn summarize_bash_uses_command_first_line() {
        let args = serde_json::json!({"command": "echo hello"});
        assert_eq!(
            summarize_tool_args("bash", &args).as_deref(),
            Some("echo hello"),
        );
    }

    #[test]
    fn summarize_bash_collapses_multiline_to_first_line() {
        let args = serde_json::json!({"command": "set -e\nnpm test\nnpm build"});
        let summary =
            summarize_tool_args("bash", &args).unwrap_or_else(|| panic!("expected Some summary"));
        assert!(
            summary.starts_with("set -e"),
            "expected first line to lead; got {summary:?}"
        );
        assert!(
            !summary.contains('\n'),
            "summary must be single-line; got {summary:?}"
        );
    }

    #[test]
    fn summarize_grep_combines_pattern_and_path() {
        let args = serde_json::json!({"pattern": "fn main", "path": "src/"});
        let summary =
            summarize_tool_args("grep", &args).unwrap_or_else(|| panic!("expected Some summary"));
        assert!(
            summary.contains("fn main") && summary.contains("src/"),
            "pattern + path should both appear; got {summary:?}"
        );
    }

    #[test]
    fn summarize_grep_pattern_only_when_no_path() {
        let args = serde_json::json!({"pattern": "TODO"});
        assert_eq!(
            summarize_tool_args("grep", &args).as_deref(),
            Some("\"TODO\"")
        );
    }

    #[test]
    fn summarize_find_returns_pattern() {
        let args = serde_json::json!({"pattern": "*.rs"});
        assert_eq!(summarize_tool_args("find", &args).as_deref(), Some("*.rs"),);
    }

    #[test]
    fn summarize_webfetch_returns_url() {
        let args = serde_json::json!({"url": "https://example.com/x"});
        assert_eq!(
            summarize_tool_args("WebFetch", &args).as_deref(),
            Some("https://example.com/x"),
        );
    }

    #[test]
    fn summarize_unknown_tool_returns_none() {
        // Unknown / MCP tools fall back to the existing dim-JSON
        // preview path in the renderer; the helper returns None.
        let args = serde_json::json!({"some_arg": 42});
        assert!(summarize_tool_args("acmecorp__weather", &args).is_none());
    }

    #[test]
    fn summarize_mcp_namespaced_known_tool_uses_local_name() {
        // `server__read` should be classified as `read` (the MCP server's
        // tool happens to share the canonical name) and use `path`.
        let args = serde_json::json!({"path": "x.txt"});
        assert_eq!(
            summarize_tool_args("myserver__read", &args).as_deref(),
            Some("x.txt"),
        );
    }

    #[test]
    fn summarize_missing_field_returns_none() {
        // Tool is known but the expected field isn't present (malformed
        // call). Fall back to None so renderer uses the generic preview.
        let args = serde_json::json!({"unexpected": "value"});
        assert!(summarize_tool_args("read", &args).is_none());
    }

    #[test]
    fn summarize_truncates_very_long_field() {
        let long = "x".repeat(200);
        let args = serde_json::json!({"path": long});
        let summary =
            summarize_tool_args("read", &args).unwrap_or_else(|| panic!("expected Some summary"));
        // `summarize` truncates to N chars and appends `…`, so the
        // bounded output is N+1. We pass max=80 → expect ≤81.
        let n = summary.chars().count();
        assert!(n <= 81, "summary must be ≤81 chars; got {n} chars");
        assert!(
            summary.ends_with('…'),
            "long input should be marked truncated; got {summary:?}"
        );
    }

    #[test]
    fn is_visible_thinking_respects_setting() {
        let thinking = MessageBlock::Thinking {
            text: "x".into(),
            collapsed: false,
        };
        let s_hide = default_ui_with(true, true, 2, 0);
        let s_show = default_ui_with(false, true, 2, 0);
        assert!(!is_visible(&thinking, &s_hide, false));
        assert!(is_visible(&thinking, &s_show, false));
    }

    #[test]
    fn is_visible_other_blocks_always_visible() {
        let s = default_ui_with(true, true, 2, 0);
        let blocks: Vec<MessageBlock> = vec![
            MessageBlock::User {
                text: "x".into(),
                attachment_filenames: vec![],
            },
            MessageBlock::Assistant {
                text: "y".into(),
                collapsed: false,
            },
            MessageBlock::Error("z".into()),
            MessageBlock::Notice {
                title: "a".into(),
                body: "b".into(),
            },
        ];
        for b in &blocks {
            assert!(is_visible(b, &s, false), "block should be visible: {b:?}");
        }
    }

    #[test]
    fn is_visible_expanded_all_overrides_hide_thinking() {
        let thinking = MessageBlock::Thinking {
            text: "x".into(),
            collapsed: false,
        };
        let s_hide = default_ui_with(true, true, 2, 0);
        assert!(is_visible(&thinking, &s_hide, true));
    }

    #[test]
    fn is_collapsed_view_per_block_toggle_wins() {
        let s = default_ui_with(false, false, 0, 0);
        let collapsed_thinking = MessageBlock::Thinking {
            text: "x".into(),
            collapsed: true,
        };
        assert!(is_collapsed_view(&collapsed_thinking, 0, 1, &s, false));
    }

    #[test]
    fn is_collapsed_view_history_after_collapses_old_assistant() {
        let s = default_ui_with(false, false, 0, 10);
        let assistant = MessageBlock::Assistant {
            text: "response".into(),
            collapsed: false,
        };
        assert!(is_collapsed_view(&assistant, 5, 30, &s, false));
        assert!(!is_collapsed_view(&assistant, 25, 30, &s, false));
    }

    #[test]
    fn block_wrap_row_count_returns_zero_for_hidden_block() {
        let block = MessageBlock::Thinking {
            text: "x".into(),
            collapsed: false,
        };
        let settings = default_ui_with(true, true, 2, 0);
        let count = block_wrap_row_count(&block, 0, 1, &settings, false, 80);
        assert_eq!(count, 0);
    }

    #[test]
    fn block_wrap_row_count_returns_one_for_collapsed_view() {
        let block = MessageBlock::ToolResult {
            id: "t1".into(),
            result: capo_agent::UiToolResult {
                is_error: false,
                text: "many\nmany\nlines".into(),
            },
            collapsed: false,
        };
        let settings = default_ui_with(false, true, 2, 0);
        let count = block_wrap_row_count(&block, 0, 1, &settings, false, 80);
        assert_eq!(count, 1);
    }

    #[test]
    fn block_wrap_row_count_counts_wrapped_rows_for_full_view_assistant() {
        let text = "x".repeat(100);
        let block = MessageBlock::Assistant {
            text,
            collapsed: false,
        };
        let settings = default_ui_with(false, false, 0, 0);
        let count = block_wrap_row_count(&block, 0, 1, &settings, false, 30);
        assert!(
            (3..=5).contains(&count),
            "expected ~4 wrapped rows; got {count}"
        );
    }

    #[test]
    fn row_position_from_bottom_zero_for_last_block() {
        let blocks = vec![
            MessageBlock::Assistant {
                text: "first".into(),
                collapsed: false,
            },
            MessageBlock::Assistant {
                text: "last".into(),
                collapsed: false,
            },
        ];
        let settings = default_ui_with(false, false, 0, 0);
        let pos = row_position_from_bottom(&blocks, 1, &settings, false, 80);
        assert_eq!(pos, 0);
    }

    #[test]
    fn row_position_from_bottom_sums_below_blocks() {
        let blocks = vec![
            MessageBlock::Assistant {
                text: "selected".into(),
                collapsed: false,
            },
            MessageBlock::Assistant {
                text: "one line".into(),
                collapsed: false,
            },
            MessageBlock::Assistant {
                text: "another".into(),
                collapsed: false,
            },
        ];
        let settings = default_ui_with(false, false, 0, 0);
        let pos = row_position_from_bottom(&blocks, 0, &settings, false, 80);
        assert!(pos >= 2, "expected ≥2 rows below selected; got {pos}");
    }

    #[test]
    fn is_collapsed_view_history_after_zero_disables() {
        let s = default_ui_with(false, false, 0, 0);
        let assistant = MessageBlock::Assistant {
            text: "response".into(),
            collapsed: false,
        };
        assert!(!is_collapsed_view(&assistant, 0, 30, &s, false));
    }

    #[test]
    fn is_collapsed_view_expanded_all_overrides_everything() {
        let s = default_ui_with(true, true, 1, 1);
        let collapsed_thinking = MessageBlock::Thinking {
            text: "x".into(),
            collapsed: true,
        };
        assert!(!is_collapsed_view(&collapsed_thinking, 0, 10, &s, true));
    }

    #[test]
    fn renders_selection_marker_on_nav_selected_block() -> Result<(), Box<dyn std::error::Error>> {
        let messages = vec![
            MessageBlock::Assistant {
                text: "first".into(),
                collapsed: false,
            },
            MessageBlock::Assistant {
                text: "second".into(),
                collapsed: false,
            },
            MessageBlock::Assistant {
                text: "third".into(),
                collapsed: false,
            },
        ];
        let mut state =
            state_with_messages_and_settings(messages, default_ui_with(false, false, 0, 0));
        state.nav_mode = true;
        state.nav_selected = Some(1);

        let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 8))
            .unwrap_or_else(|e| panic!("term: {e}"));
        term.draw(|f| render(&state, f, f.area()))
            .unwrap_or_else(|e| panic!("draw: {e}"));
        insta::assert_snapshot!(term.backend());
        Ok(())
    }

    #[test]
    fn collapse_override_expands_default_collapsed_tool_result(
    ) -> Result<(), Box<dyn std::error::Error>> {
        let messages = vec![MessageBlock::ToolResult {
            id: "t1".into(),
            result: capo_agent::UiToolResult {
                is_error: false,
                text: "line 1\nline 2".into(),
            },
            collapsed: false,
        }];
        let mut state = state_with_messages_and_settings(messages, UiSettings::default());
        state.collapse_overrides.insert(0, false);

        let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 6))
            .unwrap_or_else(|e| panic!("term: {e}"));
        term.draw(|f| render(&state, f, f.area()))
            .unwrap_or_else(|e| panic!("draw: {e}"));
        insta::assert_snapshot!(term.backend());
        Ok(())
    }

    #[test]
    fn expanded_all_overrides_collapse_override() -> Result<(), Box<dyn std::error::Error>> {
        let messages = vec![MessageBlock::ToolResult {
            id: "t1".into(),
            result: capo_agent::UiToolResult {
                is_error: false,
                text: "line 1\nline 2".into(),
            },
            collapsed: false,
        }];
        let mut state = state_with_messages_and_settings(messages, UiSettings::default());
        state.expanded_all = true;
        state.collapse_overrides.insert(0, true);

        let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 6))
            .unwrap_or_else(|e| panic!("term: {e}"));
        term.draw(|f| render(&state, f, f.area()))
            .unwrap_or_else(|e| panic!("draw: {e}"));
        insta::assert_snapshot!(term.backend());
        Ok(())
    }

    #[test]
    fn renders_selection_marker_on_coalesced_tool_result_row(
    ) -> Result<(), Box<dyn std::error::Error>> {
        let messages = vec![
            MessageBlock::ToolCall {
                id: "t1".into(),
                name: "bash".into(),
                args: serde_json::json!({"command": "echo hi"}),
                collapsed: false,
            },
            MessageBlock::ToolResult {
                id: "t1".into(),
                result: capo_agent::UiToolResult {
                    is_error: false,
                    text: "hi".into(),
                },
                collapsed: false,
            },
        ];
        let mut state = state_with_messages_and_settings(messages, UiSettings::default());
        state.nav_mode = true;
        state.nav_selected = Some(1);

        let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 6))
            .unwrap_or_else(|e| panic!("term: {e}"));
        term.draw(|f| render(&state, f, f.area()))
            .unwrap_or_else(|e| panic!("draw: {e}"));
        insta::assert_snapshot!(term.backend());
        Ok(())
    }

    #[test]
    fn renders_selection_marker_on_empty_coalesced_tool_result_row(
    ) -> Result<(), Box<dyn std::error::Error>> {
        let messages = vec![
            MessageBlock::ToolCall {
                id: "t1".into(),
                name: "bash".into(),
                args: serde_json::json!({"command": "true"}),
                collapsed: false,
            },
            MessageBlock::ToolResult {
                id: "t1".into(),
                result: capo_agent::UiToolResult {
                    is_error: false,
                    text: String::new(),
                },
                collapsed: false,
            },
        ];
        let mut state = state_with_messages_and_settings(messages, UiSettings::default());
        state.nav_mode = true;
        state.nav_selected = Some(1);

        let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 6))
            .unwrap_or_else(|e| panic!("term: {e}"));
        term.draw(|f| render(&state, f, f.area()))
            .unwrap_or_else(|e| panic!("draw: {e}"));
        insta::assert_snapshot!(term.backend());
        Ok(())
    }

    #[test]
    fn exec_nav_gutter_marks_only_structural_result_row() {
        let mut lines = vec![
            Line::from(vec![Span::raw("✓ bash")]),
            Line::from(vec![Span::raw("  └ "), Span::raw("ok")]),
            Line::from(vec![Span::raw("    "), Span::raw("literal └ in output")]),
        ];

        add_exec_nav_gutter(&mut lines, false, true);

        assert_eq!(lines[0].spans[0].content, "  ");
        assert_eq!(lines[1].spans[0].content, "▸ ");
        assert_eq!(lines[2].spans[0].content, "  ");
    }

    #[test]
    fn renders_with_hide_thinking_and_collapse_tool_output(
    ) -> Result<(), Box<dyn std::error::Error>> {
        let messages = vec![
            MessageBlock::User {
                text: "what's in the file?".into(),
                attachment_filenames: vec![],
            },
            MessageBlock::Thinking {
                text: "I should read the file first\nthen summarize".into(),
                collapsed: false,
            },
            MessageBlock::ToolCall {
                id: "t1".into(),
                name: "read".into(),
                args: serde_json::json!({"path": "src/main.rs"}),
                collapsed: false,
            },
            MessageBlock::ToolResult {
                id: "t1".into(),
                result: capo_agent::UiToolResult {
                    is_error: false,
                    text: "fn main() {\n    println!(\"hi\");\n}\n".into(),
                },
                collapsed: false,
            },
            MessageBlock::Assistant {
                text: "It's a hello world.".into(),
                collapsed: false,
            },
        ];
        let settings = default_ui_with(true, true, 2, 0);
        let state = state_with_messages_and_settings(messages, settings);
        let mut term = Terminal::new(TestBackend::new(60, 12))?;
        term.draw(|f| render(&state, f, f.area()))?;
        insta::assert_snapshot!(term.backend());
        Ok(())
    }

    #[test]
    fn renders_with_expanded_all_overriding_collapse() -> Result<(), Box<dyn std::error::Error>> {
        let messages = vec![
            MessageBlock::Thinking {
                text: "considering options...".into(),
                collapsed: false,
            },
            MessageBlock::ToolResult {
                id: "t1".into(),
                result: capo_agent::UiToolResult {
                    is_error: false,
                    text: "line1\nline2\nline3\n".into(),
                },
                collapsed: false,
            },
        ];
        let settings = default_ui_with(true, true, 2, 0);
        let state = AppState {
            expanded_all: true,
            ..state_with_messages_and_settings(messages, settings)
        };
        let mut term = Terminal::new(TestBackend::new(60, 12))?;
        term.draw(|f| render(&state, f, f.area()))?;
        insta::assert_snapshot!(term.backend());
        Ok(())
    }

    #[test]
    fn renders_with_history_after_collapsing_old_messages() -> Result<(), Box<dyn std::error::Error>>
    {
        let messages: Vec<MessageBlock> = (0..5)
            .map(|i| MessageBlock::Assistant {
                text: format!("message {i}\nline 2\nline 3"),
                collapsed: false,
            })
            .collect();
        let settings = default_ui_with(false, false, 0, 2);
        let state = state_with_messages_and_settings(messages, settings);
        let mut term = Terminal::new(TestBackend::new(60, 16))?;
        term.draw(|f| render(&state, f, f.area()))?;
        insta::assert_snapshot!(term.backend());
        Ok(())
    }

    #[test]
    fn renders_user_and_assistant_blocks() -> Result<(), Box<dyn std::error::Error>> {
        let mut state = AppState::default();
        state.messages.push(MessageBlock::User {
            text: "hello".into(),
            attachment_filenames: Vec::new(),
        });
        state.messages.push(MessageBlock::Assistant {
            text: "hi there".into(),
            collapsed: false,
        });
        let mut term = Terminal::new(TestBackend::new(60, 8))?;
        term.draw(|f| render(&state, f, f.area()))?;
        insta::assert_snapshot!(term.backend());
        Ok(())
    }

    #[test]
    fn auto_follow_handles_line_wrapping() -> Result<(), Box<dyn std::error::Error>> {
        // Critical regression: an earlier attempt at the scroll fix counted
        // raw `Line` items via `lines.len()` instead of wrap-expanded rows
        // via `Paragraph::line_count(width)`. With wrapping, that under-
        // shoots the scroll offset and the latest content still hides.
        // Reproduce: 5 long messages on a narrow 40-wide terminal — each
        // wraps to multiple rows, so raw count != wrapped count.
        let mut state = AppState::default();
        let long = "X".repeat(120); // 120 chars → 3 rows at width 40
        for i in 1..=5 {
            state.messages.push(MessageBlock::User {
                text: format!("msg-{i:02}-{long}"),
                attachment_filenames: Vec::new(),
            });
        }
        let mut term = Terminal::new(TestBackend::new(40, 6))?;
        term.draw(|f| render(&state, f, f.area()))?;
        let buf = term.backend().buffer();
        let dumped = (0..buf.area.height)
            .map(|y| {
                (0..buf.area.width)
                    .map(|x| buf[(x, y)].symbol())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n");
        // The latest message's prefix `msg-05` must show even though prior
        // messages wrap to many rows.
        assert!(
            dumped.contains("msg-05"),
            "wrap-aware auto-follow broke — latest message must be visible. Got:\n{dumped}"
        );
        Ok(())
    }

    #[test]
    fn auto_follow_anchors_latest_message_when_overflowing(
    ) -> Result<(), Box<dyn std::error::Error>> {
        // 20 distinct user messages into an 8-row terminal. Without the
        // scroll computation we'd see msg-01..msg-04 (the oldest) and the
        // rest would silently overflow. The fix anchors to the bottom, so
        // the latest message must appear in the rendered backend.
        let mut state = AppState::default();
        for i in 1..=20 {
            state.messages.push(MessageBlock::User {
                text: format!("msg-{i:02}"),
                attachment_filenames: Vec::new(),
            });
        }
        let mut term = Terminal::new(TestBackend::new(60, 8))?;
        term.draw(|f| render(&state, f, f.area()))?;
        let buf = term.backend().buffer();
        let dumped = (0..buf.area.height)
            .map(|y| {
                (0..buf.area.width)
                    .map(|x| buf[(x, y)].symbol())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n");
        assert!(
            dumped.contains("msg-20"),
            "latest message must be visible after auto-follow, got:\n{dumped}"
        );
        assert!(
            !dumped.contains("msg-01"),
            "oldest message must be scrolled off in an 8-row window with 20 entries, got:\n{dumped}"
        );
        Ok(())
    }

    #[test]
    fn tool_output_over_cap_shows_tail_with_omitted_hint() -> Result<(), Box<dyn std::error::Error>>
    {
        // 12-line output exceeds TOOL_OUTPUT_MAX_ROWS (5). Expectation:
        // exactly 5 body rows after the bullet — first is `  └ … +N lines`,
        // remaining 4 are the LAST 4 lines of the source (tail-biased).
        let mut state = AppState {
            settings_snapshot: Some(capo_agent::Settings {
                ui: default_ui_with(false, false, 0, 0),
                ..capo_agent::Settings::default()
            }),
            ..AppState::default()
        };
        state.messages.push(MessageBlock::ToolCall {
            id: "t1".into(),
            name: "bash".into(),
            args: serde_json::json!({"command": "seq 1 12"}),
            collapsed: false,
        });
        let output: String = (1..=12)
            .map(|i| format!("line-{i:02}"))
            .collect::<Vec<_>>()
            .join("\n");
        state.messages.push(MessageBlock::ToolResult {
            id: "t1".into(),
            result: capo_agent::UiToolResult {
                is_error: false,
                text: output,
            },
            collapsed: false,
        });
        let mut term = Terminal::new(TestBackend::new(60, 12))?;
        term.draw(|f| render(&state, f, f.area()))?;
        let buf = term.backend().buffer();
        let dumped = (0..buf.area.height)
            .map(|y| {
                (0..buf.area.width)
                    .map(|x| buf[(x, y)].symbol())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n");

        // Tail rows present (last 4 of 12): line-09..line-12.
        assert!(
            dumped.contains("line-12"),
            "last line must be visible:\n{dumped}"
        );
        assert!(
            dumped.contains("line-09"),
            "line-09 (4th from end) must be visible:\n{dumped}"
        );

        // Hidden rows: anything before line-09 (line-01..line-08).
        // omitted = 12 - (5 - 1) = 8 lines.
        assert!(
            !dumped.contains("line-08"),
            "line-08 must be truncated away:\n{dumped}"
        );
        assert!(
            !dumped.contains("line-01"),
            "line-01 must be truncated away:\n{dumped}"
        );

        // The hint row spells out the omitted count.
        assert!(
            dumped.contains("+8 lines"),
            "truncation hint must say `+8 lines`:\n{dumped}"
        );
        Ok(())
    }

    #[test]
    fn renders_tool_call_and_result() -> Result<(), Box<dyn std::error::Error>> {
        let mut state = AppState {
            settings_snapshot: Some(capo_agent::Settings {
                ui: default_ui_with(false, false, 0, 0),
                ..capo_agent::Settings::default()
            }),
            ..AppState::default()
        };
        state.messages.push(MessageBlock::ToolCall {
            id: "t1".into(),
            name: "fake__echo".into(),
            args: serde_json::json!({"text":"hi"}),
            collapsed: false,
        });
        state.messages.push(MessageBlock::ToolResult {
            id: "t1".into(),
            result: capo_agent::UiToolResult {
                is_error: false,
                text: "exit=0\nhi".into(),
            },
            collapsed: false,
        });
        let mut term = Terminal::new(TestBackend::new(60, 10))?;
        term.draw(|f| render(&state, f, f.area()))?;
        insta::assert_snapshot!(term.backend());
        Ok(())
    }
}
