//! Maps the core `RenderedBlock` list into styled ratatui lines.

use hagent_core::transcript::{CallStatus, RenderedBlock};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};

const SPINNER: [&str; 4] = ["|", "/", "-", "\\"];

/// How many trailing lines of reasoning / tool output to keep; the rest collapse
/// behind a `… N more lines` marker. Applied to both the live tail and the
/// permanent scrollback commit (the collapse is not re-expandable on screen —
/// the full text lives in the conversation log).
const COLLAPSE_LINES: usize = 3;

/// How many lines of an edit's unified diff to show in the transcript before
/// truncating with a `… N more lines` marker. The full diff still lives in the
/// conversation log (scrollback is one-way).
const EDIT_DIFF_LINES: usize = 20;

/// Per-render display options, threaded from `App` (the `/reasoning` and
/// `/output` toggles). When a kind is hidden its block renders to nothing, so
/// the commit/live-tail empty-skip drops it entirely.
#[derive(Debug, Clone, Copy, Default)]
pub struct RenderOpts {
    pub hide_reasoning: bool,
    pub hide_tool_output: bool,
}

fn status_glyph(status: CallStatus, spinner: usize) -> Span<'static> {
    match status {
        CallStatus::Pending => Span::styled("…", Style::default().fg(Color::DarkGray)),
        CallStatus::InProgress => Span::styled(
            SPINNER[spinner % SPINNER.len()].to_string(),
            Style::default().fg(Color::Yellow),
        ),
        CallStatus::Completed => Span::styled("✓", Style::default().fg(Color::Green)),
        CallStatus::Failed => Span::styled("✗", Style::default().fg(Color::Red)),
    }
}

/// Render a single block to styled lines, with no inter-block separator. Used
/// to commit finalized blocks into native scrollback and to render the live
/// in-flight tail. Returns empty for a block that renders nothing.
pub fn block_lines(block: &RenderedBlock, spinner: usize, opts: RenderOpts) -> Vec<Line<'static>> {
    let mut out = Vec::new();
    push_block(&mut out, block, spinner, opts);
    out
}

/// True for append-only text blocks whose earlier lines never change, so they
/// can be committed to immutable scrollback line-by-line as they stream. Tool
/// and status blocks mutate in place (spinner → ✓, output appended) and must be
/// committed whole, only once they reach a terminal state.
///
/// Reasoning is **not** append-only: it collapses to its last few lines (see
/// [`COLLAPSE_LINES`]), a sliding window that's incompatible with line-by-line
/// commit, so it commits whole once final like a tool block.
pub fn is_append_only_text(block: &RenderedBlock) -> bool {
    matches!(block, RenderedBlock::AssistantText { .. })
}

/// Push the last [`COLLAPSE_LINES`] of `body`, each formatted by `fmt`, behind a
/// dim `… N more lines` marker when earlier lines were dropped.
fn push_collapsed(out: &mut Vec<Line<'static>>, body: &str, fmt: impl Fn(&str) -> Line<'static>) {
    let lines: Vec<&str> = body.lines().collect();
    let dropped = lines.len().saturating_sub(COLLAPSE_LINES);
    if dropped > 0 {
        out.push(Line::styled(
            format!(
                "  … {dropped} more line{}",
                if dropped == 1 { "" } else { "s" }
            ),
            Style::default()
                .fg(Color::DarkGray)
                .add_modifier(Modifier::ITALIC),
        ));
    }
    for l in &lines[dropped..] {
        out.push(fmt(l));
    }
}

