/**
 * claude-stream — Shared types, rendering, and core spawn/stream logic
 * for ask-claude and chat-claude pi extensions.
 *
 * Both extensions spawn `claude -p --output-format stream-json` and parse
 * the same streaming protocol. This module provides:
 *   - Block types (ThinkingBlock, ToolBlock, TextBlock)
 *   - Rendering helpers (tool call lines, result boxes, usage formatting)
 *   - runClaude() — the core spawn + stream parser
 */

import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { diffLines } from "diff";
import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";

// =============================================================================
// Block types
// =============================================================================

export interface ThinkingBlock {
	type: "thinking";
	text: string;
}

export interface ToolBlock {
	type: "tool";
	id: string;
	name: string;
	inputJson: string;
	editContext?: { before: string[]; after: string[]; startLine: number };
	result?: { text: string; isError: boolean };
}

export interface TextBlock {
	type: "text";
	text: string;
}

export type StreamBlock = ThinkingBlock | ToolBlock | TextBlock;

// =============================================================================
// Details interface (stored in tool result, drives rendering)
// =============================================================================

export interface ClaudeDetails {
	label: string;
	done: boolean;
	blocks: StreamBlock[];
	finalText: string;
	sessionId?: string;
	isResume?: boolean;
	costUsd?: number;
	inputTokens?: number;
	outputTokens?: number;
	cacheReadTokens?: number;
	cacheWriteTokens?: number;
}

// =============================================================================
// Rendering helpers
// =============================================================================

export function shortenPath(p: string): string {
	const home = process.env.HOME ?? "";
	return home && p.startsWith(home) ? "~" + p.slice(home.length) : p;
}

export function formatUsage(d: ClaudeDetails): string {
	const parts: string[] = [];
	if (d.inputTokens)      parts.push(`↑${d.inputTokens}`);
	if (d.outputTokens)     parts.push(`↓${d.outputTokens}`);
	if (d.cacheReadTokens)  parts.push(`R${d.cacheReadTokens}`);
	if (d.cacheWriteTokens) parts.push(`W${d.cacheWriteTokens}`);
	if (d.costUsd)          parts.push(d.costUsd < 0.001 ? `$${(d.costUsd * 1000).toFixed(2)}m` : `$${d.costUsd.toFixed(4)}`);
	return parts.join(" ");
}

export type Theme = {
	fg: (c: any, t: string) => string;
	bg: (c: any, t: string) => string;
	bold: (t: string) => string;
	italic: (t: string) => string;
	dim?: (t: string) => string;
};

export function renderToolCallLine(block: ToolBlock, theme: Theme): string {
	let input: Record<string, unknown> = {};
	try { input = JSON.parse(block.inputJson); } catch { /* ok */ }

	switch (block.name.toLowerCase()) {
		case "read": {
			const path   = shortenPath(String(input.file_path ?? input.path ?? ""));
			const offset = Number(input.offset ?? 1);
			const limit  = input.limit != null ? Number(input.limit) : undefined;
			const range  = limit != null ? `:${offset}-${offset + limit - 1}` : "";
			return theme.fg("muted", "read ") + theme.fg("accent", path) + theme.fg("warning", range);
		}
		case "bash": {
			const cmd = String(input.command ?? "").replace(/\n/g, " ↵ ");
			return theme.fg("muted", "$ ") + theme.fg("toolOutput", cmd);
		}
		case "edit": {
			const path = shortenPath(String(input.file_path ?? input.path ?? ""));
			return theme.fg("muted", "edit ") + theme.fg("accent", path);
		}
		case "write": {
			const path  = shortenPath(String(input.file_path ?? input.path ?? ""));
			const lines = String(input.content ?? "").split("\n").length;
			return theme.fg("muted", "write ") + theme.fg("accent", path) + theme.fg("dim", ` (${lines} lines)`);
		}
		case "glob": {
			const pat  = String(input.pattern ?? "");
			const path = input.path ? shortenPath(String(input.path)) : ".";
			return theme.fg("muted", "glob ") + theme.fg("accent", pat) + theme.fg("dim", ` in ${path}`);
		}
		case "grep": {
			const pat  = String(input.pattern ?? "");
			const path = input.path ? shortenPath(String(input.path)) : ".";
			return theme.fg("muted", "grep ") + theme.fg("accent", `"${pat}"`) + theme.fg("dim", ` in ${path}`);
		}
		case "mcp__pi__ask": {
			// Surfaces from the pi-ask-bridge MCP server. input is
			//   { questions: [{ id, question, options[], multi?, recommended? }, ...] }
			// Show the first question text inline; if there are more, append a count.
			const qs = Array.isArray(input.questions) ? (input.questions as any[]) : [];
			const first = qs[0];
			const head  = first?.question ? String(first.question) : "(empty)";
			const more  = qs.length > 1 ? ` (+${qs.length - 1} more)` : "";
			const tag   = first?.id ? ` [${first.id}]` : "";
			return theme.fg("muted", "ask ") + theme.fg("accent", head) + theme.fg("dim", tag + more);
		}
		default: {
			const desc = typeof input.description === "string" ? input.description
				: typeof input.prompt === "string" ? input.prompt.split("\n")[0]
				: block.inputJson;
			return theme.fg("toolTitle", block.name) + theme.fg("dim", " " + desc);
		}
	}
}

