//! Timeline presentation helpers for share-log exports.

#![allow(clippy::let_underscore_must_use)]

use std::fmt::Write as _;

use super::{
    REDACTION_NOTICE, SUMMARY_PREVIEW_LIMIT, TIMELINE_SOURCE_CONVERSATION_FALLBACK,
    TIMELINE_SOURCE_THREAD_EVENTS, TimelineExport, TimelineRow,
};
use anyhow::{Context, Result};
use serde::Serialize;
use serde_json::Value;
use vtcode_core::core::threads::ThreadEventRecord;
use vtcode_core::exec::events::{
    CommandExecutionStatus, HarnessEventKind, McpToolCallStatus, PatchApplyStatus,
    ThreadCompletionSubtype, ThreadEvent, ThreadItem, ThreadItemDetails, ToolCallStatus, Usage,
};

pub(super) fn timeline_rows_from_thread_events(records: &[ThreadEventRecord]) -> Vec<TimelineRow> {
    records
        .iter()
        .map(|record| match &record.event {
            ThreadEvent::ThreadStarted(event) => timeline_row(
                record.sequence,
                TIMELINE_SOURCE_THREAD_EVENTS,
                "thread.started",
                None,
                "thread",
                Some("in_progress"),
                record.turn_id.as_deref(),
                record.submission_id.as_ref().map(|value| value.as_str()),
                "Thread started".to_string(),
                truncate_preview(&event.thread_id, SUMMARY_PREVIEW_LIMIT),
                event.thread_id.clone(),
                pretty_json_string(&record.event),
                false,
            ),
            ThreadEvent::ThreadCompleted(event) => {
                let status = thread_completion_status(&event.subtype);
                let mut body = String::new();
                let _ = writeln!(&mut body, "Outcome: {}", event.outcome_code);
                let _ = writeln!(&mut body, "Subtype: {}", event.subtype.as_str());
                if let Some(stop_reason) = &event.stop_reason {
                    let _ = writeln!(&mut body, "Stop reason: {}", stop_reason);
                }
                if let Some(total_cost_usd) = &event.total_cost_usd {
                    let _ = writeln!(&mut body, "Total cost (USD): {}", total_cost_usd);
                }
                let _ = writeln!(&mut body, "Turns: {}", event.num_turns);
                let usage_summary = format_usage_summary(&event.usage);
                let _ = writeln!(&mut body, "Usage: {}", usage_summary);
                if let Some(result) = &event.result {
                    append_text_section(&mut body, "Result", result);
                }

                timeline_row(
                    record.sequence,
                    TIMELINE_SOURCE_THREAD_EVENTS,
                    "thread.completed",
                    None,
                    "thread",
                    Some(status),
                    record.turn_id.as_deref(),
                    record.submission_id.as_ref().map(|value| value.as_str()),
                    "Thread completed".to_string(),
                    format!("{} ({})", event.outcome_code, status),
                    body,
                    pretty_json_string(&record.event),
                    false,
                )
            }
            ThreadEvent::ThreadCompactBoundary(event) => timeline_row(
                record.sequence,
                TIMELINE_SOURCE_THREAD_EVENTS,
                "thread.compact_boundary",
                None,
                "thread",
                Some("completed"),
                record.turn_id.as_deref(),
                record.submission_id.as_ref().map(|value| value.as_str()),
                "Compaction boundary".to_string(),
                format!(
                    "{} -> {} messages ({}/{})",
                    event.original_message_count,
                    event.compacted_message_count,
                    event.trigger.as_str(),
                    event.mode.as_str()
                ),
                {
                    let mut body = String::new();
                    let _ = writeln!(&mut body, "Trigger: {}", event.trigger.as_str());
                    let _ = writeln!(&mut body, "Mode: {}", event.mode.as_str());
                    let _ = writeln!(
                        &mut body,
                        "Messages: {} -> {}",
                        event.original_message_count, event.compacted_message_count
                    );
                    if let Some(path) = &event.history_artifact_path {
                        let _ = writeln!(&mut body, "History artifact: {}", path);
                    }
                    body
                },
                pretty_json_string(&record.event),
                false,
            ),
            ThreadEvent::TurnStarted(_) => timeline_row(
                record.sequence,
                TIMELINE_SOURCE_THREAD_EVENTS,
                "turn.started",
                None,
                "turn",
                Some("in_progress"),
                record.turn_id.as_deref(),
                record.submission_id.as_ref().map(|value| value.as_str()),
                "Turn started".to_string(),
                record
                    .turn_id
                    .clone()
                    .unwrap_or_else(|| "turn started".to_string()),
                String::new(),
                pretty_json_string(&record.event),
                false,
            ),
            ThreadEvent::TurnCompleted(event) => timeline_row(
                record.sequence,
                TIMELINE_SOURCE_THREAD_EVENTS,
                "turn.completed",
                None,
                "turn",
                Some("completed"),
                record.turn_id.as_deref(),
                record.submission_id.as_ref().map(|value| value.as_str()),
                "Turn completed".to_string(),
                format_usage_summary(&event.usage),
                format!("Usage: {}", format_usage_summary(&event.usage)),
                pretty_json_string(&record.event),
                false,
            ),
            ThreadEvent::TurnFailed(event) => {
                let mut body = event.message.clone();
                if let Some(usage) = &event.usage {
                    let _ = write!(&mut body, "\n\nUsage: {}", format_usage_summary(usage));
                }
                timeline_row(
                    record.sequence,
                    TIMELINE_SOURCE_THREAD_EVENTS,
                    "turn.failed",
                    None,
                    "turn",
                    Some("failed"),
                    record.turn_id.as_deref(),
                    record.submission_id.as_ref().map(|value| value.as_str()),
                    "Turn failed".to_string(),
                    truncate_preview(&event.message, SUMMARY_PREVIEW_LIMIT),
                    body,
                    pretty_json_string(&record.event),
                    false,
                )
            }
            ThreadEvent::ItemStarted(event) => {
                timeline_row_from_item(record, "item.started", "in_progress", &event.item)
            }
            ThreadEvent::ItemUpdated(event) => {
                timeline_row_from_item(record, "item.updated", "in_progress", &event.item)
            }
            ThreadEvent::ItemCompleted(event) => {
                timeline_row_from_item(record, "item.completed", "completed", &event.item)
            }
            ThreadEvent::PlanDelta(event) => timeline_row(
                record.sequence,
                TIMELINE_SOURCE_THREAD_EVENTS,
                "plan.delta",
                Some("plan_delta"),
                "plan",
                Some("in_progress"),
                Some(&event.turn_id),
                record.submission_id.as_ref().map(|value| value.as_str()),
                "Plan update".to_string(),
                truncate_preview(&event.delta, SUMMARY_PREVIEW_LIMIT),
                event.delta.clone(),
                pretty_json_string(&record.event),
                true,
            ),
            ThreadEvent::Error(event) => timeline_row(
                record.sequence,
                TIMELINE_SOURCE_THREAD_EVENTS,
                "error",
                Some("error"),
                "error",
                Some("failed"),
                record.turn_id.as_deref(),
                record.submission_id.as_ref().map(|value| value.as_str()),
                "Error".to_string(),
                truncate_preview(&event.message, SUMMARY_PREVIEW_LIMIT),
                event.message.clone(),
                pretty_json_string(&record.event),
                false,
            ),
        })
        .collect()
}

