//! Shared formatting primitives for `WaitTask` aggregated results.
//!
//! **Post-#1325 Phase 5b:** the `WaitTask` LLM tool is retired (replaced
//! by `WaitForMail` + the mailbox bridge from #1336), so this module is
//! no longer triggered by *new* assistant messages. It stays alive
//! purely to render `WaitTask` tool rows persisted in pre-5b session
//! DBs — resuming an old session must still render its history correctly.
//! Same holds for the `ListBackgroundTasks` rendering wired through the
//! shared status palette here.
//!
//! `WaitTask` (#1157) returned a JSON envelope with N sub-task results
//! (`{tasks: [...], summary: {total, completed, ...}}`). Two rendering
//! surfaces consume this same payload:
//!
//!   1. Live TUI streaming ([`crate::tui_render`]) — ratatui `Span`/`Line`
//!   2. Resumed history TUI ([`crate::history_render`]) — same as #1
//!
//! (A third surface — markdown export — lived in `transcript.rs`
//! until RFC #1167 PR δ collapsed `/export` into `/debug-bundle`.
//! `/debug-bundle` consumes `history_render` directly so this module's
//! primitives still feed exported text via the TUI → lines_to_text
//! pipeline.)
//!
//! This module owns the JSON-shape parsing and presentation primitives
//! (icons, previews) shared by both surfaces. Each surface owns the
//! styling appropriate to its medium.
//!
//! Centralising the icon table and preview helper means a status-set
//! change (e.g. adding `parse_error`) lights up consistently across all
//! renderers in one edit.

use ratatui::{
    style::{Color, Modifier, Style},
    text::{Line, Span},
};

use crate::tui_output::{BOLD, DIM, TOOL_PREFIX};

/// Per-task status icon. Centralised so the summary line, per-task
/// header, and resumed-history render can't drift apart. New statuses
/// added to the engine must land here as well — the catch-all `❔`
/// makes the omission visible in rendered output rather than failing
/// silently.
///
/// Covers the union of statuses emitted by **both** `WaitTask` (the
/// original consumer) and `ListBackgroundTasks` (#1209 added the latter):
///
/// - `WaitTask` outcomes: `completed`, `timed_out`, `cancelled`,
///   `not_found`, `forbidden`, `parse_error`.
/// - `ListBackgroundTasks` agent statuses: `pending`, `running`,
///   `completed`, `cancelled`, `errored`.
/// - `ListBackgroundTasks` process statuses: `running`, `exited`,
///   `killed`.
#[must_use]
pub fn wait_status_icon(status: &str) -> &'static str {
    match status {
        "completed" => "\u{2705}",   // ✅
        "timed_out" => "\u{23F1}",   // ⏱
        "cancelled" => "\u{26D4}",   // ⛔
        "not_found" => "\u{2753}",   // ❓
        "forbidden" => "\u{1F512}",  // 🔒
        "parse_error" => "\u{26A0}", // ⚠
        // ListBackgroundTasks-specific statuses (#1209). Glyph choice
        // matches `child_activity_overlay`'s status palette so the live
        // overlay and the tool-result render speak the same icon
        // vocabulary.
        "pending" => "\u{25D0}", // ◐ — half-filled circle
        "running" => "\u{25B6}", // ▶ — play / in-flight
        "errored" => "\u{2717}", // ✗ — cross
        "exited" => "\u{2713}",  // ✓ — check (clean exit)
        "killed" => "\u{26D4}",  // ⛔ — same as cancelled (also user-initiated stop)
        _ => "\u{2754}",         // ❔ — catch-all (visible omission)
    }
}