fn push_block(
    out: &mut Vec<Line<'static>>,
    block: &RenderedBlock,
    spinner: usize,
    opts: RenderOpts,
) {
    match block {
        RenderedBlock::UserMsg(text) => {
            let style = Style::default()
                .fg(Color::Cyan)
                .add_modifier(Modifier::BOLD);
            for (i, l) in text.lines().enumerate() {
                let prefix = if i == 0 { "› " } else { "  " };
                out.push(Line::from(vec![
                    Span::styled(prefix, style),
                    Span::styled(l.to_string(), style),
                ]));
            }
        }
        RenderedBlock::AssistantText { text, commentary } => {
            // Render the model's text as Markdown (it's instructed to emit GFM),
            // then mark each assistant turn with a leading ● and indent
            // continuation lines under it, so distinct rounds of model output are
            // visually separated (Claude Code / Codex style). The Markdown
            // renderer emits exactly one line per source line, so the gutter and
            // the line-by-line scrollback commit (see `app::next_commit_chunk`)
            // stay aligned.
            //
            // `trim_start`: some providers (e.g. OpenRouter → Kimi) prefix the
            // first line of the block with 1–3 stray spaces (a first-token
            // artifact), which pushes the ● gutter out of alignment. Stripping the
            // leading whitespace run is display-only (the raw text is untouched in
            // the snapshot/log) and stable for the streaming commit: once the first
            // non-whitespace char arrives the leading run is fixed, so already
            // committed lines never re-render differently.
            //
            // GPT-5.4 `commentary` items are intermediate notes before the
            // `final_answer`; grey them out (glyph + every span) so the final
            // answer stands out. The dimming is a pure per-line recolour keyed off
            // the block's constant `commentary` flag, so it preserves the
            // line-stability property the streaming scrollback commit relies on.
            let glyph = if *commentary {
                Style::default().fg(Color::DarkGray)
            } else {
                Style::default()
                    .fg(Color::Green)
                    .add_modifier(Modifier::BOLD)
            };
            for (i, mut line) in super::markdown::render_block(text.trim_start())
                .into_iter()
                .enumerate()
            {
                if *commentary {
                    for span in &mut line.spans {
                        span.style = span
                            .style
                            .fg(Color::DarkGray)
                            .remove_modifier(Modifier::BOLD);
                    }
                }
                let prefix = if i == 0 { "● " } else { "  " };
                line.spans.insert(0, Span::styled(prefix, glyph));
                out.push(line);
            }
        }
        RenderedBlock::Reasoning { text } => {
            if opts.hide_reasoning {
                return;
            }
            let style = Style::default()
                .fg(Color::DarkGray)
                .add_modifier(Modifier::ITALIC);
            // Same provider leading-whitespace artifact as AssistantText above —
            // trim it so the dim reasoning gutter lines up. Display-only.
            push_collapsed(out, text.trim_start(), |l| {
                Line::styled(format!("  {l}"), style)
            });
        }
        RenderedBlock::ShellCall {
            command,
            output,
            status,
            ..
        } => {
            // A shell call that invokes the bundled file-editing skill is rendered
            // as a file edit (with a coloured diff) rather than a raw command —
            // see [`parse_edit_call`].
            if let Some(edit) = parse_edit_call(command) {
                push_edit(out, &edit, output, *status, spinner, opts);
                return;
            }
            out.push(header("shell", *status, spinner));
            // The command echo always stays visible; only the output collapses.
            for l in command.lines() {
                out.push(Line::styled(
                    format!("  $ {l}"),
                    Style::default().fg(Color::White),
                ));
            }
            if !opts.hide_tool_output {
                push_collapsed(out, output, |l| {
                    Line::styled(format!("  {l}"), Style::default().fg(Color::DarkGray))
                });
            }
        }
        RenderedBlock::WebSearch { status } => {
            out.push(header("web search", *status, spinner));
        }
        RenderedBlock::FileSearch { queries, status } => {
            out.push(header("file search", *status, spinner));
            for q in queries {
                out.push(Line::styled(
                    format!("  ? {q}"),
                    Style::default().fg(Color::DarkGray),
                ));
            }
        }
        RenderedBlock::McpCall {
            server_label,
            name,
            output,
            status,
            ..
        } => {
            out.push(header(
                &format!("mcp · {server_label}::{name}"),
                *status,
                spinner,
            ));
            if let (false, Some(o)) = (opts.hide_tool_output, output) {
                push_collapsed(out, o, |l| {
                    Line::styled(format!("  {l}"), Style::default().fg(Color::DarkGray))
                });
            }
        }
        RenderedBlock::ApprovalPrompt {
            server_label,
            name,
            decision,
            ..
        } => {
            let label = match decision {
                Some(true) => "approved",
                Some(false) => "denied",
                None => "awaiting approval",
            };
            out.push(Line::from(vec![
                Span::styled("⚠ ", Style::default().fg(Color::Yellow)),
                Span::styled(
                    format!("mcp approval — {server_label}::{name} [{label}]"),
                    Style::default()
                        .fg(Color::Yellow)
                        .add_modifier(Modifier::BOLD),
                ),
            ]));
        }
        RenderedBlock::CompactionMarker => {
            out.push(Line::styled(
                "— context compacted —",
                Style::default()
                    .fg(Color::DarkGray)
                    .add_modifier(Modifier::ITALIC),
            ));
        }
        RenderedBlock::Notice(text) => {
            out.push(Line::styled(
                format!("‹ {text} ›"),
                Style::default()
                    .fg(Color::DarkGray)
                    .add_modifier(Modifier::ITALIC),
            ));
        }
        RenderedBlock::Error { message, code } => {
            let style = Style::default().fg(Color::Red);
            let suffix = code.as_ref().map(|c| format!(" [{c}]")).unwrap_or_default();
            // The message may be multi-line (a JSON error body humanized in
            // core). Lead with the `✗` glyph; indent any continuation lines.
            let mut lines = message.split('\n');
            let first = lines.next().unwrap_or_default();
            out.push(Line::from(Span::styled(
                format!("✗ {first}{suffix}"),
                style,
            )));
            for line in lines {
                out.push(Line::from(Span::styled(format!("  {line}"), style)));
            }
        }
    }
}