pub(super) fn timeline_row_from_item(
    record: &ThreadEventRecord,
    event_type: &str,
    default_status: &str,
    item: &ThreadItem,
) -> TimelineRow {
    let detail_json = pretty_json_string(&record.event);
    match &item.details {
        ThreadItemDetails::AgentMessage(message) => timeline_row(
            record.sequence,
            TIMELINE_SOURCE_THREAD_EVENTS,
            event_type,
            Some("agent_message"),
            "message",
            Some(default_status),
            record.turn_id.as_deref(),
            record.submission_id.as_ref().map(|value| value.as_str()),
            "Agent message".to_string(),
            truncate_preview(&message.text, SUMMARY_PREVIEW_LIMIT),
            message.text.clone(),
            detail_json,
            false,
        ),
        ThreadItemDetails::Plan(plan) => timeline_row(
            record.sequence,
            TIMELINE_SOURCE_THREAD_EVENTS,
            event_type,
            Some("plan"),
            "plan",
            Some(default_status),
            record.turn_id.as_deref(),
            record.submission_id.as_ref().map(|value| value.as_str()),
            "Plan".to_string(),
            truncate_preview(&plan.text, SUMMARY_PREVIEW_LIMIT),
            plan.text.clone(),
            detail_json,
            false,
        ),
        ThreadItemDetails::Reasoning(reasoning) => timeline_row(
            record.sequence,
            TIMELINE_SOURCE_THREAD_EVENTS,
            event_type,
            Some("reasoning"),
            "reasoning",
            Some(default_status),
            record.turn_id.as_deref(),
            record.submission_id.as_ref().map(|value| value.as_str()),
            reasoning
                .stage
                .as_deref()
                .map(|stage| format!("Reasoning ({stage})"))
                .unwrap_or_else(|| "Reasoning".to_string()),
            truncate_preview(&reasoning.text, SUMMARY_PREVIEW_LIMIT),
            reasoning.text.clone(),
            detail_json,
            true,
        ),
        ThreadItemDetails::CommandExecution(command) => {
            let status = command_status_label(&command.status);
            let mut body = String::new();
            if let Some(arguments) = &command.arguments {
                append_json_section(&mut body, "Arguments", arguments);
            }
            append_text_section(&mut body, "Output", &command.aggregated_output);
            if body.is_empty() {
                body = command.command.clone();
            }
            timeline_row(
                record.sequence,
                TIMELINE_SOURCE_THREAD_EVENTS,
                event_type,
                Some("command_execution"),
                "command",
                Some(status),
                record.turn_id.as_deref(),
                record.submission_id.as_ref().map(|value| value.as_str()),
                command.command.clone(),
                summarize_status_with_exit(status, command.exit_code),
                body,
                detail_json,
                event_type == "item.updated",
            )
        }
        ThreadItemDetails::ToolInvocation(tool) => {
            let status = tool_status_label(&tool.status);
            let tool_name = super::super::canonical_tool_name(&tool.tool_name);
            let mut body = String::new();
            if let Some(arguments) = &tool.arguments {
                append_json_section(&mut body, "Arguments", arguments);
            }
            if let Some(tool_call_id) = &tool.tool_call_id {
                let _ = write!(&mut body, "Tool call id: {}", tool_call_id);
            }
            timeline_row(
                record.sequence,
                TIMELINE_SOURCE_THREAD_EVENTS,
                event_type,
                Some("tool_invocation"),
                "tool",
                Some(status),
                record.turn_id.as_deref(),
                record.submission_id.as_ref().map(|value| value.as_str()),
                tool_name,
                summarize_status_with_label(status, tool.tool_call_id.as_deref()),
                body,
                detail_json,
                event_type == "item.updated",
            )
        }
        ThreadItemDetails::ToolOutput(tool_output) => {
            let status = tool_status_label(&tool_output.status);
            let mut body = String::new();
            if let Some(tool_call_id) = &tool_output.tool_call_id {
                let _ = writeln!(&mut body, "Tool call id: {}", tool_call_id);
            }
            if let Some(spool_path) = &tool_output.spool_path {
                let _ = writeln!(&mut body, "Spool path: {}", spool_path);
            }
            append_text_section(&mut body, "Output", &tool_output.output);
            if body.trim().is_empty() {
                body = format!("Call id: {}", tool_output.call_id);
            }
            timeline_row(
                record.sequence,
                TIMELINE_SOURCE_THREAD_EVENTS,
                event_type,
                Some("tool_output"),
                "tool",
                Some(status),
                record.turn_id.as_deref(),
                record.submission_id.as_ref().map(|value| value.as_str()),
                "Tool output".to_string(),
                summarize_status_with_exit(status, tool_output.exit_code),
                body,
                detail_json,
                event_type == "item.updated",
            )
        }
        ThreadItemDetails::FileChange(file_change) => timeline_row(
            record.sequence,
            TIMELINE_SOURCE_THREAD_EVENTS,
            event_type,
            Some("file_change"),
            "file_change",
            Some(patch_status_label(&file_change.status)),
            record.turn_id.as_deref(),
            record.submission_id.as_ref().map(|value| value.as_str()),
            "File change".to_string(),
            format!(
                "{} file(s) ({})",
                file_change.changes.len(),
                patch_status_label(&file_change.status)
            ),
            file_change
                .changes
                .iter()
                .map(|change| format!("{:?}: {}", change.kind, change.path))
                .collect::<Vec<_>>()
                .join("\n"),
            detail_json,
            false,
        ),
        ThreadItemDetails::McpToolCall(tool_call) => {
            let status = mcp_status_label(tool_call.status.as_ref()).unwrap_or(default_status);
            let mut body = String::new();
            if let Some(arguments) = &tool_call.arguments {
                append_json_section(&mut body, "Arguments", arguments);
            }
            if let Some(result) = &tool_call.result {
                append_text_section(&mut body, "Result", result);
            }
            timeline_row(
                record.sequence,
                TIMELINE_SOURCE_THREAD_EVENTS,
                event_type,
                Some("mcp_tool_call"),
                "mcp",
                Some(status),
                record.turn_id.as_deref(),
                record.submission_id.as_ref().map(|value| value.as_str()),
                tool_call.tool_name.clone(),
                summarize_status_with_label(status, None),
                body,
                detail_json,
                event_type == "item.updated",
            )
        }
        ThreadItemDetails::WebSearch(search) => {
            let mut body = String::new();
            let _ = writeln!(&mut body, "Query: {}", search.query);
            if let Some(provider) = &search.provider {
                let _ = writeln!(&mut body, "Provider: {}", provider);
            }
            if let Some(results) = &search.results {
                let _ = writeln!(&mut body, "Results: {}", results.len());
                append_text_section(&mut body, "Top results", &results.join("\n"));
            }
            timeline_row(
                record.sequence,
                TIMELINE_SOURCE_THREAD_EVENTS,
                event_type,
                Some("web_search"),
                "web_search",
                Some(default_status),
                record.turn_id.as_deref(),
                record.submission_id.as_ref().map(|value| value.as_str()),
                "Web search".to_string(),
                truncate_preview(&search.query, SUMMARY_PREVIEW_LIMIT),
                body,
                detail_json,
                false,
            )
        }
        ThreadItemDetails::Harness(event) => timeline_row(
            record.sequence,
            TIMELINE_SOURCE_THREAD_EVENTS,
            event_type,
            Some("harness"),
            "harness",
            Some(harness_status_label(&event.event)),
            record.turn_id.as_deref(),
            record.submission_id.as_ref().map(|value| value.as_str()),
            harness_title(&event.event).to_string(),
            truncate_preview(&harness_summary(event), SUMMARY_PREVIEW_LIMIT),
            harness_body(event),
            detail_json,
            false,
        ),
        ThreadItemDetails::Error(error) => timeline_row(
            record.sequence,
            TIMELINE_SOURCE_THREAD_EVENTS,
            event_type,
            Some("error"),
            "error",
            Some("failed"),
            record.turn_id.as_deref(),
            record.submission_id.as_ref().map(|value| value.as_str()),
            "Warning".to_string(),
            truncate_preview(&error.message, SUMMARY_PREVIEW_LIMIT),
            error.message.clone(),
            detail_json,
            false,
        ),
    }
}

