import type * as fs from "node:fs";
import * as path from "node:path";
import type {
	AgentTool,
	AgentToolContext,
	AgentToolResult,
	AgentToolUpdateCallback,
} from "@f5-sales-demo/pi-agent-core";
import type { ImageContent } from "@f5-sales-demo/pi-ai";
import type { Component } from "@f5-sales-demo/pi-tui";
import { Markdown, Text } from "@f5-sales-demo/pi-tui";
import { getProjectDir, prompt } from "@f5-sales-demo/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { executePython, getPreludeDocs, type PythonExecutorOptions, warmPythonEnvironment } from "../ipy/executor";
import type { PreludeHelper, PythonStatusEvent } from "../ipy/kernel";
import { truncateToVisualLines } from "../modes/components/visual-truncate";
import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
import pythonDescription from "../prompts/tools/python.md" with { type: "text" };
import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output";
import { getTreeBranch, getTreeContinuePrefix, renderCodeCell } from "../tui";
import type { ToolSession } from ".";
import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
import { resolveToCwd } from "./path-utils";
import { formatTitle, replaceTabs, shortenPath, truncateToWidth, wrapBrackets } from "./render-utils";
import { ToolAbortError, ToolError } from "./tool-errors";
import { toolResult } from "./tool-result";
import { clampTimeout } from "./tool-timeouts";

export const PYTHON_DEFAULT_PREVIEW_LINES = 10;

type PreludeCategory = {
	name: string;
	functions: PreludeHelper[];
};

function groupPreludeHelpers(helpers: PreludeHelper[]): PreludeCategory[] {
	const categories: PreludeCategory[] = [];
	const byName = new Map<string, PreludeHelper[]>();
	for (const helper of helpers) {
		let bucket = byName.get(helper.category);
		if (!bucket) {
			bucket = [];
			byName.set(helper.category, bucket);
			categories.push({ name: helper.category, functions: bucket });
		}
		bucket.push(helper);
	}
	return categories;
}

export const pythonSchema = Type.Object({
	cells: Type.Array(
		Type.Object({
			code: Type.String({ description: "Python code to execute" }),
			title: Type.Optional(Type.String({ description: "Cell label, e.g. 'imports', 'helper'" })),
		}),
		{ description: "Cells to execute sequentially in persistent kernel" },
	),
	timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (default: 30)" })),
	cwd: Type.Optional(Type.String({ description: "Working directory (default: cwd)" })),
	reset: Type.Optional(Type.Boolean({ description: "Restart kernel before execution" })),
});
export type PythonToolParams = Static<typeof pythonSchema>;

export type PythonToolResult = {
	content: Array<{ type: "text"; text: string }>;
	details: PythonToolDetails | undefined;
};

export type PythonProxyExecutor = (params: PythonToolParams, signal?: AbortSignal) => Promise<PythonToolResult>;

export interface PythonCellResult {
	index: number;
	title?: string;
	code: string;
	output: string;
	status: "pending" | "running" | "complete" | "error";
	durationMs?: number;
	exitCode?: number;
	statusEvents?: PythonStatusEvent[];
	hasMarkdown?: boolean;
}

export interface PythonToolDetails {
	cells?: PythonCellResult[];
	jsonOutputs?: unknown[];
	images?: ImageContent[];
	/** Structured status events from prelude helpers */
	statusEvents?: PythonStatusEvent[];
	isError?: boolean;
	/** Structured output metadata for notices */
	meta?: OutputMeta;
}

function formatJsonScalar(value: unknown): string {
	if (value === null) return "null";
	if (value === undefined) return "undefined";
	if (typeof value === "string") return JSON.stringify(value);
	if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
	if (typeof value === "function") return "[function]";
	return "[object]";
}

function renderJsonTree(value: unknown, theme: Theme, expanded: boolean, maxDepth = expanded ? 6 : 2): string[] {
	const maxItems = expanded ? 20 : 5;

	const renderNode = (node: unknown, prefix: string, depth: number, isLast: boolean, label?: string): string[] => {
		const branch = getTreeBranch(isLast, theme);
		const displayLabel = label ? `${label}: ` : "";

		if (depth >= maxDepth || node === null || typeof node !== "object") {
			return [`${prefix}${branch} ${displayLabel}${formatJsonScalar(node)}`];
		}

		const isArray = Array.isArray(node);
		const entries = isArray
			? node.map((val, index) => [String(index), val] as const)
			: Object.entries(node as object);
		const header = `${prefix}${branch} ${displayLabel}${isArray ? `Array(${entries.length})` : `Object(${entries.length})`}`;
		const lines = [header];

		const childPrefix = prefix + getTreeContinuePrefix(isLast, theme);
		const visible = entries.slice(0, maxItems);
		for (let i = 0; i < visible.length; i++) {
			const [key, val] = visible[i];
			const childLast = i === visible.length - 1 && (expanded || entries.length <= maxItems);
			lines.push(...renderNode(val, childPrefix, depth + 1, childLast, isArray ? `[${key}]` : key));
		}
		if (!expanded && entries.length > maxItems) {
			const moreBranch = theme.tree.last;
			lines.push(`${childPrefix}${moreBranch} ${entries.length - maxItems} more item(s)`);
		}
		return lines;
	};

	return renderNode(value, "", 0, true);
}