/// A shell call recognised as a file-editing-skill invocation
/// (`python3 …/edit.py <op> <path> …`), parsed from the command string.
struct EditCall {
    op: String,
    path: String,
}

/// The edit.py subcommands we recognise.
const EDIT_OPS: [&str; 4] = ["view", "create", "str-replace", "insert"];

/// Recognise a shell command that drives the bundled `edit.py`, extracting the
/// operation and target path so the call can render as a file edit. Returns
/// `None` for any other shell command.
///
/// The invocation can sit on any *command* line — the model often prefixes a
/// `# comment` or `cd … &&` line before it — so we scan every line, but skip
/// **heredoc bodies** (the JSON `{"old","new"}` payload between `<<'EOF'` and its
/// terminator), since that text can contain `edit.py` or lines starting with `-`
/// and would otherwise cause false matches. The `edit.py` match must be at a
/// token boundary (so `myedit.py` doesn't match).
fn parse_edit_call(command: &str) -> Option<EditCall> {
    // Terminator of the heredoc whose body we're currently skipping, if any.
    let mut heredoc_end: Option<String> = None;
    for line in command.lines() {
        if let Some(term) = &heredoc_end {
            if line.trim() == term {
                heredoc_end = None;
            }
            continue; // inside a heredoc body — not a command line
        }
        if let Some(ec) = parse_edit_line(line) {
            return Some(ec);
        }
        // If this command line opens a heredoc, skip its body on the next lines.
        if let Some(term) = heredoc_terminator(line) {
            heredoc_end = Some(term);
        }
    }
    None
}

/// Parse a single command line as an `edit.py <op> <path>` invocation, or `None`.
/// The path is the first *positional* after the subcommand, skipping value-taking
/// flags (`--range` consumes 2 values, `--line` 1) so a flag value isn't mistaken
/// for it.
fn parse_edit_line(line: &str) -> Option<EditCall> {
    for (pos, _) in line.match_indices("edit.py") {
        // Reject a mid-token match like `myedit.py` — require a boundary before.
        let boundary = line[..pos]
            .chars()
            .next_back()
            .is_none_or(|c| c.is_whitespace() || c == '/');
        if !boundary {
            continue;
        }
        let rest = line[pos + "edit.py".len()..].trim_start();
        let mut toks = rest.split_whitespace();
        let Some(op) = toks.next() else { continue };
        if !EDIT_OPS.contains(&op) {
            continue;
        }
        let path = loop {
            match toks.next() {
                Some("--range") => {
                    toks.next();
                    toks.next();
                }
                Some("--line") => {
                    toks.next();
                }
                Some(t) if t.starts_with('-') => {} // value-less flag (--replace-all, --line=N)
                Some(t) => break t,
                None => return None,
            }
        };
        return Some(EditCall {
            op: op.to_string(),
            path: path.to_string(),
        });
    }
    None
}