pub(super) fn timeline_rows_from_messages(messages: &[Value]) -> Vec<TimelineRow> {
    let mut rows = Vec::with_capacity(messages.len());
    let mut sequence = 1_u64;

    for message in messages {
        let role = message
            .get("role")
            .and_then(Value::as_str)
            .unwrap_or("Unknown");
        let content = message.get("content").and_then(Value::as_str).unwrap_or("");
        let tool_call_id = message.get("tool_call_id").and_then(Value::as_str);
        let role_lower = role.to_ascii_lowercase();

        if tool_call_id.is_some() || role_lower.contains("tool") {
            rows.push(timeline_row(
                sequence,
                TIMELINE_SOURCE_CONVERSATION_FALLBACK,
                "conversation.tool_output",
                Some("tool_output"),
                "tool",
                Some("completed"),
                None,
                None,
                "Tool output".to_string(),
                tool_call_id
                    .map(|value| format!("tool call id: {value}"))
                    .unwrap_or_else(|| truncate_preview(content, SUMMARY_PREVIEW_LIMIT)),
                content.to_string(),
                pretty_json_value(message),
                false,
            ));
            sequence += 1;
        } else {
            rows.push(timeline_row(
                sequence,
                TIMELINE_SOURCE_CONVERSATION_FALLBACK,
                "conversation.message",
                Some("message"),
                "message",
                Some("completed"),
                None,
                None,
                format!("{role} message"),
                truncate_preview(content, SUMMARY_PREVIEW_LIMIT),
                content.to_string(),
                pretty_json_value(message),
                false,
            ));
            sequence += 1;
        }

        if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
            for tool_call in tool_calls {
                let tool_name = tool_call
                    .get("function")
                    .and_then(|value| value.get("name"))
                    .and_then(Value::as_str)
                    .map(super::super::canonical_tool_name)
                    .unwrap_or_else(|| "unknown".to_string());
                let tool_call_id = tool_call.get("id").and_then(Value::as_str);
                let mut body = String::new();
                if let Some(arguments) = tool_call
                    .get("function")
                    .and_then(|value| value.get("arguments"))
                {
                    append_json_section(&mut body, "Arguments", arguments);
                }
                rows.push(timeline_row(
                    sequence,
                    TIMELINE_SOURCE_CONVERSATION_FALLBACK,
                    "conversation.tool_call",
                    Some("tool_invocation"),
                    "tool",
                    Some("completed"),
                    None,
                    None,
                    tool_name,
                    tool_call_id
                        .map(|value| format!("tool call id: {value}"))
                        .unwrap_or_else(|| "tool invocation".to_string()),
                    body,
                    pretty_json_value(tool_call),
                    false,
                ));
                sequence += 1;
            }
        }
    }

    rows
}