export function getPythonToolDescription(): string {
	const helpers = getPreludeDocs();
	const categories = groupPreludeHelpers(helpers);
	return prompt.render(pythonDescription, { categories });
}

export interface PythonToolOptions {
	proxyExecutor?: PythonProxyExecutor;
}

export class PythonTool implements AgentTool<typeof pythonSchema> {
	readonly name = "python";
	readonly label = "Python";
	get description(): string {
		return getPythonToolDescription();
	}
	readonly parameters = pythonSchema;
	readonly concurrency = "exclusive";
	readonly strict = true;

	readonly #proxyExecutor?: PythonProxyExecutor;

	constructor(
		private readonly session: ToolSession | null,
		options?: PythonToolOptions,
	) {
		this.#proxyExecutor = options?.proxyExecutor;
	}

	async execute(
		_toolCallId: string,
		params: Static<typeof pythonSchema>,
		signal?: AbortSignal,
		onUpdate?: AgentToolUpdateCallback,
		_ctx?: AgentToolContext,
	): Promise<AgentToolResult<PythonToolDetails | undefined>> {
		if (this.#proxyExecutor) {
			return this.#proxyExecutor(params, signal);
		}

		if (!this.session) {
			throw new ToolError("Python tool requires a session when not using proxy executor");
		}

		// Reject early if session disposal has already started
		this.session.assertPythonExecutionAllowed?.();

		const { cells, timeout: rawTimeout = 30, cwd, reset } = params;
		// Clamp to reasonable range: 1s - 600s (10 min)
		const timeoutSec = clampTimeout("python", rawTimeout);
		const timeoutMs = timeoutSec * 1000;
		const deadlineMs = Date.now() + timeoutMs;
		const timeoutSignal = AbortSignal.timeout(Math.max(0, deadlineMs - Date.now()));
		const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
		let outputSink: OutputSink | undefined;
		let outputSummary: OutputSummary | undefined;
		let outputDumped = false;
		const finalizeOutput = async (): Promise<OutputSummary | undefined> => {
			if (outputDumped || !outputSink) return outputSummary;
			outputSummary = await outputSink.dump();
			outputDumped = true;
			return outputSummary;
		};

		try {
			if (signal?.aborted) {
				throw new ToolAbortError();
			}

			const commandCwd = cwd ? resolveToCwd(cwd, this.session.cwd) : this.session.cwd;
			let cwdStat: fs.Stats;
			try {
				cwdStat = await Bun.file(commandCwd).stat();
			} catch {
				throw new ToolError(`Working directory does not exist: ${commandCwd}`);
			}
			if (!cwdStat.isDirectory()) {
				throw new ToolError(`Working directory is not a directory: ${commandCwd}`);
			}

			const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES * 2);
			const jsonOutputs: unknown[] = [];
			const images: ImageContent[] = [];
			const statusEvents: PythonStatusEvent[] = [];

			const cellResults: PythonCellResult[] = cells.map((cell, index) => ({
				index,
				title: cell.title,
				code: cell.code,
				output: "",
				status: "pending",
			}));
			const cellOutputs: string[] = [];

			const appendTail = (text: string) => {
				tailBuffer.append(text);
			};

			const buildUpdateDetails = (): PythonToolDetails => {
				const details: PythonToolDetails = {
					cells: cellResults.map(cell => ({
						...cell,
						statusEvents: cell.statusEvents ? [...cell.statusEvents] : undefined,
					})),
				};
				if (jsonOutputs.length > 0) {
					details.jsonOutputs = jsonOutputs;
				}
				if (images.length > 0) {
					details.images = images;
				}
				if (statusEvents.length > 0) {
					details.statusEvents = statusEvents;
				}
				return details;
			};

			const pushUpdate = () => {
				if (!onUpdate) return;
				const obfuscator = this.session?.obfuscator;
				const rawTail = tailBuffer.text();
				const tailText = obfuscator?.hasSecrets() ? obfuscator.obfuscate(rawTail) : rawTail;
				onUpdate({
					content: [{ type: "text", text: tailText }],
					details: buildUpdateDetails(),
				});
			};

			const sessionFile = this.session.getSessionFile?.() ?? undefined;
			const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("python")) ?? {};

			// Re-check disposal after async artifact allocation — dispose may have started while awaiting
			this.session.assertPythonExecutionAllowed?.();

			outputSink = new OutputSink({
				artifactPath,
				artifactId,
				maskSecrets: this.session?.obfuscator?.hasSecrets()
					? t => this.session!.obfuscator!.obfuscate(t)
					: undefined,
				onChunk: chunk => {
					appendTail(chunk);
					pushUpdate();
				},
			});
			const sessionId = sessionFile ? `session:${sessionFile}:cwd:${commandCwd}` : `cwd:${commandCwd}`;

			const kernelOwnerId = this.session.getPythonKernelOwnerId?.() ?? undefined;