/// One-line preview of an agent's output for the collapsed summary
/// line. Strips markdown noise (heading hashes, blockquote markers),
/// collapses interior whitespace, truncates with an ellipsis.
///
/// 120 chars is the markdown export's preferred width; the TUI uses
/// shorter limits because terminal columns are scarcer than browser
/// width. Chars (not bytes) so multibyte content isn't truncated mid
/// codepoint.
#[must_use]
pub fn first_meaningful_line(output: &str, max_chars: usize) -> String {
    for raw in output.lines() {
        let trimmed = raw
            .trim()
            .trim_start_matches('#')
            .trim_start_matches('>')
            .trim();
        if trimmed.is_empty() {
            continue;
        }
        let collapsed: String = trimmed.split_whitespace().collect::<Vec<_>>().join(" ");
        if collapsed.chars().count() <= max_chars {
            return collapsed;
        }
        let truncated: String = collapsed.chars().take(max_chars).collect();
        return format!("{truncated}\u{2026}");
    }
    String::new()
}

/// Per-task TUI preview width. Shorter than the markdown export's 120
/// because terminal columns are shared with the `│` prefix, status
/// icon, task id, and agent name — anything longer wraps awkwardly.
const TUI_PREVIEW_CHARS: usize = 80;

/// Render a `WaitTask` JSON payload as styled TUI lines, or `None` on
/// any parse failure or shape mismatch. Caller falls back to the
/// generic tool-output render path on `None` — we never want to lose
/// the raw content when the pretty path can't run.
///
/// Output shape (one `│` prefix per line, matching the surrounding
/// `render_tool_output` convention):
///
/// ```text
///   │ 4 task(s) gathered — ✅ 4 completed
///   │   ✅ agent:1  explore — The following is a report on the code…
///   │   ✅ agent:2  explore — I have sufficient information for a…
///   │   …
/// ```
///
/// Style choices are deliberately conservative: status icon stays
/// uncoloured (the icon itself carries semantic meaning), task id is
/// `BOLD` for scanability, agent name is `DIM` (it's contextual, not
/// the headline), preview stays in default text colour so it stands
/// out from the prefix chrome.
#[must_use]
pub fn try_render_wait_task_lines(payload: &str) -> Option<Vec<Line<'static>>> {
    let v: serde_json::Value = serde_json::from_str(payload).ok()?;
    let tasks = v.get("tasks")?.as_array()?;
    if tasks.is_empty() {
        return None;
    }

    let summary = v.get("summary").and_then(|s| s.as_object());
    let count = |k: &str| -> u64 {
        summary
            .and_then(|s| s.get(k))
            .and_then(|v| v.as_u64())
            .unwrap_or(0)
    };
    let total = summary
        .and_then(|s| s.get("total"))
        .and_then(|v| v.as_u64())
        .unwrap_or(tasks.len() as u64);

    // Build the summary suffix in a fixed order so the rendered text is
    // stable across runs. Zero-count statuses are dropped so a fully
    // successful gather doesn't render as "✅ 4 completed · ⏱ 0 timed
    // out · ⛔ 0 cancelled · …".
    let mut summary_parts: Vec<String> = Vec::new();
    for (key, label) in [
        ("completed", "completed"),
        ("timed_out", "timed out"),
        ("cancelled", "cancelled"),
        ("not_found", "not found"),
        ("forbidden", "forbidden"),
        ("parse_error", "parse error"),
    ] {
        let n = count(key);
        if n > 0 {
            summary_parts.push(format!("{} {n} {label}", wait_status_icon(key)));
        }
    }
    let summary_suffix = if summary_parts.is_empty() {
        String::new()
    } else {
        format!(" \u{2014} {}", summary_parts.join(" \u{00B7} "))
    };

    let mut lines: Vec<Line<'static>> = Vec::new();

    // Header: "  │ N task(s) gathered — <summary>"
    lines.push(Line::from(vec![
        Span::styled("  \u{2502} ", TOOL_PREFIX),
        Span::styled(format!("{total} task(s) gathered{summary_suffix}"), BOLD),
    ]));

    // One row per task. We cap output preview to TUI_PREVIEW_CHARS;
    // longer reports stay reachable via `/debug-bundle` (full text in
    // the bundled `conversation.md`) or by re-asking the model to
    // summarise.
    let dim_italic = Style::default()
        .fg(Color::DarkGray)
        .add_modifier(Modifier::ITALIC);

    for task in tasks {
        let task_id = task.get("task_id").and_then(|v| v.as_str()).unwrap_or("?");
        let status = task.get("status").and_then(|v| v.as_str()).unwrap_or("?");
        let icon = wait_status_icon(status);
        let agent_name = task
            .get("agent_name")
            .and_then(|v| v.as_str())
            .unwrap_or("");
        let preview = task
            .get("output")
            .and_then(|v| v.as_str())
            .map(|s| first_meaningful_line(s, TUI_PREVIEW_CHARS))
            .unwrap_or_default();

        // Spans are pushed conditionally so we don't emit empty
        // separators when agent_name or preview is missing.
        let mut spans: Vec<Span<'static>> = vec![
            Span::styled("  \u{2502} ", TOOL_PREFIX),
            Span::raw(format!("  {icon} ")),
            Span::styled(task_id.to_string(), BOLD),
        ];
        if !agent_name.is_empty() {
            spans.push(Span::raw("  "));
            spans.push(Span::styled(agent_name.to_string(), DIM));
        }
        if !preview.is_empty() {
            spans.push(Span::raw(" \u{2014} "));
            spans.push(Span::styled(preview, dim_italic));
        }
        lines.push(Line::from(spans));
    }

    Some(lines)
}