pub(super) fn render_session_timeline_html(
    export: &TimelineExport,
    session_log_json: &Value,
) -> Result<String> {
    let export_json = serde_json::to_string(export).context("Failed to serialize timeline data")?;
    let session_log_json = serde_json::to_string_pretty(session_log_json)
        .context("Failed to serialize session log")?;
    let mut html = String::new();
    let escaped_workspace = escape_html(&export.workspace);
    let escaped_model = escape_html(&export.model);
    let escaped_provider = escape_html(&export.provider);
    let escaped_thread_id = escape_html(&export.thread_id);
    let escaped_source = escape_html(&export.source);
    let escaped_exported_at = escape_html(&export.exported_at);
    let escaped_overview_json = escape_html(
        &serde_json::to_string_pretty(&export.overview)
            .context("Failed to serialize session overview")?,
    );
    html.push_str(
        "<!DOCTYPE html>\n<html lang=\"en\" data-theme=\"dark\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>VT Code Thread Share</title>\n<style>\n:root{--success:#7ea36d;--danger:#BF4545;--warning:#D99A4E}[data-theme=\"dark\"]{color-scheme:dark;--bg:#262626;--panel:#2f2b28;--panel-soft:#38322d;--panel-muted:#433a32;--surface:#312c29;--line:#5b4c45;--line-soft:#4a3f39;--text:#BFB38F;--muted:#d1c59f;--accent:#D9487D;--summary:#e4d8b3;--user:#D99A4E;--assistant:#BFB38F;--system:#D9487D}[data-theme=\"light\"]{color-scheme:light;--bg:#F6F1E5;--panel:#FFFDF8;--panel-soft:#F5EFE5;--panel-muted:#EFE7DB;--surface:#FAF5ED;--line:#DDD1C2;--line-soft:#EBE1D3;--text:#393A34;--muted:#6A695F;--accent:#1C6B48;--summary:#4A463E;--user:#B07000;--assistant:#393A34;--system:#1C6B48}*{box-sizing:border-box}html,body{background:var(--bg)}body{margin:0;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;color:var(--text);line-height:1.55;transition:background-color .18s ease,color .18s ease}a{color:inherit}button,input,select{font:inherit}main{max-width:1240px;margin:0 auto;padding:28px 18px 80px}.masthead{margin:0 auto 20px}.masthead-top{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.eyebrow{font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}.theme-toggle,.button{padding:9px 13px;border-radius:999px;border:1px solid var(--line);background:var(--panel-soft);color:var(--text);cursor:pointer}.theme-toggle:hover,.button:hover{background:var(--panel-muted)}.masthead h1{margin:10px 0 14px;font-size:clamp(2rem,4vw,3.2rem);line-height:1.05;font-weight:800}.byline{display:flex;flex-wrap:wrap;gap:10px;align-items:center;color:var(--muted);font-size:.96rem}.pill{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;background:var(--panel-soft);border:1px solid var(--line-soft)}.pill.notice{color:var(--accent)}.layout{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:22px;align-items:start}.transcript-column{min-width:0}.sidebar{position:sticky;top:18px;display:grid;gap:14px}.panel,.lede,.entry,.empty{background:var(--panel);border:1px solid var(--line);border-radius:18px}.panel{padding:14px 16px}.panel details{margin:0}.panel details>summary,.summary-toggle{cursor:pointer;list-style:none;color:var(--muted);font-weight:700}.panel details>summary::-webkit-details-marker{display:none}.panel-body{margin-top:14px}.lede{padding:16px 18px;color:var(--summary)}.lede pre,.raw-json,.log-stream pre,.inline-json-preview{margin:0;white-space:pre-wrap;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.92rem;line-height:1.6}.facts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:14px}.fact{padding:14px;border-radius:14px;background:var(--panel-soft)}.fact-label{display:block;font-size:.72rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}.fact-value{display:block;margin-top:6px;font-weight:700}.kv-list{list-style:none;margin:0;padding:0}.kv-list li{display:grid;grid-template-columns:140px 1fr;gap:12px;align-items:flex-start;padding:9px 0;border-top:1px solid var(--line-soft)}.kv-list li:first-child{border-top:0;padding-top:0}.kv-key{color:var(--muted);font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;font-weight:700}.kv-value{color:var(--text);min-width:0;white-space:pre-wrap;word-break:break-word}.control-grid{display:grid;gap:10px}.control{display:grid;gap:6px}.control label{font-size:.8rem;color:var(--muted);font-weight:600}.control input,.control select{width:100%;padding:11px 12px;border-radius:12px;border:1px solid var(--line);background:var(--panel-muted);color:var(--text)}.control input:focus,.control select:focus{outline:none;border-color:var(--accent)}.toolbar{display:grid;gap:10px;margin-top:12px}.toggle{display:flex;align-items:center;gap:8px;color:var(--muted);font-weight:600}.results{color:var(--muted);font-weight:600}.sidebar-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.inline-json-preview{margin-top:12px;padding:12px;border-radius:14px;background:var(--panel-soft);border:1px solid var(--line-soft)}.timeline{display:grid;gap:14px}.entry{padding:16px 18px}.entry-head{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-start;justify-content:space-between}.entry-meta{display:grid;gap:8px;min-width:0}.entry-identity{display:flex;flex-wrap:wrap;gap:10px;align-items:center}.role-badge,.seq,.kind-badge,.status-badge{display:inline-flex;align-items:center;padding:5px 10px;border-radius:999px;border:1px solid var(--line-soft);background:var(--panel-soft);font-size:.76rem;font-weight:700}.role-user{color:var(--user)}.role-assistant{color:var(--assistant)}.role-system{color:var(--system)}.seq{color:var(--muted)}.entry-title{font-size:1.02rem;font-weight:700}.entry-subtitle{color:var(--muted);font-size:.92rem}.entry-tags{display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end}.kind-badge{color:var(--muted)}.status-badge.status-completed{color:var(--success)}.status-badge.status-in_progress{color:var(--accent)}.status-badge.status-failed,.status-badge.status-cancelled{color:var(--danger)}.status-badge.low-signal{color:var(--warning)}.entry-body{margin-top:14px;display:grid;gap:12px}.message-block,.system-block,.log-stream,.detail-shell{padding:14px;border-radius:14px;background:var(--panel-soft);border:1px solid var(--line-soft)}.message-text p,.system-copy p{margin:0 0 10px}.message-text p:last-child,.system-copy p:last-child{margin-bottom:0}.message-text ul,.system-copy ul{margin:0;padding-left:18px}.message-text li,.system-copy li{margin:0 0 6px}.message-section-title,.system-label{display:block;margin-bottom:8px;color:var(--muted);font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;font-weight:700}.message-text code,.system-copy code{padding:1px 5px;border-radius:6px;background:var(--panel-muted);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.92em}.message-text strong,.system-copy strong{font-weight:800}.log-stream-title{display:block;margin-bottom:8px;color:var(--muted);font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;font-weight:700}.raw-json{padding:14px;border-radius:14px;background:var(--panel-soft);border:1px solid var(--line-soft)}details{margin-top:12px}details summary{cursor:pointer;color:var(--muted);font-weight:700}.empty{padding:28px;text-align:center;color:var(--muted)}.footer-note{margin-top:20px;color:var(--muted);font-size:.9rem}@media (max-width:980px){.layout{grid-template-columns:1fr}.sidebar{position:static}}@media (max-width:760px){main{padding:20px 12px 48px}.masthead-top{flex-direction:column;align-items:stretch}.facts{grid-template-columns:1fr}.kv-list li{grid-template-columns:1fr;gap:4px}.entry{padding:14px}}\n</style>\n</head>\n<body>\n<main>\n<header class=\"masthead\">\n<div class=\"masthead-top\">\n<div class=\"eyebrow\">Shared Thread</div>\n<button id=\"theme-toggle\" class=\"theme-toggle\" type=\"button\">Switch to light</button>\n</div>\n<h1>VT Code Session Transcript</h1>\n<div class=\"byline\">\n<span class=\"pill\">Provider: ");
    html.push_str(&escaped_provider);
    html.push_str("</span>\n<span class=\"pill\">Model: ");
    html.push_str(&escaped_model);
    html.push_str("</span>\n<span class=\"pill\">Exported: <time id=\"exported-at\" datetime=\"");
    html.push_str(&escaped_exported_at);
    html.push_str("\">");
    html.push_str(&escaped_exported_at);
    html.push_str("</time></span>\n<span class=\"pill notice\">");
    html.push_str(&escape_html(REDACTION_NOTICE));
    html.push_str("</span>\n</div>\n</header>\n<div class=\"layout\">\n<section class=\"transcript-column\">\n<section class=\"lede\"><pre>");
    html.push_str(&escaped_workspace);
    html.push_str("\nThread: ");
    html.push_str(&escaped_thread_id);
    html.push_str("\nSource: ");
    html.push_str(&escaped_source);
    html.push_str("</pre>\n<div class=\"facts\">\n<div class=\"fact\"><span class=\"fact-label\">API Calls</span><span class=\"fact-value\" id=\"overview-api-calls\"></span></div>\n<div class=\"fact\"><span class=\"fact-label\">Tokens</span><span class=\"fact-value\" id=\"overview-tokens\"></span></div>\n<div class=\"fact\"><span class=\"fact-label\">Diff</span><span class=\"fact-value\" id=\"overview-diff\"></span></div>\n</div>\n</section>\n<section class=\"panel\"><details id=\"overview-panel\"><summary>Session Overview</summary><div class=\"panel-body\"><ul id=\"overview-list\" class=\"kv-list\"></ul><details><summary>Overview JSON</summary><pre class=\"raw-json\">");
    html.push_str(&escaped_overview_json);
    html.push_str("</pre></details></div></details></section>\n<section id=\"timeline\" class=\"timeline\"></section>\n<p class=\"footer-note\">This HTML file is self-contained, shareable offline, and exported with redaction enabled.</p>\n</section>\n<aside class=\"sidebar\">\n<section class=\"panel\"><details id=\"filters-panel\"><summary>Search &amp; Filters</summary><div class=\"panel-body\"><div class=\"control-grid\">\n<div class=\"control\"><label for=\"search-input\">Search</label><input id=\"search-input\" type=\"search\" placeholder=\"Search transcript, tool output, commands\"></div>\n<div class=\"control\"><label for=\"category-filter\">Category</label><select id=\"category-filter\"></select></div>\n<div class=\"control\"><label for=\"status-filter\">Status</label><select id=\"status-filter\"></select></div>\n</div>\n<div class=\"toolbar\">\n<label class=\"toggle\" for=\"hide-low-signal\"><input id=\"hide-low-signal\" type=\"checkbox\">Hide low-signal rows</label>\n<button id=\"clear-filters\" class=\"button\" type=\"button\">Clear filters</button>\n<div id=\"results-count\" class=\"results\"></div>\n</div></div></details></section>\n<section class=\"panel\"><details id=\"raw-log-panel\"><summary>Raw JSON Log</summary><div class=\"panel-body\"><div class=\"system-copy\"><p>");
    html.push_str(&escape_html(REDACTION_NOTICE));
    html.push_str("</p></div><div class=\"sidebar-actions\"><button id=\"open-json\" class=\"button\" type=\"button\">Open redacted JSON log</button><button id=\"toggle-json-preview\" class=\"button\" type=\"button\">Toggle inline preview</button></div><pre id=\"raw-log-preview\" class=\"inline-json-preview\" hidden></pre></div></details></section>\n</aside>\n</div>\n</main>\n<script id=\"vtcode-session-data\" type=\"application/json\">");
    html.push_str(&sanitize_json_for_script_tag(&export_json));
    html.push_str("</script>\n<script id=\"vtcode-session-log-data\" type=\"application/json\">");
    html.push_str(&sanitize_json_for_script_tag(&session_log_json));
    html.push_str("</script>\n<script>\nconst exportData = JSON.parse(document.getElementById('vtcode-session-data').textContent);\nconst sessionLog = JSON.parse(document.getElementById('vtcode-session-log-data').textContent);\nconst rows = exportData.rows || [];\nconst overview = exportData.overview || {};\nconst timelineEl = document.getElementById('timeline');\nconst overviewList = document.getElementById('overview-list');\nconst searchInput = document.getElementById('search-input');\nconst categoryFilter = document.getElementById('category-filter');\nconst statusFilter = document.getElementById('status-filter');\nconst hideLowSignal = document.getElementById('hide-low-signal');\nconst clearFilters = document.getElementById('clear-filters');\nconst resultsCount = document.getElementById('results-count');\nconst themeToggle = document.getElementById('theme-toggle');\nconst openJson = document.getElementById('open-json');\nconst toggleJsonPreview = document.getElementById('toggle-json-preview');\nconst rawLogPreview = document.getElementById('raw-log-preview');\nconst exportedAt = document.getElementById('exported-at');\nconst root = document.documentElement;\nfunction escapeHtml(text){return String(text || '').replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('\"','&quot;').replaceAll(\"'\",'&#39;');}\nfunction formatNumber(value){return Number(value || 0).toLocaleString(undefined);}\nfunction normalizeText(text){return String(text || '').replace(/\\s+/g,' ').trim();}\nfunction setTheme(theme){root.dataset.theme = theme; if(themeToggle){themeToggle.textContent = theme === 'dark' ? 'Switch to light' : 'Switch to dark';}}\nfunction toggleTheme(){setTheme(root.dataset.theme === 'light' ? 'dark' : 'light');}\nfunction fillSelect(select, values, label){select.innerHTML = `<option value=\"\">${escapeHtml(label)}</option>` + values.map(value => `<option value=\"${escapeHtml(value)}\">${escapeHtml(value)}</option>`).join('');}\nfunction uniqueValues(key){return [...new Set(rows.map(row => row[key]).filter(Boolean))].sort((a,b) => String(a).localeCompare(String(b)));}\nfunction rowSearchText(row){return [row.role,row.transcript_kind,row.event_type,row.item_type,row.category,row.status,row.title,row.summary,row.body,row.detail_json].filter(Boolean).join('\\n').toLowerCase();}\nfunction statusClass(status){return status ? ` status-${status}` : '';}\nfunction roleClass(role){return role ? ` role-${role}` : ' role-system';}\nfunction inlineMarkdown(text){return escapeHtml(text).replace(/`([^`]+)`/g,'<code>$1</code>').replace(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>');}\nfunction splitBlocks(text){return String(text || '').split(/\\n\\s*\\n/).map(block => block.trim()).filter(Boolean);}\nfunction formatLocaleTimestamp(){if(!exportedAt){return;} const raw = exportedAt.getAttribute('datetime') || exportedAt.textContent || ''; const date = new Date(raw); if(Number.isNaN(date.getTime())){return;} exportedAt.textContent = new Intl.DateTimeFormat(undefined,{dateStyle:'full',timeStyle:'long'}).format(date);}\nfunction messageBody(row){const summary = String(row.summary || '').trim(); const body = String(row.body || '').trim(); if(!body){return summary;} if(normalizeText(body) === normalizeText(summary)){return body;} return body;}\nfunction renderBulletList(lines){return `<ul>` + lines.map(line => `<li>${inlineMarkdown(line.replace(/^[-*]\\s+/, '').trim())}</li>`).join('') + `</ul>`;}\nfunction renderMessageBlocks(text){const blocks = splitBlocks(text); if(!blocks.length){return '<div class=\"message-block\"><div class=\"message-text\"><p>No transcript content.</p></div></div>';} return blocks.map(block => { const lines = block.split('\\n').map(line => line.trim()).filter(Boolean); if(!lines.length){return '';} const heading = lines[0].match(/^([A-Za-z][A-Za-z0-9 &/()_-]{0,60}):$/); if(heading && lines.slice(1).every(line => /^[-*]\\s+/.test(line))){ return `<section class=\"message-block\"><span class=\"message-section-title\">${escapeHtml(heading[1])}</span><div class=\"message-text\">${renderBulletList(lines.slice(1))}</div></section>`; } if(lines.every(line => /^[-*]\\s+/.test(line))){ return `<section class=\"message-block\"><div class=\"message-text\">${renderBulletList(lines)}</div></section>`; } return `<section class=\"message-block\"><div class=\"message-text\">` + lines.map(line => `<p>${inlineMarkdown(line)}</p>`).join('') + `</div></section>`; }).join('');}\nfunction renderStreams(text){const normalized = String(text || '').trim(); if(!normalized){return '';} const streamRegex = /(stdout|stderr)\\s+─+\\n([\\s\\S]*?)(?=(?:\\n(?:stdout|stderr)\\s+─+\\n)|$)/gi; const streams = [...normalized.matchAll(streamRegex)]; if(!streams.length){return `<section class=\"log-stream\"><span class=\"log-stream-title\">Output</span><pre>${escapeHtml(normalized)}</pre></section>`;} return streams.map(match => `<section class=\"log-stream\"><span class=\"log-stream-title\">${escapeHtml(match[1])}</span><pre>${escapeHtml((match[2] || '').trim())}</pre></section>`).join('');}\nfunction renderSystemBlocks(text){const blocks = splitBlocks(text); if(!blocks.length){return '';} return blocks.map(block => { const lines = block.split('\\n').map(line => line.trimEnd()).filter(line => line.trim().length > 0); if(!lines.length){return '';} const first = lines[0].trim(); const headingMatch = first.match(/^([A-Za-z][A-Za-z0-9 ()_./-]{0,60}):(.*)$/); if(headingMatch){ const label = headingMatch[1]; const inlineValue = headingMatch[2].trim(); const rest = [inlineValue, ...lines.slice(1).map(line => line.trim())].filter(Boolean).join('\\n').trim(); if(label === 'Output' || label === 'Result' || label === 'Arguments' || label === 'Top results'){ return `<section class=\"system-block\"><span class=\"system-label\">${escapeHtml(label)}</span>${renderStreams(rest)}</section>`; } return `<section class=\"system-block\"><span class=\"system-label\">${escapeHtml(label)}</span><div class=\"system-copy\"><p>${inlineMarkdown(rest || '')}</p></div></section>`; } return `<section class=\"detail-shell\"><pre>${escapeHtml(lines.join('\\n'))}</pre></section>`; }).join('');}\nfunction renderEntryBody(row){if(row.transcript_kind === 'message'){return renderMessageBlocks(messageBody(row));} if(row.transcript_kind === 'plan' || row.transcript_kind === 'reasoning'){return renderMessageBlocks(row.body || row.summary);} return renderSystemBlocks(row.body || row.summary || '');}\nfunction renderRawEvent(row){if(!row.detail_json){return '';} return `<details><summary>Raw event JSON</summary><pre class=\"raw-json\">${escapeHtml(row.detail_json)}</pre></details>`;}\nfunction renderRow(row){const subtitle = row.summary && normalizeText(row.summary) !== normalizeText(row.body || '') ? `<div class=\"entry-subtitle\">${inlineMarkdown(row.summary)}</div>` : ''; const tags = [`<span class=\"kind-badge\">${escapeHtml(row.transcript_kind || row.category || 'status')}</span>`, row.category ? `<span class=\"kind-badge\">${escapeHtml(row.category)}</span>` : '', row.status ? `<span class=\"status-badge${statusClass(row.status)}\">${escapeHtml(row.status)}</span>` : '', row.is_low_signal ? '<span class=\"status-badge low-signal\">low-signal</span>' : ''].filter(Boolean).join(''); return `<article class=\"entry\"><div class=\"entry-head\"><div class=\"entry-meta\"><div class=\"entry-identity\"><span class=\"role-badge${roleClass(row.role)}\">${escapeHtml(row.role || 'system')}</span><span class=\"seq\">#${row.sequence}</span><span class=\"entry-title\">${escapeHtml(row.title)}</span></div>${subtitle}</div><div class=\"entry-tags\">${tags}</div></div><div class=\"entry-body\">${renderEntryBody(row)}${renderRawEvent(row)}</div></article>`;}\nfunction fillOverview(){const apiCalls = document.getElementById('overview-api-calls'); const tokens = document.getElementById('overview-tokens'); const diff = document.getElementById('overview-diff'); if(apiCalls){apiCalls.textContent = `${formatNumber(overview.api_calls)} call(s) / ${formatNumber(overview.turns)} turn(s)`;} if(tokens){tokens.textContent = `${formatNumber(overview.input_tokens)} in / ${formatNumber(overview.output_tokens)} out`; } if(diff){diff.textContent = `+${formatNumber(overview.added_files)} ~${formatNumber(overview.updated_files)} -${formatNumber(overview.deleted_files)}`;} if(overviewList){const cacheChurn = `stable_prefix: ${formatNumber(overview.prompt_cache_stable_prefix_changes)} | tool_catalog: ${formatNumber(overview.prompt_cache_tool_catalog_changes)} | both: ${formatNumber(overview.prompt_cache_combined_changes)} | model: ${formatNumber(overview.prompt_cache_model_changes)} | unchanged: ${formatNumber(overview.prompt_cache_unchanged)}`; const items = [['Provider', overview.provider || ''], ['Model', overview.model || ''], ['Source', overview.source || ''], ['Rows', formatNumber(overview.total_rows)], ['API calls', `${formatNumber(overview.api_calls)} call(s)`], ['Turns', formatNumber(overview.turns)], ['Input tokens', formatNumber(overview.input_tokens)], ['Output tokens', formatNumber(overview.output_tokens)], ['Cached input', formatNumber(overview.cached_input_tokens)], ['Cache creation', formatNumber(overview.cache_creation_tokens)], ['Total tokens', formatNumber(overview.total_tokens)], ['Diff', `+${formatNumber(overview.added_files)} ~${formatNumber(overview.updated_files)} -${formatNumber(overview.deleted_files)}`], ['Prompt cache observations', formatNumber(overview.prompt_cache_observations)], ['Cache churn', cacheChurn], ['Last cache change', overview.last_prompt_cache_change_reason || 'n/a'], ['Outcome', overview.outcome_code || 'n/a'], ['Cost', overview.total_cost_usd || 'n/a'], ['Redaction', exportData.redaction_enabled ? 'enabled' : 'disabled']]; overviewList.innerHTML = items.map(([key, value]) => `<li><span class=\"kv-key\">${escapeHtml(key)}</span><span class=\"kv-value\">${escapeHtml(value)}</span></li>`).join(''); }}\nfunction openRawJsonWindow(){const popup = window.open('', '_blank', 'noopener,noreferrer'); if(!popup){return;} const pretty = JSON.stringify(sessionLog, null, 2); popup.document.write(`<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>VT Code Redacted JSON Log</title><style>body{margin:0;padding:18px;background:#111;color:#f5f5f5;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}pre{white-space:pre-wrap;word-break:break-word}</style></head><body><pre>${escapeHtml(pretty)}</pre></body></html>`); popup.document.close();}\nfunction render(){const query = searchInput.value.trim().toLowerCase(); const category = categoryFilter.value; const status = statusFilter.value; const hideLow = hideLowSignal.checked; const filtered = rows.filter(row => { if (hideLow && row.is_low_signal) return false; if (category && row.category !== category) return false; if (status && row.status !== status) return false; if (query && !rowSearchText(row).includes(query)) return false; return true; }); resultsCount.textContent = `${filtered.length} of ${rows.length} rows`; if (!filtered.length){timelineEl.innerHTML = '<div class=\"empty\">No transcript rows match the current filters.</div>'; return;} timelineEl.innerHTML = filtered.map(renderRow).join('');}\nfillSelect(categoryFilter, uniqueValues('category'), 'All categories');\nfillSelect(statusFilter, uniqueValues('status'), 'All statuses');\nfillOverview();\nformatLocaleTimestamp();\nsetTheme(root.dataset.theme || 'dark');\nif(themeToggle){themeToggle.addEventListener('click', toggleTheme);}\nif(openJson){openJson.addEventListener('click', openRawJsonWindow);}\nif(toggleJsonPreview && rawLogPreview){toggleJsonPreview.addEventListener('click', () => { rawLogPreview.hidden = !rawLogPreview.hidden; if(!rawLogPreview.hidden && !rawLogPreview.textContent){ rawLogPreview.textContent = JSON.stringify(sessionLog, null, 2); } });}\nsearchInput.addEventListener('input', render);\ncategoryFilter.addEventListener('change', render);\nstatusFilter.addEventListener('change', render);\nhideLowSignal.addEventListener('change', render);\nclearFilters.addEventListener('click', () => { searchInput.value = ''; categoryFilter.value = ''; statusFilter.value = ''; hideLowSignal.checked = false; render(); });\nrender();\n</script>\n</body>\n</html>\n");

    Ok(html)
}