			if (getPreludeDocs().length === 0) {
				const warmupAbortController = new AbortController();
				const warmupSignal = AbortSignal.any([combinedSignal, warmupAbortController.signal]);
				const warmupExecution = warmPythonEnvironment(
					commandCwd,
					sessionId,
					this.session.settings.get("python.sharedGateway"),
					sessionFile ?? undefined,
					kernelOwnerId,
					warmupSignal,
				);
				const warmup = await (this.session.trackPythonExecution
					? this.session.trackPythonExecution(warmupExecution, warmupAbortController)
					: warmupExecution);
				if (!warmup.ok) {
					// If the abort signal fired, throw ToolAbortError for consistent abort handling
					if (warmupSignal.aborted) {
						throw new ToolAbortError();
					}
					throw new ToolError(warmup.reason ?? "Python prelude helpers unavailable");
				}
			}

			// After warmup, re-check disposal state — dispose may have started while warmup was in progress
			this.session.assertPythonExecutionAllowed?.();

			const baseExecutorOptions: Omit<PythonExecutorOptions, "reset" | "signal"> = {
				cwd: commandCwd,
				deadlineMs,
				sessionId,
				kernelOwnerId,
				maskSecrets: this.session?.obfuscator?.hasSecrets()
					? t => this.session!.obfuscator!.obfuscate(t)
					: undefined,
				kernelMode: this.session.settings.get("python.kernelMode"),
				useSharedGateway: this.session.settings.get("python.sharedGateway"),
				sessionFile: sessionFile ?? undefined,
			};

			for (let i = 0; i < cells.length; i++) {
				const cell = cells[i];
				const isFirstCell = i === 0;
				const cellResult = cellResults[i];
				cellResult.status = "running";
				cellResult.output = "";
				cellResult.statusEvents = undefined;
				cellResult.exitCode = undefined;
				cellResult.durationMs = undefined;
				pushUpdate();

				// Create abort controller first so trackPythonExecution can abort this specific execution
				const abortController = new AbortController();
				const executionSignal = AbortSignal.any([combinedSignal, abortController.signal]);

				const executorOptions: PythonExecutorOptions = {
					...baseExecutorOptions,
					signal: executionSignal,
					reset: isFirstCell ? reset : false,
					onChunk: chunk => {
						outputSink!.push(chunk);
					},
				};

				const startTime = Date.now();
				const executionPromise = executePython(cell.code, executorOptions);
				const result = await (this.session.trackPythonExecution
					? this.session.trackPythonExecution(executionPromise, abortController)
					: executionPromise);
				const durationMs = Date.now() - startTime;

				const cellStatusEvents: PythonStatusEvent[] = [];
				let cellHasMarkdown = false;
				for (const output of result.displayOutputs) {
					if (output.type === "json") {
						jsonOutputs.push(output.data);
					}
					if (output.type === "image") {
						images.push({ type: "image", data: output.data, mimeType: output.mimeType });
					}
					if (output.type === "status") {
						statusEvents.push(output.event);
						cellStatusEvents.push(output.event);
					}
					if (output.type === "markdown") {
						cellHasMarkdown = true;
					}
				}

				const cellOutput = result.output.trim();
				cellResult.output = cellOutput;
				cellResult.exitCode = result.exitCode;
				cellResult.durationMs = durationMs;
				cellResult.statusEvents = cellStatusEvents.length > 0 ? cellStatusEvents : undefined;
				cellResult.hasMarkdown = cellHasMarkdown || undefined;

				let combinedCellOutput = "";
				if (cells.length > 1) {
					const cellHeader = `[${i + 1}/${cells.length}]`;
					const cellTitle = cell.title ? ` ${cell.title}` : "";
					if (cellOutput) {
						combinedCellOutput = `${cellHeader}${cellTitle}\n${cellOutput}`;
					} else {
						combinedCellOutput = `${cellHeader}${cellTitle} (ok)`;
					}
					cellOutputs.push(combinedCellOutput);
				} else if (cellOutput) {
					combinedCellOutput = cellOutput;
					cellOutputs.push(combinedCellOutput);
				}

				if (combinedCellOutput) {
					const prefix = cellOutputs.length > 1 ? "\n\n" : "";
					appendTail(`${prefix}${combinedCellOutput}`);
				}

				if (result.cancelled) {
					cellResult.status = "error";
					pushUpdate();
					const errorMsg = result.output || "Command aborted";
					const combinedOutput = cellOutputs.join("\n\n");
					const outputText =
						cells.length > 1
							? `${combinedOutput}\n\nCell ${i + 1} aborted: ${errorMsg}`
							: combinedOutput || errorMsg;

					const rawSummary = (await finalizeOutput()) ?? {
						output: "",
						truncated: false,
						totalLines: 0,
						totalBytes: 0,
						outputLines: 0,
						outputBytes: 0,
					};
					const outputLines = combinedOutput.length > 0 ? combinedOutput.split("\n").length : 0;
					const outputBytes = Buffer.byteLength(combinedOutput, "utf-8");
					const missingLines = Math.max(0, rawSummary.totalLines - rawSummary.outputLines);
					const missingBytes = Math.max(0, rawSummary.totalBytes - rawSummary.outputBytes);
					const summaryForMeta: OutputSummary = {
						output: combinedOutput,
						truncated: rawSummary.truncated,
						totalLines: outputLines + missingLines,
						totalBytes: outputBytes + missingBytes,
						outputLines,
						outputBytes,
						artifactId: rawSummary.artifactId,
					};

					const details: PythonToolDetails = {
						cells: cellResults,
						jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
						images: images.length > 0 ? images : undefined,
						statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
						isError: true,
					};

					return toolResult(details)
						.text(outputText)
						.truncationFromSummary(summaryForMeta, { direction: "tail" })
						.done();
				}

				if (result.exitCode !== 0 && result.exitCode !== undefined) {
					cellResult.status = "error";
					pushUpdate();
					const combinedOutput = cellOutputs.join("\n\n");
					const outputText =
						cells.length > 1
							? `${combinedOutput}\n\nCell ${i + 1} failed (exit code ${result.exitCode}). Earlier cells succeeded—their state persists. Fix only cell ${i + 1}.`
							: combinedOutput
								? `${combinedOutput}\n\nCommand exited with code ${result.exitCode}`
								: `Command exited with code ${result.exitCode}`;

					const rawSummary = (await finalizeOutput()) ?? {
						output: "",
						truncated: false,
						totalLines: 0,
						totalBytes: 0,
						outputLines: 0,
						outputBytes: 0,
					};
					const outputLines = combinedOutput.length > 0 ? combinedOutput.split("\n").length : 0;
					const outputBytes = Buffer.byteLength(combinedOutput, "utf-8");
					const missingLines = Math.max(0, rawSummary.totalLines - rawSummary.outputLines);
					const missingBytes = Math.max(0, rawSummary.totalBytes - rawSummary.outputBytes);
					const summaryForMeta: OutputSummary = {
						output: combinedOutput,
						truncated: rawSummary.truncated,
						totalLines: outputLines + missingLines,
						totalBytes: outputBytes + missingBytes,
						outputLines,
						outputBytes,
						artifactId: rawSummary.artifactId,
					};

					const details: PythonToolDetails = {
						cells: cellResults,
						jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
						images: images.length > 0 ? images : undefined,
						statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
						isError: true,
					};

					return toolResult(details)
						.text(outputText)
						.truncationFromSummary(summaryForMeta, { direction: "tail" })
						.done();
				}

				cellResult.status = "complete";
				pushUpdate();
			}