export function renderToolResultBox(block: ToolBlock, theme: Theme): Text {
	if (!block.result) return new Text("", 0, 0);

	let input: Record<string, unknown> = {};
	try { input = JSON.parse(block.inputJson); } catch { /* ok */ }

	// Strip / restyle synthetic envelopes like <tool_use_error> and
	// <system_reminder> before any tool-specific parsing.
	const text = transformSpecialTags(block.result.text);
	const { isError } = block.result;

	switch (block.name.toLowerCase()) {
		case "read": {
			const rawLines = text.split("\n").filter((l) => l.length > 0);
			const parsed   = rawLines.map((l) => {
				const tab = l.indexOf("\t");
				return tab >= 0 ? { num: l.slice(0, tab), content: l.slice(tab + 1) } : { num: "", content: l };
			});
			const maxNumLen = parsed.reduce((m, l) => Math.max(m, l.num.length), 0);
			return new Text(
				parsed.map((l) => theme.fg("dim", l.num.padStart(maxNumLen)) + " " + l.content).join("\n"),
				0, 0,
			);
		}
		case "edit": {
			if (isError) return new Text(text, 0, 0);
			const oldStr   = String(input.old_string ?? input.oldText ?? "");
			const newStr   = String(input.new_string ?? input.newText ?? "");
			if (!oldStr && !newStr) return new Text(text.slice(0, 200), 0, 0);
			const oldLines = oldStr === "" ? [] : oldStr.split("\n");
			const newLines = newStr === "" ? [] : newStr.split("\n");
			const ctx      = block.editContext;
			const startLine = ctx?.startLine ?? 1;
			const header   = theme.fg("dim", `@@ -${startLine},${oldLines.length} +${startLine},${newLines.length} @@`);
			const diff: string[] = [header];
			for (const l of ctx?.before ?? []) diff.push(theme.fg("dim", " " + l));
			for (const l of oldLines)          diff.push(theme.fg("toolDiffRemoved", "-" + l));
			for (const l of newLines)          diff.push(theme.fg("toolDiffAdded", "+" + l));
			for (const l of ctx?.after ?? [])  diff.push(theme.fg("dim", " " + l));
			return new Text(diff.join("\n"), 0, 0);
		}
		case "write": {
			if (isError) return new Text(text, 0, 0);
			const lines    = String(input.content ?? "").split("\n");
			const numWidth = String(lines.length).length;
			return new Text(
				lines.map((l, i) => theme.fg("dim", String(i + 1).padStart(numWidth)) + " " + l).join("\n"),
				0, 0,
			);
		}
		case "bash":
			return new Text(text.trimEnd(), 0, 0);
		case "mcp__pi__ask": {
			// The pi-ask-mcp server returns a JSON array of QuestionResults.
			// Pretty-print as one "id → answer" line per question instead of
			// dumping raw JSON into the result banner.
			if (isError) return new Text(text.trim(), 0, 0);
			let parsed: any;
			try { parsed = JSON.parse(text); } catch { return new Text(text.trim(), 0, 0); }
			if (!Array.isArray(parsed)) return new Text(text.trim(), 0, 0);
			const lines: string[] = [];
			for (const r of parsed) {
				const id     = theme.fg("accent", String(r?.id ?? "?"));
				const opts   = Array.isArray(r?.selectedOptions) ? r.selectedOptions : [];
				const custom = r?.customInput ? String(r.customInput) : "";
				const arrow  = theme.fg("dim", " → ");
				let answer: string;
				if (opts.length === 0 && !custom) {
					answer = theme.fg("warning", "(cancelled)");
				} else if (opts.length > 0 && custom) {
					answer = theme.fg("toolOutput", `[${opts.join(", ")}] + Other: "${custom}"`);
				} else if (custom) {
					answer = theme.fg("toolOutput", `Other: "${custom}"`);
				} else {
					answer = theme.fg("toolOutput", opts.length === 1 ? opts[0] : `[${opts.join(", ")}]`);
				}
				lines.push(id + arrow + answer);
			}
			return new Text(lines.join("\n"), 0, 0);
		}
		default:
			return new Text(text.trim(), 0, 0);
	}
}