#[expect(clippy::too_many_arguments)]
fn timeline_row(
    sequence: u64,
    source: &str,
    event_type: &str,
    item_type: Option<&str>,
    category: &str,
    status: Option<&str>,
    turn_id: Option<&str>,
    submission_id: Option<&str>,
    title: String,
    summary: String,
    body: String,
    detail_json: Option<String>,
    is_low_signal: bool,
) -> TimelineRow {
    let role = infer_row_role(category, item_type, &title);
    let transcript_kind = infer_transcript_kind(category, item_type, &title);
    TimelineRow {
        sequence,
        source: source.to_string(),
        event_type: event_type.to_string(),
        item_type: item_type.map(str::to_string),
        category: category.to_string(),
        role,
        transcript_kind,
        status: status.map(str::to_string),
        turn_id: turn_id.map(str::to_string),
        submission_id: submission_id.map(str::to_string),
        title,
        summary,
        body,
        detail_json,
        is_low_signal,
    }
}

fn infer_row_role(category: &str, item_type: Option<&str>, title: &str) -> String {
    let normalized_title = title.to_ascii_lowercase();
    if normalized_title.starts_with("user ") {
        return "user".to_string();
    }
    if normalized_title.starts_with("assistant ")
        || matches!(
            item_type,
            Some("agent_message" | "plan" | "reasoning" | "plan_delta")
        )
    {
        return "assistant".to_string();
    }
    if category == "message" && normalized_title.contains("assistant") {
        return "assistant".to_string();
    }
    "system".to_string()
}