			const combinedOutput = cellOutputs.join("\n\n");
			const outputText =
				combinedOutput || (jsonOutputs.length > 0 || images.length > 0 ? "(no text output)" : "(no output)");
			const rawSummary = (await finalizeOutput()) ?? {
				output: "",
				truncated: false,
				totalLines: 0,
				totalBytes: 0,
				outputLines: 0,
				outputBytes: 0,
			};
			const outputLines = combinedOutput.length > 0 ? combinedOutput.split("\n").length : 0;
			const outputBytes = Buffer.byteLength(combinedOutput, "utf-8");
			const missingLines = Math.max(0, rawSummary.totalLines - rawSummary.outputLines);
			const missingBytes = Math.max(0, rawSummary.totalBytes - rawSummary.outputBytes);
			const summaryForMeta: OutputSummary = {
				output: combinedOutput,
				truncated: rawSummary.truncated,
				totalLines: outputLines + missingLines,
				totalBytes: outputBytes + missingBytes,
				outputLines,
				outputBytes,
				artifactId: rawSummary.artifactId,
			};

			const details: PythonToolDetails = {
				cells: cellResults,
				jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
				images: images.length > 0 ? images : undefined,
				statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
			};

			const resultBuilder = toolResult(details)
				.text(outputText)
				.truncationFromSummary(summaryForMeta, { direction: "tail" });

			return resultBuilder.done();
		} finally {
			if (!outputDumped) {
				try {
					await finalizeOutput();
				} catch {}
			}
		}
	}
}

interface PythonRenderArgs {
	cells?: Array<{ code: string; title?: string }>;
	timeout?: number;
	cwd?: string;
}

interface PythonRenderContext {
	output?: string;
	expanded?: boolean;
	previewLines?: number;
	timeout?: number;
}