/// Render a `ListBackgroundTasks` JSON payload (#1209).
///
/// Shape contract (see `koda-core::tools::bg_task_tools`):
///
/// ```json
/// [
///   {
///     "task_id": "agent:1",
///     "task_type": "agent" | "process",
///     "description": "<agent_name>: <prompt>" | "<command>",
///     "status": "pending"|"running"|"completed"|"cancelled"|"errored"
///             | "running"|"exited"|"killed",
///     "age_secs": 12,
///     "exit_code": 0   // optional, processes only
///   },
///   ...
/// ]
/// ```
///
/// Empty arrays render as a single "no background tasks" line so the
/// model's tool call still leaves a visible trace in history rather
/// than vanishing. Any other shape mismatch returns `None` so the
/// caller falls back to the generic raw render — we never want to
/// drop content silently.
///
/// Output shape (matches the `WaitTask` renderer's per-task style):
///
/// ```text
///   │ 3 background task(s)
///   │   ▶ agent:1  explore: scan koda-core/src for ToolEffect (running, 12s)
///   │   ◐ agent:2  verify: confirm render (pending, 3s)
///   │   ⛔ process:5821  cargo test --workspace (killed, 45s)
/// ```
#[must_use]
pub fn try_render_list_bg_tasks_lines(payload: &str) -> Option<Vec<Line<'static>>> {
    let v: serde_json::Value = serde_json::from_str(payload).ok()?;
    let tasks = v.as_array()?;

    let mut lines: Vec<Line<'static>> = Vec::new();

    // Header. Empty-array case still emits a header so a `ListBackgroundTasks`
    // call with zero results is visible in history rather than rendering
    // as nothing-at-all (which would look like a broken tool).
    let header = if tasks.is_empty() {
        "no background tasks".to_string()
    } else {
        format!("{} background task(s)", tasks.len())
    };
    lines.push(Line::from(vec![
        Span::styled("  \u{2502} ", TOOL_PREFIX),
        Span::styled(header, BOLD),
    ]));

    let dim_italic = Style::default()
        .fg(Color::DarkGray)
        .add_modifier(Modifier::ITALIC);

    for task in tasks {
        let task_id = task.get("task_id").and_then(|v| v.as_str()).unwrap_or("?");
        let status = task.get("status").and_then(|v| v.as_str()).unwrap_or("?");
        let age_secs = task.get("age_secs").and_then(|v| v.as_u64()).unwrap_or(0);
        let description = task
            .get("description")
            .and_then(|v| v.as_str())
            .map(|s| first_meaningful_line(s, TUI_PREVIEW_CHARS))
            .unwrap_or_default();
        let exit_code = task.get("exit_code").and_then(|v| v.as_i64());

        let icon = wait_status_icon(status);

        // Status suffix shows the raw status word (with optional exit
        // code for processes) plus age. Format mirrors the live
        // `child_activity_overlay` so eye sweeps between live + history
        // see the same tail.
        let status_tail = match exit_code {
            Some(code) => format!("({status} {code}, {age_secs}s)"),
            None => format!("({status}, {age_secs}s)"),
        };

        let mut spans: Vec<Span<'static>> = vec![
            Span::styled("  \u{2502} ", TOOL_PREFIX),
            Span::raw(format!("  {icon} ")),
            Span::styled(task_id.to_string(), BOLD),
        ];
        if !description.is_empty() {
            spans.push(Span::raw("  "));
            spans.push(Span::styled(description, dim_italic));
        }
        spans.push(Span::raw(" "));
        spans.push(Span::styled(status_tail, DIM));
        lines.push(Line::from(spans));
    }

    Some(lines)
}