fn infer_transcript_kind(category: &str, item_type: Option<&str>, title: &str) -> String {
    let normalized_title = title.to_ascii_lowercase();
    if normalized_title.contains("pty") {
        return "pty".to_string();
    }
    match item_type {
        Some("agent_message" | "message") => "message".to_string(),
        Some("plan" | "plan_delta") => "plan".to_string(),
        Some("reasoning") => "reasoning".to_string(),
        Some("tool_invocation" | "tool_output" | "mcp_tool_call" | "web_search") => {
            if normalized_title.contains("pty") {
                "pty".to_string()
            } else {
                "tool".to_string()
            }
        }
        Some("command_execution") => "command".to_string(),
        Some("file_change") => "diff".to_string(),
        _ => match category {
            "message" => "message".to_string(),
            "tool" | "mcp" | "web_search" => "tool".to_string(),
            "command" => "command".to_string(),
            "file_change" => "diff".to_string(),
            _ => "status".to_string(),
        },
    }
}

fn append_text_section(body: &mut String, label: &str, text: &str) {
    let trimmed = text.trim();
    if trimmed.is_empty() {
        return;
    }
    if !body.is_empty() {
        body.push_str("\n\n");
    }
    let _ = write!(body, "{label}:\n{trimmed}");
}

fn append_json_section(body: &mut String, label: &str, value: &Value) {
    if let Ok(pretty) = serde_json::to_string_pretty(value) {
        append_text_section(body, label, &pretty);
    }
}