/** Format a status event as a single line for display. */
function formatStatusEvent(event: PythonStatusEvent, theme: Theme, width: number): string {
	const { op, ...data } = event;

	// Map operations to available theme icons
	type AvailableIcon = "icon.file" | "icon.folder" | "icon.git" | "icon.package";
	const opIcons: Record<string, AvailableIcon> = {
		// File I/O
		read: "icon.file",
		write: "icon.file",
		append: "icon.file",
		cat: "icon.file",
		touch: "icon.file",
		lines: "icon.file",
		// Navigation/Directory
		ls: "icon.folder",
		cd: "icon.folder",
		pwd: "icon.folder",
		mkdir: "icon.folder",
		tree: "icon.folder",
		stat: "icon.folder",
		// Search (use file icon since no search icon)
		find: "icon.file",
		grep: "icon.file",
		rgrep: "icon.file",
		glob: "icon.file",
		// Edit operations (use file icon)
		replace: "icon.file",
		sed: "icon.file",
		rsed: "icon.file",
		delete_lines: "icon.file",
		delete_matching: "icon.file",
		insert_at: "icon.file",
		// Git
		git_status: "icon.git",
		git_diff: "icon.git",
		git_log: "icon.git",
		git_show: "icon.git",
		git_branch: "icon.git",
		git_file_at: "icon.git",
		git_has_changes: "icon.git",
		// Shell/batch (use package icon)
		run: "icon.package",
		sh: "icon.package",
		env: "icon.package",
		batch: "icon.package",
	};

	const iconKey = opIcons[op] ?? "icon.file";
	const icon = theme.styledSymbol(iconKey, "muted");

	// Format the status message based on operation type
	const parts: string[] = [];

	// Error handling
	if (data.error) {
		return `${icon} ${theme.fg("warning", op)}: ${theme.fg("dim", String(data.error))}`;
	}

	// Build description based on common fields
	switch (op) {
		case "read":
			parts.push(`${data.chars} chars`);
			if (data.path) parts.push(`from ${shortenPath(String(data.path))}`);
			break;
		case "write":
		case "append":
			parts.push(`${data.chars} chars`);
			if (data.path) parts.push(`to ${shortenPath(String(data.path))}`);
			break;
		case "cat":
			parts.push(`${data.files} file${(data.files as number) !== 1 ? "s" : ""}`);
			parts.push(`${data.chars} chars`);
			break;
		case "find":
		case "glob":
			parts.push(`${data.count} match${(data.count as number) !== 1 ? "es" : ""}`);
			if (data.pattern)
				parts.push(`for "${truncateToWidth(String(data.pattern), Math.max(15, Math.floor(width * 0.15)))}"`);
			break;
		case "grep":
			parts.push(`${data.count} match${(data.count as number) !== 1 ? "es" : ""}`);
			if (data.path) parts.push(`in ${shortenPath(String(data.path))}`);
			break;
		case "rgrep":
			parts.push(`${data.count} match${(data.count as number) !== 1 ? "es" : ""}`);
			if (data.pattern)
				parts.push(`for "${truncateToWidth(String(data.pattern), Math.max(15, Math.floor(width * 0.15)))}"`);
			break;
		case "ls":
			parts.push(`${data.count} entr${(data.count as number) !== 1 ? "ies" : "y"}`);
			break;
		case "env":
			if (data.action === "set") {
				parts.push(
					`set ${data.key}=${truncateToWidth(String(data.value ?? ""), Math.max(15, Math.floor(width * 0.2)))}`,
				);
			} else if (data.action === "get") {
				parts.push(
					`${data.key}=${truncateToWidth(String(data.value ?? ""), Math.max(15, Math.floor(width * 0.2)))}`,
				);
			} else {
				parts.push(`${data.count} variable${(data.count as number) !== 1 ? "s" : ""}`);
			}
			break;
		case "stat":
			if (data.is_dir) {
				parts.push("directory");
			} else {
				parts.push(`${data.size} bytes`);
			}
			if (data.path) parts.push(shortenPath(String(data.path)));
			break;
		case "replace":
		case "sed":
			parts.push(`${data.count} replacement${(data.count as number) !== 1 ? "s" : ""}`);
			if (data.path) parts.push(`in ${shortenPath(String(data.path))}`);
			break;
		case "rsed":
			parts.push(`${data.count} replacement${(data.count as number) !== 1 ? "s" : ""}`);
			if (data.files) parts.push(`in ${data.files} file${(data.files as number) !== 1 ? "s" : ""}`);
			break;
		case "git_status":
			if (data.clean) {
				parts.push("clean");
			} else {
				const statusParts: string[] = [];
				if (data.staged) statusParts.push(`${data.staged} staged`);
				if (data.modified) statusParts.push(`${data.modified} modified`);
				if (data.untracked) statusParts.push(`${data.untracked} untracked`);
				parts.push(statusParts.join(", ") || "unknown");
			}
			if (data.branch) parts.push(`on ${data.branch}`);
			break;
		case "git_log":
			parts.push(`${data.commits} commit${(data.commits as number) !== 1 ? "s" : ""}`);
			break;
		case "git_diff":
			parts.push(`${data.lines} line${(data.lines as number) !== 1 ? "s" : ""}`);
			if (data.staged) parts.push("(staged)");
			break;
		case "diff":
			if (data.identical) {
				parts.push("files identical");
			} else {
				parts.push("files differ");
			}
			break;
		case "batch":
			parts.push(`${data.files} file${(data.files as number) !== 1 ? "s" : ""} processed`);
			break;
		case "wc":
			parts.push(`${data.lines}L ${data.words}W ${data.chars}C`);
			break;
		case "lines":
			parts.push(`${data.count} line${(data.count as number) !== 1 ? "s" : ""}`);
			if (data.start && data.end) parts.push(`(${data.start}-${data.end})`);
			break;
		case "delete_lines":
		case "delete_matching":
			parts.push(`${data.count} line${(data.count as number) !== 1 ? "s" : ""} deleted`);
			break;
		case "insert_at":
			parts.push(`${data.lines_inserted} line${(data.lines_inserted as number) !== 1 ? "s" : ""} inserted`);
			break;
		case "cd":
		case "pwd":
		case "mkdir":
		case "touch":
			if (data.path) parts.push(shortenPath(String(data.path)));
			break;
		case "rm":
		case "mv":
		case "cp":
			if (data.src) parts.push(`${shortenPath(String(data.src))} → ${shortenPath(String(data.dst))}`);
			else if (data.path) parts.push(shortenPath(String(data.path)));
			break;
		default:
			// Generic formatting for other operations
			if (data.count !== undefined) {
				parts.push(String(data.count));
			}
			if (data.path) {
				parts.push(shortenPath(String(data.path)));
			}
	}

	const desc = parts.length > 0 ? parts.join(" · ") : "";
	return `${icon} ${theme.fg("muted", op)}${desc ? ` ${theme.fg("dim", desc)}` : ""}`;
}