/// Render a `WaitForMail` JSON payload (#1336, enriched in #1343).
///
/// Shape contract (see `koda-core::tools::wait_for_mail`):
///
/// 1. **Completed (mail arrived)** — emitted whenever the mailbox
///    delivered at least one message before the deadline:
///
///    ```json
///    {"message": "Wait completed.", "timed_out": false}
///    ```
///
/// 2. **Timed out, legacy minimal** — emitted when no mailbox
///    registry is attached (rare; older session DBs / test
///    fixtures):
///
///    ```json
///    {"message": "Wait timed out.", "timed_out": true}
///    ```
///
/// 3. **Timed out, rich** — the standard timeout payload after
///    #1343. Includes a per-agent in-flight summary plus an
///    actionable hint string so the model can decide what to do
///    next without re-querying:
///
///    ```json
///    {
///      "message": "Wait timed out.",
///      "timed_out": true,
///      "bg_agents_in_flight": 1,
///      "bg_agents": [
///        {"task_id": "agent:1", "agent_name": "explore",
///         "status": "running", "age_secs": 41}
///      ],
///      "hint": "1 background sub-agent(s) still running. Consider …"
///    }
///    ```
///
/// Output shape (matches the `ListBackgroundTasks` per-task style
/// so eye-sweeps between the two tools see the same vocabulary):
///
/// ```text
///   │ ✅ Wait completed (mail arrived).
/// ```
///
/// ```text
///   │ ⏱  Wait timed out. 1 sub-agent still running:
///   │   ▶ agent:1  explore  (running, 41s)
///   │ 💡 1 background sub-agent(s) still running. Consider doing …
/// ```
///
/// Returns `None` on any shape mismatch (missing `message` / wrong
/// types) so the caller's generic raw-render path takes over —
/// content is never silently dropped, which is the same fail-safe
/// stance as `try_render_wait_task_lines`.
#[must_use]
pub fn try_render_wait_for_mail_lines(payload: &str) -> Option<Vec<Line<'static>>> {
    let v: serde_json::Value = serde_json::from_str(payload).ok()?;
    // Required discriminator. If `timed_out` isn't present the payload
    // is some other tool's output and we bail to the generic render.
    let timed_out = v.get("timed_out")?.as_bool()?;

    let mut lines: Vec<Line<'static>> = Vec::new();

    if !timed_out {
        lines.push(Line::from(vec![
            Span::styled("  \u{2502} ", TOOL_PREFIX),
            Span::raw(format!("{} ", wait_status_icon("completed"))),
            Span::styled("Wait completed (mail arrived).".to_string(), BOLD),
        ]));
        return Some(lines);
    }

    // Timed out. Header summarises in-flight count when available;
    // legacy minimal payload (no `bg_agents_in_flight` field) just
    // says "Wait timed out." with no per-agent rows.
    let in_flight = v.get("bg_agents_in_flight").and_then(|x| x.as_u64());
    let bg_agents = v.get("bg_agents").and_then(|x| x.as_array());
    let hint = v.get("hint").and_then(|x| x.as_str());

    let header = match in_flight {
        None => "Wait timed out.".to_string(),
        Some(0) => "Wait timed out. No background sub-agents in flight.".to_string(),
        Some(1) => "Wait timed out. 1 sub-agent still running:".to_string(),
        Some(n) => format!("Wait timed out. {n} sub-agents still running:"),
    };
    lines.push(Line::from(vec![
        Span::styled("  \u{2502} ", TOOL_PREFIX),
        Span::raw(format!("{} ", wait_status_icon("timed_out"))),
        Span::styled(header, BOLD),
    ]));

    // Per-agent rows. Same icon palette + spacing as
    // `try_render_list_bg_tasks_lines` so the live overlay,
    // ListBackgroundTasks render, and WaitForMail timeout render
    // all show in-flight agents identically.
    if let Some(arr) = bg_agents {
        for entry in arr {
            let task_id = entry.get("task_id").and_then(|v| v.as_str()).unwrap_or("?");
            let status = entry.get("status").and_then(|v| v.as_str()).unwrap_or("?");
            let age_secs = entry.get("age_secs").and_then(|v| v.as_u64()).unwrap_or(0);
            let agent_name = entry
                .get("agent_name")
                .and_then(|v| v.as_str())
                .unwrap_or("");

            let icon = wait_status_icon(status);
            let status_tail = format!("({status}, {age_secs}s)");

            let mut spans: Vec<Span<'static>> = vec![
                Span::styled("  \u{2502} ", TOOL_PREFIX),
                Span::raw(format!("  {icon} ")),
                Span::styled(task_id.to_string(), BOLD),
            ];
            if !agent_name.is_empty() {
                spans.push(Span::raw("  "));
                spans.push(Span::styled(agent_name.to_string(), DIM));
            }
            spans.push(Span::raw(" "));
            spans.push(Span::styled(status_tail, DIM));
            lines.push(Line::from(spans));
        }
    }

    // Hint line. Always present in the rich payload — gives the
    // human reader the same actionable nudge the model received,
    // so transcript readers can see *why* the model picked its next
    // move. Dim italic to read as commentary, not content.
    if let Some(h) = hint {
        let dim_italic = Style::default()
            .fg(Color::DarkGray)
            .add_modifier(Modifier::ITALIC);
        lines.push(Line::from(vec![
            Span::styled("  \u{2502} ", TOOL_PREFIX),
            Span::raw("\u{1F4A1} "), // 💡
            Span::styled(h.to_string(), dim_italic),
        ]));
    }

    Some(lines)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn icon_covers_known_statuses() {
        assert_eq!(wait_status_icon("completed"), "\u{2705}");
        assert_eq!(wait_status_icon("timed_out"), "\u{23F1}");
        assert_eq!(wait_status_icon("cancelled"), "\u{26D4}");
        assert_eq!(wait_status_icon("not_found"), "\u{2753}");
        assert_eq!(wait_status_icon("forbidden"), "\u{1F512}");
        assert_eq!(wait_status_icon("parse_error"), "\u{26A0}");
    }

    #[test]
    fn icon_falls_back_visibly_for_unknown_status() {
        // The fallback must be a glyph (not empty/space) so a future
        // status added to the engine without updating this table is
        // visible in rendered output.
        let icon = wait_status_icon("brand_new_status");
        assert!(!icon.is_empty());
        assert_ne!(icon, " ");
    }

    #[test]
    fn first_meaningful_line_strips_markdown_and_truncates() {
        assert_eq!(
            first_meaningful_line("### Header\n\nReal content", 50),
            "Header"
        );
        assert_eq!(
            first_meaningful_line("> blockquote intro\nrest", 50),
            "blockquote intro"
        );
        // Truncation appends ellipsis (single Unicode char, not "...").
        let long = "a".repeat(200);
        let out = first_meaningful_line(&long, 10);
        assert!(out.ends_with('\u{2026}'), "expected ellipsis: {out}");
        assert_eq!(out.chars().count(), 11); // 10 + ellipsis
    }

    #[test]
    fn first_meaningful_line_collapses_interior_whitespace() {
        // Real LLM output often has tabs / multiple spaces inside a line.
        // The preview should read as one continuous sentence.
        assert_eq!(first_meaningful_line("foo   bar\tbaz", 50), "foo bar baz");
    }

    #[test]
    fn try_render_returns_none_on_garbage() {
        assert!(try_render_wait_task_lines("{not json").is_none());
        assert!(try_render_wait_task_lines("\"a string\"").is_none());
        assert!(try_render_wait_task_lines("null").is_none());
    }

    #[test]
    fn try_render_returns_none_on_missing_tasks() {
        // Missing or empty tasks → fall back to generic renderer rather
        // than emit an empty pretty block (which would silently swallow
        // a structurally-broken payload).
        assert!(try_render_wait_task_lines(r#"{"summary":{}}"#).is_none());
        assert!(try_render_wait_task_lines(r#"{"tasks":[]}"#).is_none());
    }

    #[test]
    fn try_render_emits_header_plus_one_line_per_task() {
        let payload = serde_json::json!({
            "summary": {"total": 2, "completed": 2},
            "tasks": [
                {"task_id": "agent:1", "status": "completed", "agent_name": "explore",
                 "output": "Found 3 issues in the parser."},
                {"task_id": "agent:2", "status": "completed", "agent_name": "explore",
                 "output": "All clear in the renderer."},
            ],
        });
        let lines = try_render_wait_task_lines(&payload.to_string()).expect("renders");
        assert_eq!(lines.len(), 3, "header + 2 task rows: {lines:?}");

        let line_text =
            |i: usize| -> String { lines[i].spans.iter().map(|s| s.content.as_ref()).collect() };
        assert!(line_text(0).contains("2 task(s) gathered"));
        assert!(line_text(0).contains("\u{2705} 2 completed"));
        assert!(line_text(1).contains("agent:1") && line_text(1).contains("Found 3 issues"));
        assert!(line_text(2).contains("agent:2") && line_text(2).contains("All clear"));
    }

    #[test]
    fn try_render_uses_distinct_icons_for_mixed_statuses() {
        let payload = serde_json::json!({
            "summary": {"total": 3, "completed": 1, "timed_out": 1, "cancelled": 1},
            "tasks": [
                {"task_id": "agent:1", "status": "completed", "agent_name": "explore",
                 "output": "OK."},
                {"task_id": "agent:2", "status": "timed_out", "agent_name": "explore"},
                {"task_id": "agent:3", "status": "cancelled", "agent_name": "explore"},
            ],
        });
        let lines = try_render_wait_task_lines(&payload.to_string()).expect("renders");
        let all: String = lines
            .iter()
            .flat_map(|l| l.spans.iter())
            .map(|s| s.content.as_ref())
            .collect();
        assert!(all.contains("\u{2705}"), "completed icon present: {all}");
        assert!(all.contains("\u{23F1}"), "timed_out icon present: {all}");
        assert!(all.contains("\u{26D4}"), "cancelled icon present: {all}");
    }

    #[test]
    fn try_render_omits_optional_fields_cleanly() {
        // Missing agent_name and missing output should NOT produce
        // dangling separators ("  — " with nothing after).
        let payload = serde_json::json!({
            "summary": {"total": 1, "completed": 1},
            "tasks": [
                {"task_id": "agent:1", "status": "completed"},
            ],
        });
        let lines = try_render_wait_task_lines(&payload.to_string()).expect("renders");
        let row: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(!row.contains("\u{2014} "), "no dangling em-dash: {row}");
        assert!(row.contains("agent:1"), "task id still present: {row}");
    }

    // --- ListBackgroundTasks renderer (#1209) ------------------------------

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

    #[test]
    fn list_bg_tasks_renders_mixed_agent_and_process() {
        let payload = serde_json::json!([
            {
                "task_id": "agent:1",
                "task_type": "agent",
                "description": "explore: scan koda-core/src for ToolEffect",
                "status": "running",
                "age_secs": 12,
            },
            {
                "task_id": "agent:2",
                "task_type": "agent",
                "description": "verify: confirm render",
                "status": "pending",
                "age_secs": 3,
            },
            {
                "task_id": "process:5821",
                "task_type": "process",
                "description": "cargo test --workspace",
                "status": "killed",
                "age_secs": 45,
            },
        ]);
        let lines = try_render_list_bg_tasks_lines(&payload.to_string()).expect("renders");
        let all = collect(&lines);

        // Header summarises count
        assert!(
            all.contains("3 background task(s)"),
            "header missing: {all}"
        );

        // Each task id present
        for id in ["agent:1", "agent:2", "process:5821"] {
            assert!(all.contains(id), "task id {id} missing: {all}");
        }

        // Status icons (running, pending, killed)
        assert!(all.contains("\u{25B6}"), "running icon ▶ missing: {all}");
        assert!(all.contains("\u{25D0}"), "pending icon ◐ missing: {all}");
        assert!(all.contains("\u{26D4}"), "killed icon ⛔ missing: {all}");

        // Status tail with age
        assert!(all.contains("(running, 12s)"), "status tail missing: {all}");
        assert!(all.contains("(killed, 45s)"), "status tail missing: {all}");
    }

    #[test]
    fn list_bg_tasks_includes_exit_code_when_present() {
        let payload = serde_json::json!([
            {
                "task_id": "process:1",
                "task_type": "process",
                "description": "cargo build",
                "status": "exited",
                "age_secs": 7,
                "exit_code": 0,
            },
        ]);
        let lines = try_render_list_bg_tasks_lines(&payload.to_string()).expect("renders");
        let all = collect(&lines);
        assert!(all.contains("(exited 0, 7s)"), "exit code missing: {all}");
        assert!(all.contains("\u{2713}"), "exited icon ✓ missing: {all}");
    }

    #[test]
    fn list_bg_tasks_renders_empty_array_visibly() {
        // The model can call this tool when nothing's running.
        // The user must still see *something* in history rather than
        // the line silently disappearing.
        let lines = try_render_list_bg_tasks_lines("[]").expect("renders");
        let all = collect(&lines);
        assert!(
            all.contains("no background tasks"),
            "empty-state header missing: {all}"
        );
        assert_eq!(lines.len(), 1, "empty state is exactly one line");
    }

    #[test]
    fn list_bg_tasks_returns_none_for_non_array() {
        // Non-array payload — fall back to generic render path.
        assert!(try_render_list_bg_tasks_lines("{}").is_none());
        assert!(try_render_list_bg_tasks_lines("not json").is_none());
    }

    // --- WaitForMail renderer (#1336 / #1343 / #1344) ----------------------

    #[test]
    fn wait_for_mail_renders_completed_payload() {
        // Shape 1: mailbox delivered — single-line success render.
        let payload = serde_json::json!({
            "message": "Wait completed.",
            "timed_out": false,
        });
        let lines = try_render_wait_for_mail_lines(&payload.to_string()).expect("renders");
        assert_eq!(lines.len(), 1, "completed render is exactly one line");
        let all = collect(&lines);
        assert!(
            all.contains("Wait completed (mail arrived)."),
            "missing success header: {all}"
        );
        assert!(all.contains('\u{2705}'), "missing ✅ icon: {all}");
    }

    #[test]
    fn wait_for_mail_renders_legacy_minimal_timeout() {
        // Shape 2: timeout with no registry attached. No per-agent
        // rows, no hint — just the header.
        let payload = serde_json::json!({
            "message": "Wait timed out.",
            "timed_out": true,
        });
        let lines = try_render_wait_for_mail_lines(&payload.to_string()).expect("renders");
        assert_eq!(lines.len(), 1, "legacy timeout is exactly one line");
        let all = collect(&lines);
        assert!(all.contains("Wait timed out."), "missing header: {all}");
        assert!(all.contains('\u{23F1}'), "missing ⏱ icon: {all}");
    }

    #[test]
    fn wait_for_mail_renders_rich_timeout_with_in_flight() {
        // Shape 3: rich timeout payload — header pluralised, per-agent
        // row uses ListBackgroundTasks vocabulary, hint line follows.
        let payload = serde_json::json!({
            "message": "Wait timed out.",
            "timed_out": true,
            "bg_agents_in_flight": 1,
            "bg_agents": [{
                "task_id": "agent:1",
                "agent_name": "explore",
                "status": "running",
                "age_secs": 41,
            }],
            "hint": "1 background sub-agent(s) still running. Consider doing other useful work.",
        });
        let lines = try_render_wait_for_mail_lines(&payload.to_string()).expect("renders");
        // header + 1 agent row + hint = 3 lines.
        assert_eq!(lines.len(), 3, "expected 3 lines, got {}", lines.len());
        let all = collect(&lines);
        assert!(
            all.contains("1 sub-agent still running"),
            "missing singular header phrasing: {all}"
        );
        assert!(all.contains("agent:1"), "missing task_id: {all}");
        assert!(all.contains("explore"), "missing agent_name: {all}");
        assert!(all.contains("(running, 41s)"), "missing status tail: {all}");
        assert!(all.contains("\u{1F4A1}"), "missing 💡 hint marker: {all}");
        assert!(
            all.contains("Consider doing other useful work"),
            "missing hint text: {all}"
        );
    }

    #[test]
    fn wait_for_mail_pluralises_multiple_in_flight() {
        // 2+ agents — header switches to plural, both rows render.
        let payload = serde_json::json!({
            "message": "Wait timed out.",
            "timed_out": true,
            "bg_agents_in_flight": 2,
            "bg_agents": [
                {"task_id": "agent:1", "agent_name": "explore",
                 "status": "running", "age_secs": 41},
                {"task_id": "agent:2", "agent_name": "verify",
                 "status": "pending", "age_secs": 3},
            ],
            "hint": "keep working",
        });
        let lines = try_render_wait_for_mail_lines(&payload.to_string()).expect("renders");
        assert_eq!(lines.len(), 4, "header + 2 rows + hint");
        let all = collect(&lines);
        assert!(
            all.contains("2 sub-agents still running"),
            "missing plural header: {all}"
        );
        assert!(all.contains("agent:1") && all.contains("agent:2"));
        assert!(all.contains("verify") && all.contains("explore"));
    }

    #[test]
    fn wait_for_mail_handles_zero_in_flight_timeout() {
        // Edge case: timeout fired but no bg-agents present — the
        // header narrates this so the user can see why the wait
        // was issued without obvious work behind it. Hint still
        // renders since it carries the actionable nudge.
        let payload = serde_json::json!({
            "message": "Wait timed out.",
            "timed_out": true,
            "bg_agents_in_flight": 0,
            "bg_agents": [],
            "hint": "No background sub-agents are in flight. ...",
        });
        let lines = try_render_wait_for_mail_lines(&payload.to_string()).expect("renders");
        assert_eq!(lines.len(), 2, "header + hint, no agent rows");
        let all = collect(&lines);
        assert!(
            all.contains("No background sub-agents in flight"),
            "missing zero-in-flight phrasing: {all}"
        );
    }

    #[test]
    fn wait_for_mail_returns_none_for_unrelated_payloads() {
        // No `timed_out` discriminator — some other tool's output.
        // Returning None keeps the caller's generic raw render alive
        // so we never silently swallow content.
        assert!(try_render_wait_for_mail_lines("not json").is_none());
        assert!(try_render_wait_for_mail_lines("{}").is_none());
        assert!(
            try_render_wait_for_mail_lines(r#"{"tasks":[]}"#).is_none(),
            "WaitTask payload should not match"
        );
        assert!(
            try_render_wait_for_mail_lines(r#"{"timed_out":"yes"}"#).is_none(),
            "non-bool timed_out should fall through"
        );
    }
}