fn format_usage_summary(usage: &Usage) -> String {
    format!(
        "input={} cached={} cache_create={} output={}",
        usage.input_tokens,
        usage.cached_input_tokens,
        usage.cache_creation_tokens,
        usage.output_tokens
    )
}

fn truncate_preview(text: &str, max_chars: usize) -> String {
    let candidate = text
        .lines()
        .map(str::trim)
        .find(|line| !line.is_empty())
        .unwrap_or("")
        .trim();

    if candidate.is_empty() {
        return "No textual content.".to_string();
    }

    let mut truncated = String::new();
    let mut chars = candidate.chars();
    for _ in 0..max_chars {
        let Some(ch) = chars.next() else {
            return candidate.to_string();
        };
        truncated.push(ch);
    }
    if chars.next().is_some() {
        truncated.push('…');
    }
    truncated
}

fn pretty_json_string<T: Serialize>(value: &T) -> Option<String> {
    serde_json::to_string_pretty(value).ok()
}

fn pretty_json_value(value: &Value) -> Option<String> {
    serde_json::to_string_pretty(value).ok()
}

pub(super) fn redact_timeline_export(export: &TimelineExport) -> TimelineExport {
    let mut redacted = export.clone();
    redacted.workspace = super::super::redact_sensitive_text(&redacted.workspace);
    redacted.thread_id = super::super::redact_sensitive_text(&redacted.thread_id);
    if let Some(outcome_code) = &redacted.overview.outcome_code {
        redacted.overview.outcome_code = Some(super::super::redact_sensitive_text(outcome_code));
    }
    for row in &mut redacted.rows {
        row.title = super::super::redact_sensitive_text(&row.title);
        row.summary = super::super::redact_sensitive_text(&row.summary);
        row.body = super::super::redact_sensitive_text(&row.body);
        row.turn_id = row
            .turn_id
            .as_ref()
            .map(|value| super::super::redact_sensitive_text(value));
        row.submission_id = row
            .submission_id
            .as_ref()
            .map(|value| super::super::redact_sensitive_text(value));
        row.detail_json = row
            .detail_json
            .as_ref()
            .map(|value| super::super::redact_sensitive_text(value));
    }
    redacted
}