/// If `line` opens a heredoc (`<<EOF`, `<<'EOF'`, `<<"EOF"`, `<<-EOF`), return its
/// terminator word (quotes and the leading `-` stripped) so the body can be
/// skipped until a line equal to it.
fn heredoc_terminator(line: &str) -> Option<String> {
    let pos = line.find("<<")?;
    let after = line[pos + 2..].trim_start();
    let after = after.strip_prefix('-').unwrap_or(after).trim_start();
    // The delimiter runs to the next whitespace/redirect; strip surrounding quotes.
    let word: String = after
        .chars()
        .take_while(|c| !c.is_whitespace() && *c != '>' && *c != '|' && *c != ';')
        .collect();
    let word = word.trim_matches(|c| c == '\'' || c == '"');
    (!word.is_empty()).then(|| word.to_string())
}

/// Render a file-editing-skill call: a header (`<glyph> edit <op> <path>`) and
/// the body — a coloured unified diff for `create`/`str-replace`/`insert`, or the
/// (collapsed) file contents for a `view` read.
fn push_edit(
    out: &mut Vec<Line<'static>>,
    edit: &EditCall,
    output: &str,
    status: CallStatus,
    spinner: usize,
    opts: RenderOpts,
) {
    out.push(Line::from(vec![
        status_glyph(status, spinner),
        Span::raw(" "),
        Span::styled(
            "edit",
            Style::default()
                .fg(Color::Magenta)
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            format!(" {} ", edit.op),
            Style::default().fg(Color::DarkGray),
        ),
        Span::styled(edit.path.clone(), Style::default().fg(Color::White)),
    ]));
    if opts.hide_tool_output {
        return;
    }
    if edit.op != "view" && output_has_diff(output) {
        // edit.py printed a confirmation line then a unified diff; render the
        // first EDIT_DIFF_LINES with diff colouring (a long diff is truncated with
        // a marker — the full text is in the conversation log).
        let lines: Vec<&str> = output.lines().collect();
        let shown = lines.len().min(EDIT_DIFF_LINES);
        for l in &lines[..shown] {
            out.push(diff_line(l));
        }
        let hidden = lines.len() - shown;
        if hidden > 0 {
            out.push(Line::styled(
                format!(
                    "  … {hidden} more line{}",
                    if hidden == 1 { "" } else { "s" }
                ),
                Style::default()
                    .fg(Color::DarkGray)
                    .add_modifier(Modifier::ITALIC),
            ));
        }
    } else {
        // A `view` read, or output without a diff (an older edit.py that only
        // prints a confirmation, or a verification command's output): collapse to
        // the last few lines like any other tool output.
        push_collapsed(out, output, |l| {
            Line::styled(format!("  {l}"), Style::default().fg(Color::DarkGray))
        });
    }
}

/// Whether `output` contains a unified diff (as emitted by a current `edit.py`).
/// Older skills only print a confirmation line, so we collapse those instead of
/// rendering them as a (non-existent) diff.
fn output_has_diff(output: &str) -> bool {
    output
        .lines()
        .any(|l| l.starts_with("@@ ") || l.starts_with("--- a/") || l.starts_with("+++ b/"))
}

/// Colour one line of a unified diff: hunk headers cyan, file headers dim,
/// additions green, removals red, context (and the confirmation line) dim. The
/// file-header check matches edit.py's exact `--- a/…` / `+++ b/…` shape (not a
/// bare `---`/`+++` prefix) so a *content* line like a Markdown `---` rule still
/// colours as a removal/addition rather than a header.
fn diff_line(l: &str) -> Line<'static> {
    let style = if l.starts_with("@@") {
        Style::default().fg(Color::Cyan)
    } else if l.starts_with("--- a/") || l.starts_with("+++ b/") {
        Style::default()
            .fg(Color::DarkGray)
            .add_modifier(Modifier::BOLD)
    } else if l.starts_with('+') {
        Style::default().fg(Color::Green)
    } else if l.starts_with('-') {
        Style::default().fg(Color::Red)
    } else {
        Style::default().fg(Color::DarkGray)
    };
    Line::styled(format!("  {l}"), style)
}