/** Strip ANSI SGR sequences so we can re-style plain text. */
function stripAnsi(s: string): string {
	return s.replace(/\x1b\[[0-9;]*m/g, "");
}

// ---------------------------------------------------------------------------
// Special-tag transform — used for tool-result text that embeds synthetic
// envelopes like <tool_use_error> or <system_reminder>.
//
// IMPORTANT: all wrappers use PARTIAL resets (`\x1b[22;23;24;39m` — reset
// bold/dim/italic/underline and foreground only). If we emitted \x1b[0m in
// the middle of a line, it would also wipe any background that Box.bgFn
// painted around us, producing a staircase-shaped hole where the padding
// loses its colour. Partial resets leave the background intact.
// ---------------------------------------------------------------------------
const SGR_FG_RESET  = "\x1b[22;23;24;39m";
const SGR_BOLD_WHITE = "\x1b[1;97m";
const SGR_BOLD_RED   = "\x1b[1;91m";
const SGR_DIM_GREY   = "\x1b[2;90m";

function transformSpecialTags(text: string): string {
	// <tool_use_error>message</tool_use_error>
	//   → strip tags, display message in bright bold red
	text = text.replace(
		/<tool_use_error>\s*([\s\S]*?)\s*<\/tool_use_error>/g,
		(_, inner: string) => SGR_BOLD_RED + inner + SGR_FG_RESET,
	);
	// <system_reminder> or <system-reminder>
	//   → strip tags, collapse whitespace to one line, truncate at ~100 chars
	//     with a trailing "...", render in dim grey
	text = text.replace(
		/<system[-_]reminder>\s*([\s\S]*?)\s*<\/system[-_]reminder>/g,
		(_, inner: string) => {
			const oneLine = inner.replace(/\s+/g, " ").trim();
			const MAX = 100;
			const snippet = oneLine.length > MAX ? oneLine.slice(0, MAX).trimEnd() : oneLine;
			return SGR_DIM_GREY + snippet + "..." + SGR_FG_RESET;
		},
	);
	return text;
}

/**
 * Render a tool block as two stacked banners:
 *   1) header — bold-white title on an ORANGE background (matches the
 *      chat-claude outer border colour).
 *   2) result body (if any) — on the theme's pending / success / error bg.
 *
 * The header deliberately uses a different background from the result, so
 * the "staircase" where the banners have different widths is an intentional
 * stylistic cue, not a rendering glitch.
 */
const ORANGE_BG_FN = (s: string) => "\x1b[48;5;130m" + s + "\x1b[0m";

export function renderToolBlock(block: ToolBlock, theme: Theme): Container {
	const c = new Container();

	// ---- Header banner (orange) ----
	// Strip per-segment colours from renderToolCallLine so the title renders
	// uniformly bold-white over the orange background. Partial-reset at the
	// end keeps the orange bg alive for the trailing padding.
	const headerText = SGR_BOLD_WHITE + stripAnsi(renderToolCallLine(block, theme)) + SGR_FG_RESET;
	c.addChild(new Text(headerText, 2, 0, ORANGE_BG_FN));

	// ---- Result body ----
	if (block.result !== undefined) {
		const bgFn = block.result.isError
			? (s: string) => theme.bg("toolErrorBg", s)
			: (s: string) => theme.bg("toolSuccessBg", s);
		const box = new Box(2, 0, bgFn);
		box.addChild(renderToolResultBox(block, theme));
		c.addChild(box);
	}
	return c;
}

/** Build a unified diff string for an edit tool call. */
export function buildEditDiff(oldStr: string, newStr: string, contextLines = 3): string {
	const changes = diffLines(oldStr, newStr);
	if (changes.length === 1 && (changes[0].added || changes[0].removed)) {
		const oldCount = (oldStr ? oldStr.split("\n").length : 0);
		const newCount = (newStr ? newStr.split("\n").length : 0);
		const lines: string[] = [
			`@@ -1,${oldCount} +1,${newCount} @@`,
			...oldStr.split("\n").map((l) => "-" + l),
			...newStr.split("\n").map((l) => "+" + l),
		];
		return lines.join("\n");
	}

	const ctxLines: string[] = oldStr.split("\n");
	const hunks: { start: number; lines: string[] }[] = [];
	let currentHunk: { start: number; lines: string[] } | null = null;
	let lineIdx = 0;

	for (const change of changes) {
		const lines = change.value.endsWith("\n") ? change.value.slice(0, -1).split("\n") : change.value.split("\n");
		if (change.added) {
			if (!currentHunk) currentHunk = { start: lineIdx, lines: [] };
			for (const l of lines) currentHunk.lines.push("+" + l);
		} else if (change.removed) {
			if (!currentHunk) currentHunk = { start: lineIdx, lines: [] };
			for (const l of lines) currentHunk.lines.push("-" + l);
			lineIdx += lines.length;
		} else {
			for (const l of lines) {
				if (!currentHunk) { lineIdx++; continue; }
				currentHunk.lines.push(" " + l);
				if (currentHunk.lines.length >= contextLines * 2 + 1) {
					hunks.push(currentHunk);
					currentHunk = null;
				}
				lineIdx++;
			}
		}
	}
	if (currentHunk) hunks.push(currentHunk);

	if (hunks.length === 0) return "(no changes)";

	const merged: { start: number; lines: string[] }[] = [];
	for (const h of hunks) {
		if (merged.length === 0 || h.start <= merged[merged.length - 1].start + merged[merged.length - 1].lines.length) {
			if (merged.length > 0) {
				const prev = merged[merged.length - 1];
				prev.lines.push(...h.lines.slice(Math.max(0, prev.lines.length - (h.start - prev.start))));
			} else {
				merged.push({ ...h, lines: [...h.lines] });
			}
		} else {
			merged.push({ ...h, lines: [...h.lines] });
		}
	}

	const resultLines: string[] = [];
	for (const h of merged) {
		const removed = h.lines.filter((l) => l.startsWith("-")).length;
		const added = h.lines.filter((l) => l.startsWith("+")).length;
		const total = h.lines.length;
		resultLines.push(`@@ -${h.start},${removed + (total - removed - added)} +${h.start},${added + (total - removed - added)} @@`);
		resultLines.push(...h.lines);
	}
	return resultLines.join("\n");
}

// =============================================================================
// Error helpers
// =============================================================================

const ANTHROPIC_ERROR_MAP: [RegExp, string][] = [
	[/rate_limit/i,        "Rate limited by Anthropic — please try again in a few seconds."],
	[/invalid_api_key/i,   "Invalid Anthropic API key — check your ANTHROPIC_API_KEY."],
	[/permission_error/i,  "Permission denied — check your Anthropic account and API key."],
	[/not_found.*model/i,  "Model not found — the specified model is not available."],
	[/context_length/i,    "Context window exceeded — the input is too large for this model."],
	[/overloaded/i,        "Anthropic is overloaded — please retry shortly."],
	[/billing/i,           "Billing issue — check your Anthropic account status."],
	[/invalid_request/i,   "Invalid request — the prompt or parameters may be malformed."],
];

export function formatAnthropicError(raw: string, exitCode?: number): string {
	for (const [re, msg] of ANTHROPIC_ERROR_MAP) {
		if (re.test(raw)) return msg;
	}
	try {
		const parsed = JSON.parse(raw.trim());
		if (parsed.type === "error" && parsed.error?.message) {
			const em = parsed.error.message;
			for (const [re, msg] of ANTHROPIC_ERROR_MAP) {
				if (re.test(em)) return msg;
			}
			return em;
		}
	} catch { /* not JSON */ }
	for (const line of raw.split("\n")) {
		const trimmed = line.trim();
		if (!trimmed.startsWith("{")) continue;
		try {
			const parsed = JSON.parse(trimmed);
			if (parsed.type === "error" && parsed.error?.message) {
				const em = parsed.error.message;
				for (const [re, msg] of ANTHROPIC_ERROR_MAP) {
					if (re.test(em)) return msg;
				}
				return em;
			}
		} catch { /* skip */ }
	}
	if (raw.trim()) return raw.trim().slice(0, 500);
	return `claude process exited with code ${exitCode ?? "unknown"}`;
}

// =============================================================================
// Core: spawn claude CLI and stream all output
// =============================================================================

export interface RunClaudeOptions {
	model?: string;                    // default: "sonnet"
	agent?: string;                    // --agent <name> (from ~/.claude/agents/)
	sessionId?: string;                // --resume <id> (multi-turn)
	noSessionPersistence?: boolean;    // --no-session-persistence (ask-claude default)
	enrichEditDiffs?: boolean;         // enrich edit tool results with unified diffs
	mcpConfigPath?: string;            // --mcp-config <path> (e.g. pi-ask-bridge)
	disallowedTools?: string[];        // --disallowed-tools (e.g. ["AskUserQuestion"])
	// Extended-thinking effort level ("low"|"medium"|"high"|"xhigh"|"max").
	// REQUIRED for thinking blocks to appear: in `-p` / stream-json mode the
	// CLI does NOT honour the user's interactive `defaultThinkingLevel`
	// setting — thinking_delta events are only emitted when `--effort` is
	// passed explicitly. Pass "off" to leave the flag off entirely.
	effort?: string;
	extraEnv?: NodeJS.ProcessEnv;      // merged on top of process.env for the child
	cwd: string;
	signal?: AbortSignal;
	timeoutMs?: number;                // default: 15 min
	onUpdate: (partial: { blocks: StreamBlock[]; finalText: string }) => void;
}

export interface RunClaudeResult {
	finalText: string;
	blocks: StreamBlock[];
	sessionId: string;
	costUsd: number;
	inputTokens: number;
	outputTokens: number;
	cacheReadTokens: number;
	cacheWriteTokens: number;
}

export async function runClaude(prompt: string, opts: RunClaudeOptions): Promise<RunClaudeResult> {
	const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
	const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;

	return new Promise((resolve, reject) => {
		const args = ["-p", "--output-format", "stream-json", "--include-partial-messages"];

		// Session mode: resume takes priority, then agent, then plain model
		if (opts.sessionId) {
			args.push("--resume", opts.sessionId);
		} else if (opts.agent) {
			args.push("--agent", opts.agent);
			if (opts.model) args.push("--model", opts.model);
			if (opts.noSessionPersistence) args.push("--no-session-persistence");
		} else {
			args.push("--model", opts.model ?? "sonnet");
			if (opts.noSessionPersistence) args.push("--no-session-persistence");
		}

		// Extended-thinking effort — must be passed explicitly in -p mode;
		// the interactive `defaultThinkingLevel` setting does NOT apply here.
		// Callers pass "off" to suppress the flag (e.g. ask-claude where
		// raw speed matters more than thought traces).
		if (opts.effort && opts.effort !== "off") {
			args.push("--effort", opts.effort);
		}

		// MCP config (e.g. pi-ask-bridge for routing AskUserQuestion-style
		// requests through pi's native UI). Additive — does NOT pass
		// --strict-mcp-config, so the user's other configured MCP servers
		// (exa, sentry, …) remain available to Claude.
		if (opts.mcpConfigPath) {
			args.push("--mcp-config", opts.mcpConfigPath);
		}
		// Tool denylist — typically ["AskUserQuestion"] when the MCP
		// server is providing a replacement.
		if (opts.disallowedTools?.length) {
			args.push("--disallowed-tools", opts.disallowedTools.join(","));
		}

		const proc = spawn("claude", args, {
			stdio: ["pipe", "pipe", "pipe"],
			cwd: opts.cwd,
			env: opts.extraEnv ? { ...process.env, ...opts.extraEnv } : process.env,
		});

		try {
			proc.stdin.write(prompt, "utf8");
			proc.stdin.end();
		} catch (err) {
			if ((err as NodeJS.ErrnoException).code !== "EPIPE") reject(err);
		}

		let buffer = "";
		const blocks: StreamBlock[] = [];
		const pendingTools = new Map<number, { name: string; inputJson: string; id: string }>();

		let sessionId = "";
		let costUsd = 0, inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;

		const getOrCreateTextBlock = (): TextBlock => {
			const last = blocks[blocks.length - 1];
			if (last?.type === "text") return last;
			const b: TextBlock = { type: "text", text: "" };
			blocks.push(b);
			return b;
		};

		const getOrCreateThinkingBlock = (): ThinkingBlock => {
			const last = blocks[blocks.length - 1];
			if (last?.type === "thinking") return last;
			const b: ThinkingBlock = { type: "thinking", text: "" };
			blocks.push(b);
			return b;
		};

		const emit = () => {
			const finalText = blocks.filter((b): b is TextBlock => b.type === "text").map((b) => b.text).join("");
			opts.onUpdate({ blocks: [...blocks], finalText });
		};

		const processLine = (line: string) => {
			if (!line.trim()) return;
			let event: any;
			try { event = JSON.parse(line); } catch { return; }

			if (event.type === "stream_event") {
				const e = event.event as any;
				if (!e) return;

				if (e.type === "content_block_start") {
					const cb = e.content_block;
					if (cb?.type === "tool_use") {
						pendingTools.set(e.index as number, { name: cb.name, inputJson: "", id: cb.id });
					}
				} else if (e.type === "content_block_delta") {
					const d = e.delta as any;
					if (d?.type === "text_delta") {
						getOrCreateTextBlock().text += d.text as string;
						emit();
					} else if (d?.type === "thinking_delta") {
						getOrCreateThinkingBlock().text += d.thinking as string;
						emit();
					} else if (d?.type === "input_json_delta") {
						const tool = pendingTools.get(e.index as number);
						if (tool) tool.inputJson += d.partial_json as string ?? "";
					}
				} else if (e.type === "content_block_stop") {
					const tool = pendingTools.get(e.index as number);
					if (tool) {
						// Claude CLI's --include-partial-messages can emit an `assistant`
						// event with the completed tool_use BEFORE content_block_stop
						// arrives. That path already pushed a block with the same id;
						// update it in place instead of pushing a duplicate.
						const existing = blocks.find(
							(b): b is ToolBlock => b.type === "tool" && b.id === tool.id,
						);
						const target: ToolBlock = existing ?? {
							type: "tool", id: tool.id, name: tool.name, inputJson: tool.inputJson,
						};
						if (existing) {
							// Prefer the streamed inputJson (it's been accumulating and
							// matches what claude-code actually executed).
							existing.inputJson = tool.inputJson;
						}
						// For edit tools, read file context before the edit executes
						if (tool.name.toLowerCase() === "edit" && !target.editContext) {
							try {
								const inp = JSON.parse(tool.inputJson) as Record<string, unknown>;
								const fp     = String(inp.file_path ?? inp.path ?? "");
								const oldStr = String(inp.old_string ?? inp.oldText ?? "");
								if (fp && oldStr) {
									const fileLines = readFileSync(fp, "utf8").split("\n");
									const oldLines  = oldStr.split("\n");
									let startIdx = -1;
									outer: for (let i = 0; i <= fileLines.length - oldLines.length; i++) {
										for (let j = 0; j < oldLines.length; j++) {
											if (fileLines[i + j] !== oldLines[j]) continue outer;
										}
										startIdx = i;
										break;
									}
									if (startIdx >= 0) {
										target.editContext = {
											before: fileLines.slice(Math.max(0, startIdx - 3), startIdx),
											after:  fileLines.slice(startIdx + oldLines.length, startIdx + oldLines.length + 3),
											startLine: startIdx + 1,
										};
									}
								}
							} catch { /* ignore */ }
						}
						if (!existing) blocks.push(target);
						pendingTools.delete(e.index as number);
						emit();
					}
				} else if (e.type === "message_delta") {
					const u = e.usage as any;
					if (u) {
						inputTokens  = u.input_tokens ?? inputTokens;
						outputTokens = u.output_tokens ?? outputTokens;
						cacheReadTokens  = u.cache_read_input_tokens ?? cacheReadTokens;
						cacheWriteTokens = u.cache_creation_input_tokens ?? cacheWriteTokens;
					}
				}
			} else if (event.type === "user") {
				for (const c of (event.message?.content ?? []) as any[]) {
					if (c.type !== "tool_result") continue;
					// tool_result.content may be a plain string (typical for bash/read
					// output) or an array of {type,text}/{type,image} blocks. Handle both.
					let text: string;
					if (typeof c.content === "string") {
						text = c.content;
					} else if (Array.isArray(c.content)) {
						text = (c.content as any[])
							.filter((x) => x.type === "text")
							.map((x) => x.text as string)
							.join("\n");
					} else {
						text = "";
					}
					const toolId  = c.tool_use_id as string;
					const toolBlock = blocks.findLast((b): b is ToolBlock => b.type === "tool" && b.id === toolId);
					if (toolBlock && toolBlock.name.toLowerCase() === "edit" && !c.is_error && opts.enrichEditDiffs) {
						try {
							const inp = JSON.parse(toolBlock.inputJson) as Record<string, unknown>;
							const oldStr = String(inp.old_string ?? inp.oldText ?? "");
							const newStr = String(inp.new_string ?? inp.newText ?? "");
							if (oldStr || newStr) {
								const diff = buildEditDiff(oldStr, newStr);
								text = `${text}\n\n--- edit diff ---\n${diff}\n--- end diff ---`;
							}
						} catch { /* ignore */ }
					}
					if (toolBlock) toolBlock.result = { text, isError: c.is_error === true };
					emit();
				}
			} else if (event.type === "assistant") {
				const content: any[] = (event as any).message?.content ?? [];
				for (const cb of content) {
					if (cb.type !== "tool_use") continue;
					const exists = blocks.some((b): b is ToolBlock => b.type === "tool" && b.id === cb.id);
					if (!exists) blocks.push({ type: "tool", id: cb.id, name: cb.name, inputJson: JSON.stringify(cb.input ?? {}) });
				}
				emit();
			} else if (event.type === "result") {
				sessionId = event.session_id ?? "";
				costUsd   = event.total_cost_usd ?? 0;
				const u   = event.usage as any;
				if (u) {
					inputTokens  = u.input_tokens ?? inputTokens;
					outputTokens = u.output_tokens ?? outputTokens;
					cacheReadTokens  = u.cache_read_input_tokens ?? cacheReadTokens;
					cacheWriteTokens = u.cache_creation_input_tokens ?? cacheWriteTokens;
				}
			} else if (event.type === "error") {
				const errMsg = event.error?.message ?? event.message ?? JSON.stringify(event);
				reject(new Error(formatAnthropicError(errMsg)));
			}
		};

		proc.stdout.on("data", (data: Buffer) => {
			buffer += data.toString();
			const lines = buffer.split("\n");
			buffer = lines.pop() ?? "";
			for (const line of lines) processLine(line);
		});

		let stderrOutput = "";
		proc.stderr.on("data", (data: Buffer) => { stderrOutput += data.toString(); });

		proc.on("close", (code) => {
			clearTimeout(timeoutId);
			if (buffer.trim()) processLine(buffer);
			const finalText = blocks.filter((b): b is TextBlock => b.type === "text").map((b) => b.text).join("");
			if (code !== 0) {
				if (timeoutFired) {
					reject(new Error(`Claude timed out after ${timeoutMs / 1000}s — the process was killed. Consider using a simpler prompt or a faster model.`));
				} else {
					const errMsg = formatAnthropicError(stderrOutput.trim(), code);
					const detail = finalText ? ` (partial output: ${finalText.slice(0, 100)})` : "";
					reject(new Error(errMsg + detail));
				}
				return;
			}
			resolve({ finalText, blocks, sessionId, costUsd, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens });
		});

		proc.on("error", (err) => {
			if ((err as NodeJS.ErrnoException).code === "ENOENT") {
				reject(new Error("Claude CLI not found — install it with `npm install -g @anthropic-ai/claude-code`"));
			} else {
				reject(err);
			}
		});

		if (opts.signal) {
			const kill = () => {
				try { if (!proc.killed) proc.kill("SIGTERM"); } catch { /* ok */ }
				setTimeout(() => { try { if (!proc.killed) proc.kill("SIGKILL"); } catch { /* ok */ } }, 3000);
			};
			if (opts.signal.aborted) kill();
			else opts.signal.addEventListener("abort", kill, { once: true });
		}

		let timeoutFired = false;
		const timeoutId = setTimeout(() => {
			timeoutFired = true;
			try { proc.kill("SIGTERM"); } catch { /* ok */ }
			setTimeout(() => { try { if (!proc.killed) proc.kill("SIGKILL"); } catch { /* ok */ } }, 3000);
		}, timeoutMs);

		proc.prependListener("close", () => { clearTimeout(timeoutId); });
		proc.prependListener("error", () => { clearTimeout(timeoutId); });
	});
}

// =============================================================================
// RenderResult factory — produces the common result rendering for both extensions
// =============================================================================

export function renderClaudeResult(
	result: { details?: ClaudeDetails; isPartial?: boolean },
	theme: Theme,
	opts?: { showSession?: boolean }
): Container | Text {
	const d = result.details as ClaudeDetails | undefined;
	if (!d) return new Text(theme.fg("muted", "…"), 0, 0);

	const isDone = d.done && !result.isPartial;
	const icon   = isDone ? theme.fg("success", "✓ ") : theme.fg("warning", "⏳ ");
	const resume = d.isResume ? theme.fg("dim", " ↩") : "";
	const c      = new Container();

	c.addChild(new Text(icon + theme.fg("toolTitle", theme.bold(d.label)) + resume, 0, 0));

	for (const block of d.blocks ?? []) {
		if (block.type === "thinking" && block.text.trim()) {
			c.addChild(new Text(theme.fg("dim", theme.italic(block.text.trimEnd())), 0, 0));
		} else if (block.type === "tool") {
			c.addChild(renderToolBlock(block, theme));
		} else if (block.type === "text" && block.text.trim()) {
			c.addChild(new Spacer(1));
			if (isDone) {
				c.addChild(new Markdown(block.text.trim(), 0, 0, getMarkdownTheme()));
			} else {
				c.addChild(new Text(theme.fg("dim", block.text.trimEnd()), 0, 0));
			}
		}
	}

	if (isDone) {
		const usageLine = formatUsage(d);
		const parts: string[] = [];
		if (usageLine) parts.push(usageLine);
		if (opts?.showSession && d.sessionId) parts.push(`session:${d.sessionId.slice(0, 8)}`);
		if (parts.length > 0) {
			c.addChild(new Spacer(1));
			c.addChild(new Text(theme.fg("dim", parts.join("  ")), 0, 0));
		}
	}

	return c;
}