/** Format status event with expanded detail lines. */
function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme, width: number): string[] {
	const lines: string[] = [];
	const { op, ...data } = event;

	// Main status line
	lines.push(formatStatusEvent(event, theme, width));

	// Add detail lines for operations with list data
	const addItems = (items: unknown[], formatter: (item: unknown) => string, max = 5) => {
		const arr = Array.isArray(items) ? items : [];
		for (let i = 0; i < Math.min(arr.length, max); i++) {
			lines.push(`   ${theme.fg("dim", formatter(arr[i]))}`);
		}
		if (arr.length > max) {
			lines.push(`   ${theme.fg("dim", `… ${arr.length - max} more`)}`);
		}
	};

	// Add preview lines (truncated content)
	const addPreview = (preview: string, maxLines = 3) => {
		const previewLines = String(preview).split("\n").slice(0, maxLines);
		for (const line of previewLines) {
			lines.push(`   ${theme.fg("toolOutput", truncateToWidth(replaceTabs(line), width))}`);
		}
		const totalLines = String(preview).split("\n").length;
		if (totalLines > maxLines) {
			lines.push(`   ${theme.fg("dim", `… ${totalLines - maxLines} more lines`)}`);
		}
	};

	switch (op) {
		case "find":
		case "glob":
			if (data.matches) addItems(data.matches as unknown[], m => String(m));
			break;
		case "ls":
			if (data.items) addItems(data.items as unknown[], m => String(m));
			break;
		case "grep":
			if (data.hits) {
				addItems(data.hits as unknown[], h => {
					const hit = h as { line: number; text: string };
					return `${hit.line}: ${truncateToWidth(hit.text, width)}`;
				});
			}
			break;
		case "rgrep":
			if (data.hits) {
				addItems(data.hits as unknown[], h => {
					const hit = h as { file: string; line: number; text: string };
					return `${shortenPath(hit.file)}:${hit.line}: ${truncateToWidth(hit.text, width)}`;
				});
			}
			break;
		case "rsed":
			if (data.changed) {
				addItems(data.changed as unknown[], c => {
					const change = c as { file: string; count: number };
					return `${shortenPath(change.file)}: ${change.count} replacement${change.count !== 1 ? "s" : ""}`;
				});
			}
			break;
		case "env":
			if (data.keys) addItems(data.keys as unknown[], k => String(k), 10);
			break;
		case "git_log":
			if (data.entries) {
				addItems(data.entries as unknown[], e => {
					const entry = e as { sha: string; subject: string };
					return `${entry.sha} ${truncateToWidth(entry.subject, width)}`;
				});
			}
			break;
		case "git_status":
			if (data.files) addItems(data.files as unknown[], f => String(f));
			break;
		case "git_branch":
			if (data.branches) addItems(data.branches as unknown[], b => String(b));
			break;
		case "read":
		case "cat":
		case "head":
		case "tail":
		case "tree":
		case "diff":
		case "lines":
		case "git_diff":
		case "sh":
			if (data.preview) addPreview(String(data.preview));
			break;
	}

	return lines;
}

/** Render status events as tree lines. */
function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded: boolean, width: number): string[] {
	if (events.length === 0) return [];

	const maxCollapsed = 3;
	const maxExpanded = 10;
	const displayCount = expanded ? Math.min(events.length, maxExpanded) : Math.min(events.length, maxCollapsed);

	const lines: string[] = [];
	for (let i = 0; i < displayCount; i++) {
		const isLast = i === displayCount - 1 && (expanded || events.length <= maxCollapsed);
		const branch = isLast ? theme.tree.last : theme.tree.branch;

		if (expanded) {
			// Show expanded details for each event
			const eventLines = formatStatusEventExpanded(events[i], theme, width);
			lines.push(`${theme.fg("dim", branch)} ${eventLines[0]}`);
			const continueBranch = isLast ? "   " : `${theme.tree.vertical}  `;
			for (let j = 1; j < eventLines.length; j++) {
				lines.push(`${theme.fg("dim", continueBranch)}${eventLines[j]}`);
			}
		} else {
			lines.push(`${theme.fg("dim", branch)} ${formatStatusEvent(events[i], theme, width)}`);
		}
	}

	if (!expanded && events.length > maxCollapsed) {
		lines.push(`${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", `… ${events.length - maxCollapsed} more`)}`);
	} else if (expanded && events.length > maxExpanded) {
		lines.push(`${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", `… ${events.length - maxExpanded} more`)}`);
	}

	return lines;
}