fn header(label: &str, status: CallStatus, spinner: usize) -> Line<'static> {
    Line::from(vec![
        status_glyph(status, spinner),
        Span::raw(" "),
        Span::styled(
            label.to_string(),
            Style::default()
                .fg(Color::Magenta)
                .add_modifier(Modifier::BOLD),
        ),
    ])
}

#[cfg(test)]
mod tests {
    use super::{block_lines, is_append_only_text, RenderOpts};
    use hagent_core::transcript::{CallStatus, RenderedBlock};

    fn text(lines: &[ratatui::text::Line<'_>]) -> Vec<String> {
        lines
            .iter()
            .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
            .collect()
    }

    fn lines(block: &RenderedBlock) -> Vec<String> {
        text(&block_lines(block, 0, RenderOpts::default()))
    }

    fn lines_with(block: &RenderedBlock, opts: RenderOpts) -> Vec<String> {
        text(&block_lines(block, 0, opts))
    }

    #[test]
    fn user_and_assistant_blocks_carry_their_prefix_glyphs() {
        let ut = lines(&RenderedBlock::UserMsg("hi\nthere".into()));
        assert!(ut[0].starts_with("› "), "user first line: {ut:?}");
        assert!(ut[1].starts_with("  "), "user continuation indents: {ut:?}");

        let at = lines(&RenderedBlock::AssistantText {
            text: "yo\nman".into(),
            commentary: false,
        });
        assert!(at[0].starts_with("● "), "assistant turn marker: {at:?}");
        assert!(
            at[1].starts_with("  "),
            "assistant continuation indents: {at:?}"
        );
    }

    #[test]
    fn leading_whitespace_is_trimmed_so_the_gutter_aligns() {
        // Some providers prefix the first line with stray spaces; the gutter must
        // still sit flush and continuation indentation must survive.
        let at = lines(&RenderedBlock::AssistantText {
            text: "   hello\nworld".into(),
            commentary: false,
        });
        assert_eq!(
            at[0], "● hello",
            "first line trimmed under the marker: {at:?}"
        );
        assert_eq!(at[1], "  world", "continuation untouched: {at:?}");

        let re = lines(&RenderedBlock::Reasoning {
            text: "  thinking".into(),
        });
        assert_eq!(re[0], "  thinking", "reasoning gutter aligns: {re:?}");
    }

    #[test]
    fn commentary_blocks_render_dimmed_but_keep_the_gutter() {
        use ratatui::style::{Color, Modifier};
        // A GPT-5.4 commentary block keeps the ● gutter and Markdown layout but
        // every span (glyph + a bold heading) is greyed out and de-bolded, so the
        // final answer reads as primary.
        let block = RenderedBlock::AssistantText {
            text: "# heading\nbody".into(),
            commentary: true,
        };
        let out = block_lines(&block, 0, RenderOpts::default());
        let rendered: Vec<String> = text(&out);
        assert!(
            rendered[0].starts_with("● "),
            "gutter survives: {rendered:?}"
        );
        for line in &out {
            for span in &line.spans {
                assert_eq!(
                    span.style.fg,
                    Some(Color::DarkGray),
                    "every commentary span is greyed: {span:?}"
                );
                assert!(
                    !span.style.add_modifier.contains(Modifier::BOLD),
                    "commentary drops bold (incl. the heading): {span:?}"
                );
            }
        }

        // The same text as a final answer keeps its green glyph (not greyed).
        let final_block = RenderedBlock::AssistantText {
            text: "# heading\nbody".into(),
            commentary: false,
        };
        let glyph_fg = block_lines(&final_block, 0, RenderOpts::default())[0].spans[0]
            .style
            .fg;
        assert_eq!(
            glyph_fg,
            Some(Color::Green),
            "final-answer glyph stays green"
        );
    }

    #[test]
    fn empty_reasoning_renders_nothing_and_is_no_longer_append_only() {
        let empty = lines(&RenderedBlock::Reasoning {
            text: String::new(),
        });
        assert!(empty.is_empty(), "empty reasoning yields no lines");
        // Reasoning now commits whole (collapsed), so it is NOT append-only.
        assert!(!is_append_only_text(&RenderedBlock::Reasoning {
            text: "x".into()
        }));
        assert!(is_append_only_text(&RenderedBlock::AssistantText {
            text: "x".into(),
            commentary: false,
        }));
    }

    #[test]
    fn reasoning_collapses_to_last_three_lines_with_marker() {
        let block = RenderedBlock::Reasoning {
            text: (1..=6)
                .map(|i| format!("line {i}"))
                .collect::<Vec<_>>()
                .join("\n"),
        };
        let t = lines(&block);
        // marker + 3 lines.
        assert_eq!(t.len(), 4, "{t:?}");
        assert!(t[0].contains("3 more lines"), "elision marker: {t:?}");
        assert!(
            t[1].contains("line 4") && t[3].contains("line 6"),
            "tail: {t:?}"
        );

        // Short reasoning (≤3 lines) shows no marker.
        let short = lines(&RenderedBlock::Reasoning {
            text: "only\ntwo".into(),
        });
        assert_eq!(short.len(), 2, "{short:?}");
        assert!(!short.iter().any(|l| l.contains("more line")));

        // Hidden reasoning renders nothing.
        let hidden = lines_with(
            &block,
            RenderOpts {
                hide_reasoning: true,
                ..Default::default()
            },
        );
        assert!(
            hidden.is_empty(),
            "hidden reasoning yields no lines: {hidden:?}"
        );
    }

    #[test]
    fn shell_keeps_command_collapses_output_and_can_hide_output() {
        let block = RenderedBlock::ShellCall {
            command: "echo hi".into(),
            output: (1..=5)
                .map(|i| format!("out {i}"))
                .collect::<Vec<_>>()
                .join("\n"),
            status: CallStatus::Completed,
            container_id: None,
            output_seen: true,
        };
        let t = lines(&block);
        assert!(
            t.iter().any(|l| l.contains("$ echo hi")),
            "command shown: {t:?}"
        );
        assert!(
            t.iter().any(|l| l.contains("2 more lines")),
            "output collapsed: {t:?}"
        );
        assert!(
            t.iter().any(|l| l.contains("out 5")),
            "tail of output: {t:?}"
        );
        assert!(
            !t.iter().any(|l| l.contains("out 1")),
            "head dropped: {t:?}"
        );

        // Hiding output keeps the header + command but drops the body.
        let hidden = lines_with(
            &block,
            RenderOpts {
                hide_tool_output: true,
                ..Default::default()
            },
        );
        assert!(
            hidden.iter().any(|l| l.contains("$ echo hi")),
            "command kept: {hidden:?}"
        );
        assert!(
            !hidden.iter().any(|l| l.contains("out ")),
            "output hidden: {hidden:?}"
        );
    }

    #[test]
    fn edit_skill_call_renders_as_an_edit_with_a_diff() {
        // A shell call invoking edit.py str-replace renders an "edit" header with
        // the op + path, and colourises the diff edit.py printed (we assert on the
        // text content; the +/- lines are present).
        let block = RenderedBlock::ShellCall {
            command: "python3 /skills/abc/scripts/edit.py str-replace src/app.rs <<'EOF'".into(),
            output: "Edited src/app.rs (1 replacement)\n\n--- a/src/app.rs\n+++ b/src/app.rs\n@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma".into(),
            status: CallStatus::Completed,
            container_id: None,
            output_seen: true,
        };
        let t = lines(&block);
        assert!(
            t.iter().any(|l| l.contains("edit")
                && l.contains("str-replace")
                && l.contains("src/app.rs")),
            "edit header with op + path: {t:?}"
        );
        assert!(t.iter().any(|l| l.contains("-beta")), "removal line: {t:?}");
        assert!(
            t.iter().any(|l| l.contains("+BETA")),
            "addition line: {t:?}"
        );
        // The raw `$ python3 …` command echo is NOT shown for edits.
        assert!(
            !t.iter().any(|l| l.contains("$ python3")),
            "no raw command echo: {t:?}"
        );

        // A view call renders an edit header but shows the file contents.
        let view = RenderedBlock::ShellCall {
            command: "python3 /skills/abc/scripts/edit.py view src/app.rs".into(),
            output: "     1\talpha\n     2\tbeta".into(),
            status: CallStatus::Completed,
            container_id: None,
            output_seen: true,
        };
        let vt = lines(&view);
        assert!(
            vt.iter().any(|l| l.contains("edit") && l.contains("view")),
            "view header: {vt:?}"
        );
        assert!(
            vt.iter().any(|l| l.contains("alpha")),
            "file content: {vt:?}"
        );

        // A plain shell command is unaffected (still the generic shell render).
        let shell = RenderedBlock::ShellCall {
            command: "echo hi".into(),
            output: "hi".into(),
            status: CallStatus::Completed,
            container_id: None,
            output_seen: true,
        };
        let st = lines(&shell);
        assert!(
            st.iter().any(|l| l.contains("$ echo hi")),
            "generic shell: {st:?}"
        );
    }

    #[test]
    fn parse_edit_call_handles_flags_boundaries_and_heredocs() {
        use super::parse_edit_call;
        // Flag before the path: the path is the positional, not the flag value.
        let ec = parse_edit_call("python3 /skills/x/scripts/edit.py view --range 1 40 src/main.rs")
            .expect("view with leading --range");
        assert_eq!((ec.op.as_str(), ec.path.as_str()), ("view", "src/main.rs"));
        let ec = parse_edit_call("python3 edit.py insert --line 5 src/app.rs")
            .expect("insert with leading --line");
        assert_eq!((ec.op.as_str(), ec.path.as_str()), ("insert", "src/app.rs"));
        // Path-first ordering still works, with trailing flag / heredoc.
        let ec = parse_edit_call("python3 edit.py str-replace src/app.rs --replace-all <<'EOF'")
            .expect("path-first str-replace");
        assert_eq!(
            (ec.op.as_str(), ec.path.as_str()),
            ("str-replace", "src/app.rs")
        );

        // A script merely *ending* in edit.py must NOT match (token boundary).
        assert!(parse_edit_call("python3 myedit.py view foo").is_none());
        // A heredoc body mentioning edit.py must NOT match: the `cat` opens a
        // heredoc, so the `edit.py` in its body is skipped.
        let multi = "cat <<'EOF' > notes.txt\nTo edit, run python3 edit.py create x.txt\nEOF";
        assert!(parse_edit_call(multi).is_none());
    }

    #[test]
    fn long_edit_diff_is_truncated_to_twenty_lines() {
        // A diff longer than EDIT_DIFF_LINES (20) is capped with a marker.
        let mut diff = String::from(
            "Edited f.txt (1 replacement)\n\n--- a/f.txt\n+++ b/f.txt\n@@ -1,30 +1,30 @@\n",
        );
        for i in 0..30 {
            diff.push_str(&format!("+line {i}\n"));
        }
        let block = RenderedBlock::ShellCall {
            command: "python3 /skills/x/scripts/edit.py str-replace f.txt <<'EOF'".into(),
            output: diff,
            status: CallStatus::Completed,
            container_id: None,
            output_seen: true,
        };
        let t = lines(&block);
        // 1 header (edit …) + 20 diff lines + 1 truncation marker = 22.
        assert_eq!(t.len(), 22, "header + 20 diff lines + marker: {}", t.len());
        assert!(
            t.iter().any(|l| l.contains("more line")),
            "truncation marker present: {t:?}"
        );
        // The first diff lines (confirmation + headers) are shown, the tail isn't.
        assert!(t.iter().any(|l| l.contains("Edited f.txt")), "head shown");
        assert!(!t.iter().any(|l| l.contains("line 29")), "tail truncated");
    }

    #[test]
    fn edit_call_detected_below_a_comment_line_skipping_heredoc_body() {
        use super::parse_edit_call;
        // The model commonly prefixes a `# comment` before the real invocation,
        // and the str-replace JSON rides a heredoc body — detection must look past
        // both. (Regression: a first-line-only scan saw only the comment.)
        let cmd = "# Fix 1 – scope the IoT policy\n\
                   python3 /skills/hagent-file-edit-1/scripts/edit.py str-replace /mnt/data/cf.yaml <<'EOF'\n\
                   {\"old\": \"Resource: \\\"*\\\"\", \"new\": \"Resource: arn:...\"}\n\
                   EOF";
        let ec = parse_edit_call(cmd).expect("detected below the comment line");
        assert_eq!(
            (ec.op.as_str(), ec.path.as_str()),
            ("str-replace", "/mnt/data/cf.yaml")
        );
    }

    #[test]
    fn comment_prefixed_edit_renders_clean_without_heredoc_json() {
        // The user's report: a `# comment` + edit.py + heredoc JSON, with an older
        // edit.py that prints only a confirmation (no diff). It should render as an
        // edit header + collapsed confirmation — never the raw command / JSON body.
        let block = RenderedBlock::ShellCall {
            command: "# Fix 1\n\
                      python3 /skills/x-1/scripts/edit.py str-replace /mnt/data/f.yaml <<'EOF'\n\
                      {\"old\": \"a\", \"new\": \"b\"}\n\
                      EOF"
            .into(),
            output: "Edited /mnt/data/f.yaml (1 replacement)".into(),
            status: CallStatus::Completed,
            container_id: None,
            output_seen: true,
        };
        let t = lines(&block);
        assert!(
            t.iter().any(|l| l.contains("edit")
                && l.contains("str-replace")
                && l.contains("/mnt/data/f.yaml")),
            "edit header: {t:?}"
        );
        assert!(
            !t.iter().any(|l| l.contains("\"old\"")),
            "no heredoc JSON: {t:?}"
        );
        assert!(
            !t.iter().any(|l| l.contains("$ python3")),
            "no raw command echo: {t:?}"
        );
        assert!(
            t.iter().any(|l| l.contains("Edited /mnt/data/f.yaml")),
            "confirmation shown: {t:?}"
        );
    }

    #[test]
    fn diff_line_colours_content_dashes_as_changes_not_headers() {
        use super::diff_line;
        use ratatui::style::Color;
        // `Line::styled` sets the line's style, not the span's.
        let fg = |l: &str| diff_line(l).style.fg;
        // Real unified-diff file headers are dim (DarkGray).
        assert_eq!(fg("--- a/src/app.rs"), Some(Color::DarkGray));
        assert_eq!(fg("+++ b/src/app.rs"), Some(Color::DarkGray));
        // A removed/added *content* line that happens to be a Markdown rule keeps
        // the removal/addition colour rather than being mistaken for a header.
        assert_eq!(fg("----"), Some(Color::Red), "removed `---` content");
        assert_eq!(fg("+++banner"), Some(Color::Green), "added `++` content");
        assert_eq!(fg("@@ -1,3 +1,3 @@"), Some(Color::Cyan), "hunk header");
    }

    #[test]
    fn assistant_code_block_is_highlighted_under_the_gutter() {
        use ratatui::style::Color;
        // An assistant message containing a fenced rust block routes through the
        // Markdown renderer: the gutter is preserved and the code line is
        // syntax-highlighted into multiple truecolor spans.
        let block = RenderedBlock::AssistantText {
            text: "```rust\nfn main() {}\n```".into(),
            commentary: false,
        };
        let out = block_lines(&block, 0, RenderOpts::default());
        assert_eq!(out.len(), 3, "open fence + code + close fence");
        let first: String = out[0].spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(
            first.starts_with("● "),
            "turn marker on first line: {first:?}"
        );
        let code = &out[1];
        let text: String = code.spans.iter().map(|s| s.content.as_ref()).collect();
        assert_eq!(text, "  fn main() {}", "gutter + verbatim code text");
        let rgb: std::collections::HashSet<_> = code
            .spans
            .iter()
            .filter_map(|s| match s.style.fg {
                Some(Color::Rgb(r, g, b)) => Some((r, g, b)),
                _ => None,
            })
            .collect();
        assert!(rgb.len() > 1, "multiple syntax colours present: {rgb:?}");
    }

    #[test]
    fn notice_renders_a_dim_inline_marker() {
        let t = lines(&RenderedBlock::Notice("model → gpt-5.2".into()));
        assert_eq!(t.len(), 1);
        assert!(t[0].contains("model → gpt-5.2"), "{t:?}");
        assert!(!is_append_only_text(&RenderedBlock::Notice("x".into())));
    }
}