fn command_status_label(status: &CommandExecutionStatus) -> &'static str {
    match status {
        CommandExecutionStatus::Completed => "completed",
        CommandExecutionStatus::Failed => "failed",
        CommandExecutionStatus::InProgress => "in_progress",
    }
}

fn tool_status_label(status: &ToolCallStatus) -> &'static str {
    match status {
        ToolCallStatus::Completed => "completed",
        ToolCallStatus::Failed => "failed",
        ToolCallStatus::InProgress => "in_progress",
    }
}

fn mcp_status_label(status: Option<&McpToolCallStatus>) -> Option<&'static str> {
    match status {
        Some(McpToolCallStatus::Started) => Some("in_progress"),
        Some(McpToolCallStatus::Completed) => Some("completed"),
        Some(McpToolCallStatus::Failed) => Some("failed"),
        None => None,
    }
}

fn patch_status_label(status: &PatchApplyStatus) -> &'static str {
    match status {
        PatchApplyStatus::Completed => "completed",
        PatchApplyStatus::Failed => "failed",
    }
}

fn thread_completion_status(subtype: &ThreadCompletionSubtype) -> &'static str {
    match subtype {
        ThreadCompletionSubtype::Success => "completed",
        ThreadCompletionSubtype::Cancelled => "cancelled",
        ThreadCompletionSubtype::ErrorMaxTurns
        | ThreadCompletionSubtype::ErrorMaxBudgetUsd
        | ThreadCompletionSubtype::ErrorDuringExecution => "failed",
    }
}

fn summarize_status_with_exit(status: &str, exit_code: Option<i32>) -> String {
    match exit_code {
        Some(code) => format!("{status} (exit {code})"),
        None => status.to_string(),
    }
}

fn summarize_status_with_label(status: &str, label: Option<&str>) -> String {
    match label {
        Some(value) => format!("{status} ({value})"),
        None => status.to_string(),
    }
}

fn harness_title(event: &HarnessEventKind) -> &'static str {
    match event {
        HarnessEventKind::PlanningStarted => "Planning started",
        HarnessEventKind::PlanningCompleted => "Planning completed",
        HarnessEventKind::ContinuationStarted => "Continuation started",
        HarnessEventKind::ContinuationSkipped => "Continuation skipped",
        HarnessEventKind::BlockedHandoffWritten => "Blocked handoff written",
        HarnessEventKind::EvaluationStarted => "Evaluation started",
        HarnessEventKind::EvaluationPassed => "Evaluation passed",
        HarnessEventKind::EvaluationFailed => "Evaluation failed",
        HarnessEventKind::RevisionStarted => "Revision started",
        HarnessEventKind::VerificationStarted => "Verification started",
        HarnessEventKind::VerificationPassed => "Verification passed",
        HarnessEventKind::VerificationFailed => "Verification failed",
    }
}

fn harness_status_label(event: &HarnessEventKind) -> &'static str {
    match event {
        HarnessEventKind::PlanningCompleted
        | HarnessEventKind::EvaluationPassed
        | HarnessEventKind::VerificationPassed
        | HarnessEventKind::BlockedHandoffWritten => "completed",
        HarnessEventKind::EvaluationFailed | HarnessEventKind::VerificationFailed => "failed",
        HarnessEventKind::PlanningStarted
        | HarnessEventKind::ContinuationStarted
        | HarnessEventKind::ContinuationSkipped
        | HarnessEventKind::EvaluationStarted
        | HarnessEventKind::RevisionStarted
        | HarnessEventKind::VerificationStarted => "in_progress",
    }
}

fn harness_summary(event: &vtcode_core::exec::events::HarnessEventItem) -> String {
    event
        .message
        .as_ref()
        .cloned()
        .or_else(|| event.command.as_ref().cloned())
        .or_else(|| event.path.as_ref().cloned())
        .unwrap_or_else(|| harness_title(&event.event).to_string())
}

fn harness_body(event: &vtcode_core::exec::events::HarnessEventItem) -> String {
    let mut body = String::new();
    if let Some(message) = &event.message {
        let _ = writeln!(&mut body, "Message: {}", message);
    }
    if let Some(command) = &event.command {
        let _ = writeln!(&mut body, "Command: {}", command);
    }
    if let Some(path) = &event.path {
        let _ = writeln!(&mut body, "Path: {}", path);
    }
    if let Some(exit_code) = event.exit_code {
        let _ = writeln!(&mut body, "Exit code: {}", exit_code);
    }
    body.trim_end().to_string()
}

fn escape_html(input: &str) -> String {
    let mut escaped = String::with_capacity(input.len());
    for ch in input.chars() {
        match ch {
            '&' => escaped.push_str("&amp;"),
            '<' => escaped.push_str("&lt;"),
            '>' => escaped.push_str("&gt;"),
            '"' => escaped.push_str("&quot;"),
            '\'' => escaped.push_str("&#39;"),
            _ => escaped.push(ch),
        }
    }
    escaped
}

fn sanitize_json_for_script_tag(input: &str) -> String {
    input
        .replace('&', "\\u0026")
        .replace('<', "\\u003c")
        .replace('>', "\\u003e")
}