function formatCellOutputLines(
	cell: PythonCellResult,
	expanded: boolean,
	previewLines: number,
	theme: Theme,
	width: number,
): { lines: string[]; hiddenCount: number } {
	if (!cell.output) {
		return { lines: [], hiddenCount: 0 };
	}

	if (cell.hasMarkdown && cell.status !== "error") {
		const md = new Markdown(cell.output, 0, 0, getMarkdownTheme());
		const allLines = md.render(width);
		const displayLines = expanded ? allLines : allLines.slice(-previewLines);
		const hiddenCount = allLines.length - displayLines.length;
		return { lines: displayLines, hiddenCount };
	}

	const rawLines = cell.output.split("\n");
	const displayLines = expanded ? rawLines : rawLines.slice(-previewLines);
	const hiddenCount = rawLines.length - displayLines.length;
	const outputLines = displayLines.map(line => {
		const cleaned = replaceTabs(line);
		return cell.status === "error" ? theme.fg("error", cleaned) : theme.fg("toolOutput", cleaned);
	});

	return { lines: outputLines, hiddenCount };
}

export const pythonToolRenderer = {
	renderCall(args: PythonRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
		const cells = args.cells ?? [];
		const cwd = getProjectDir();
		let displayWorkdir = args.cwd;

		if (displayWorkdir) {
			const resolvedCwd = path.resolve(cwd);
			const resolvedWorkdir = path.resolve(displayWorkdir);
			if (resolvedWorkdir === resolvedCwd) {
				displayWorkdir = undefined;
			} else {
				const relativePath = path.relative(resolvedCwd, resolvedWorkdir);
				const isWithinCwd =
					relativePath && !relativePath.startsWith("..") && !relativePath.startsWith(`..${path.sep}`);
				if (isWithinCwd) {
					displayWorkdir = relativePath;
				}
			}
		}

		const workdirLabel = displayWorkdir ? `cd ${displayWorkdir}` : undefined;
		if (cells.length === 0) {
			const prompt = uiTheme.fg("contentAccent", ">>>");
			const prefix = workdirLabel ? `${uiTheme.fg("dim", `${workdirLabel} && `)}` : "";
			const text = formatTitle(`${prompt} ${prefix}…`, uiTheme);
			return new Text(text, 0, 0);
		}

		// Cache state - cells don't change, only width varies
		let cached: { width: number; result: string[] } | undefined;

		return {
			render: (width: number): string[] => {
				if (cached && cached.width === width) {
					return cached.result;
				}

				const lines: string[] = [];
				for (let i = 0; i < cells.length; i++) {
					const cell = cells[i];
					const cellTitle = cell.title;
					const combinedTitle =
						cellTitle && workdirLabel ? `${workdirLabel} · ${cellTitle}` : (cellTitle ?? workdirLabel);
					const cellLines = renderCodeCell(
						{
							code: cell.code,
							language: "python",
							index: i,
							total: cells.length,
							title: combinedTitle,
							status: "pending",
							width,
							codeMaxLines: PYTHON_DEFAULT_PREVIEW_LINES,
							expanded: true,
						},
						uiTheme,
					);
					lines.push(...cellLines);
					if (i < cells.length - 1) {
						lines.push("");
					}
				}
				cached = { width, result: lines };
				return lines;
			},
			invalidate: () => {
				cached = undefined;
			},
		};
	},

	renderResult(
		result: { content: Array<{ type: string; text?: string }>; details?: PythonToolDetails },
		options: RenderResultOptions & { renderContext?: PythonRenderContext },
		uiTheme: Theme,
	): Component {
		const details = result.details;

		const output =
			options.renderContext?.output ?? (result.content?.find(c => c.type === "text")?.text ?? "").trimEnd();

		const jsonOutputs = details?.jsonOutputs ?? [];
		const jsonLines = jsonOutputs.flatMap((value, index) => {
			const header = `JSON output ${index + 1}`;
			const treeLines = renderJsonTree(value, uiTheme, options.renderContext?.expanded ?? options.expanded);
			return [header, ...treeLines];
		});

		const timeoutSeconds = options.renderContext?.timeout;
		const timeoutLine =
			typeof timeoutSeconds === "number"
				? uiTheme.fg("dim", wrapBrackets(`Timeout: ${timeoutSeconds}s`, uiTheme))
				: undefined;
		let warningLine: string | undefined;
		if (details?.meta?.truncation) {
			warningLine = formatStyledTruncationWarning(details.meta, uiTheme) ?? undefined;
		}

		const cellResults = details?.cells;
		if (cellResults && cellResults.length > 0) {
			// Cache state following Box pattern
			let cached: { key: string; width: number; result: string[] } | undefined;

			return {
				render: (width: number): string[] => {
					// Read mutable state at render time
					const expanded = options.renderContext?.expanded ?? options.expanded;
					const previewLines = options.renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES;
					const key = `${expanded}|${previewLines}|${options.spinnerFrame}`;
					if (cached && cached.key === key && cached.width === width) {
						return cached.result;
					}

					const lines: string[] = [];
					for (let i = 0; i < cellResults.length; i++) {
						const cell = cellResults[i];
						const statusLines = renderStatusEvents(cell.statusEvents ?? [], uiTheme, expanded, width);
						const outputContent = formatCellOutputLines(cell, expanded, previewLines, uiTheme, width);
						const outputLines = [...outputContent.lines];
						if (!expanded && outputContent.hiddenCount > 0) {
							outputLines.push(
								uiTheme.fg("dim", `… ${outputContent.hiddenCount} more lines (ctrl+o to expand)`),
							);
						}
						if (statusLines.length > 0) {
							if (outputLines.length > 0) {
								outputLines.push(uiTheme.fg("dim", "Status"));
							}
							outputLines.push(...statusLines);
						}
						const cellLines = renderCodeCell(
							{
								code: cell.code,
								language: "python",
								index: i,
								total: cellResults.length,
								title: cell.title,
								status: cell.status,
								spinnerFrame: options.spinnerFrame,
								duration: cell.durationMs,
								output: outputLines.length > 0 ? outputLines.join("\n") : undefined,
								outputMaxLines: outputLines.length,
								codeMaxLines: expanded ? Number.POSITIVE_INFINITY : PYTHON_DEFAULT_PREVIEW_LINES,
								expanded,
								width,
							},
							uiTheme,
						);
						lines.push(...cellLines);
						if (i < cellResults.length - 1) {
							lines.push("");
						}
					}
					if (jsonLines.length > 0) {
						if (lines.length > 0) {
							lines.push("");
						}
						lines.push(...jsonLines);
					}
					if (timeoutLine) {
						lines.push(timeoutLine);
					}
					if (warningLine) {
						lines.push(warningLine);
					}
					cached = { key, width, result: lines };
					return lines;
				},
				invalidate: () => {
					cached = undefined;
				},
			};
		}

		const displayOutput = output;
		const combinedOutput = [displayOutput, ...jsonLines].filter(Boolean).join("\n");

		const statusEvents = details?.statusEvents ?? [];
		const expandedFlag = options.renderContext?.expanded ?? options.expanded;

		if (!combinedOutput && statusEvents.length === 0) {
			const lines = [timeoutLine, warningLine].filter(Boolean) as string[];
			return new Text(lines.join("\n"), 0, 0);
		}

		if (!combinedOutput && statusEvents.length > 0) {
			return {
				render(width: number): string[] {
					const statusLines = renderStatusEvents(statusEvents, uiTheme, expandedFlag, width);
					return [uiTheme.fg("dim", "Status"), ...statusLines, timeoutLine, warningLine].filter(
						Boolean,
					) as string[];
				},
				invalidate() {},
			};
		}

		if (expandedFlag) {
			const styledOutputLines = combinedOutput.split("\n").map(line => uiTheme.fg("toolOutput", line));
			return {
				render(width: number): string[] {
					const statusLines = renderStatusEvents(statusEvents, uiTheme, expandedFlag, width);
					return [
						...styledOutputLines,
						...(statusLines.length > 0 ? [uiTheme.fg("dim", "Status"), ...statusLines] : []),
						timeoutLine,
						warningLine,
					].filter(Boolean) as string[];
				},
				invalidate() {},
			};
		}

		const styledOutput = combinedOutput
			.split("\n")
			.map(line => uiTheme.fg("toolOutput", line))
			.join("\n");
		const textContent = `\n${styledOutput}`;

		let cachedWidth: number | undefined;
		let cachedLines: string[] | undefined;
		let cachedSkipped: number | undefined;
		let cachedPreviewLines: number | undefined;

		return {
			render: (width: number): string[] => {
				// Read mutable state at render time
				const previewLines = options.renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES;
				if (cachedLines === undefined || cachedWidth !== width || cachedPreviewLines !== previewLines) {
					const result = truncateToVisualLines(textContent, previewLines, width);
					cachedLines = result.visualLines;
					cachedSkipped = result.skippedCount;
					cachedWidth = width;
					cachedPreviewLines = previewLines;
				}
				const outputLines: string[] = [];
				if (cachedSkipped && cachedSkipped > 0) {
					outputLines.push("");
					const skippedLine = uiTheme.fg(
						"dim",
						`… (${cachedSkipped} earlier lines, showing ${cachedLines.length} of ${cachedSkipped + cachedLines.length}) (ctrl+o to expand)`,
					);
					outputLines.push(truncateToWidth(skippedLine, width));
				}
				outputLines.push(...cachedLines);
				const statusLines = renderStatusEvents(statusEvents, uiTheme, expandedFlag, width);
				if (statusLines.length > 0) {
					outputLines.push(truncateToWidth(uiTheme.fg("dim", "Status"), width));
					for (const statusLine of statusLines) {
						outputLines.push(truncateToWidth(statusLine, width));
					}
				}
				if (timeoutLine) {
					outputLines.push(truncateToWidth(timeoutLine, width));
				}
				if (warningLine) {
					outputLines.push(truncateToWidth(warningLine, width));
				}
				return outputLines;
			},
			invalidate: () => {
				cachedWidth = undefined;
				cachedLines = undefined;
				cachedSkipped = undefined;
				cachedPreviewLines = undefined;
			},
		};
	},
	mergeCallAndResult: true,
	inline: true,
};
