import * as path from "node:path";
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";

import { type GrepResult, grep } from "@oh-my-pi/pi-natives";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import { renderPromptTemplate } from "../config/prompt-templates";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import type { Theme } from "../modes/theme/theme";
import { computeLineHash } from "../patch/hashline";
import grepDescription from "../prompts/tools/grep.md" with { type: "text" };
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { resolveFileDisplayMode } from "../utils/file-display-mode";
import type { ToolSession } from ".";
import type { OutputMeta } from "./output-meta";
import { resolveToCwd } from "./path-utils";
import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
import { ToolError } from "./tool-errors";
import { toolResult } from "./tool-result";
import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "./truncate";

const grepSchema = Type.Object({
	pattern: Type.String({ description: "Regex pattern to search for" }),
	path: Type.Optional(Type.String({ description: "File or directory to search (default: cwd)" })),
	glob: Type.Optional(Type.String({ description: "Filter files by glob pattern (e.g., '*.js')" })),
	type: Type.Optional(Type.String({ description: "Filter by file type (e.g., js, py, rust)" })),
	i: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
	pre: Type.Optional(Type.Number({ description: "Lines of context before matches" })),
	post: Type.Optional(Type.Number({ description: "Lines of context after matches" })),
	multiline: Type.Optional(Type.Boolean({ description: "Enable multiline matching" })),
	limit: Type.Optional(Type.Number({ description: "Limit output to first N matches (default: 100)" })),
	offset: Type.Optional(Type.Number({ description: "Skip first N entries before applying limit (default: 0)" })),
});

export type GrepToolInput = Static<typeof grepSchema>;

const DEFAULT_MATCH_LIMIT = 100;

export interface GrepToolDetails {
	truncation?: TruncationResult;
	matchLimitReached?: number;
	resultLimitReached?: number;
	linesTruncated?: boolean;
	meta?: OutputMeta;
	scopePath?: string;
	matchCount?: number;
	fileCount?: number;
	files?: string[];
	fileMatches?: Array<{ path: string; count: number }>;
	truncated?: boolean;
	error?: string;
}

type GrepParams = Static<typeof grepSchema>;

export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
	readonly name = "grep";
	readonly label = "Grep";
	readonly description: string;
	readonly parameters = grepSchema;

	constructor(private readonly session: ToolSession) {
		const displayMode = resolveFileDisplayMode(session.settings);
		this.description = renderPromptTemplate(grepDescription, {
			IS_HASHLINE_MODE: displayMode.hashLines,
			IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
		});
	}

	async execute(
		_toolCallId: string,
		params: GrepParams,
		signal?: AbortSignal,
		_onUpdate?: AgentToolUpdateCallback<GrepToolDetails>,
		_toolContext?: AgentToolContext,
	): Promise<AgentToolResult<GrepToolDetails>> {
		const { pattern, path: searchDir, glob, type, i, pre, post, multiline, limit, offset } = params;

		return untilAborted(signal, async () => {
			const normalizedPattern = pattern.trim();
			if (!normalizedPattern) {
				throw new ToolError("Pattern must not be empty");
			}

			const normalizedOffset = offset === undefined ? 0 : Number.isFinite(offset) ? Math.floor(offset) : Number.NaN;
			if (normalizedOffset < 0 || !Number.isFinite(normalizedOffset)) {
				throw new ToolError("Offset must be a non-negative number");
			}

			const rawLimit = limit === undefined ? undefined : Number.isFinite(limit) ? Math.floor(limit) : Number.NaN;
			if (rawLimit !== undefined && (!Number.isFinite(rawLimit) || rawLimit < 0)) {
				throw new ToolError("Limit must be a non-negative number");
			}
			const normalizedLimit = rawLimit !== undefined && rawLimit > 0 ? rawLimit : undefined;

			const defaultContextBefore = this.session.settings.get("grep.contextBefore");
			const defaultContextAfter = this.session.settings.get("grep.contextAfter");
			const normalizedContextBefore = pre ?? defaultContextBefore;
			const normalizedContextAfter = post ?? defaultContextAfter;
			const ignoreCase = i ?? false;
			const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
			const effectiveMultiline = multiline ?? patternHasNewline;

			const useHashLines = resolveFileDisplayMode(this.session.settings).hashLines;
			const searchPath = resolveToCwd(searchDir || ".", this.session.cwd);
			const scopePath = (() => {
				const relative = path.relative(this.session.cwd, searchPath).replace(/\\/g, "/");
				return relative.length === 0 ? "." : relative;
			})();

			let isDirectory: boolean;
			try {
				const stat = await Bun.file(searchPath).stat();
				isDirectory = stat.isDirectory();
			} catch {
				throw new ToolError(`Path not found: ${searchPath}`);
			}

			const effectiveOutputMode = "content";
			const effectiveLimit = normalizedLimit ?? DEFAULT_MATCH_LIMIT;

			// Run grep
			let result: GrepResult;
			try {
				result = await grep({
					pattern: normalizedPattern,
					path: searchPath,
					glob: glob?.trim() || undefined,
					type: type?.trim() || undefined,
					ignoreCase,
					multiline: effectiveMultiline,
					hidden: true,
					cache: false,
					maxCount: effectiveLimit,
					offset: normalizedOffset > 0 ? normalizedOffset : undefined,
					contextBefore: normalizedContextBefore,
					contextAfter: normalizedContextAfter,
					maxColumns: DEFAULT_MAX_COLUMN,
					mode: effectiveOutputMode,
				});
			} catch (err) {
				if (err instanceof Error && err.message.startsWith("regex parse error")) {
					throw new ToolError(err.message);
				}
				throw err;
			}

			const formatPath = (filePath: string): string => {
				// returns paths starting with / (the virtual root)
				const cleanPath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
				if (isDirectory) {
					return cleanPath.replace(/\\/g, "/");
				}
				return path.basename(cleanPath);
			};

			// Build output
			const files = new Set<string>();
			const fileList: string[] = [];
			const fileMatchCounts = new Map<string, number>();

			const recordFile = (filePath: string) => {
				const relative = formatPath(filePath);
				if (!files.has(relative)) {
					files.add(relative);
					fileList.push(relative);
				}
			};

			if (result.totalMatches === 0) {
				const details: GrepToolDetails = {
					scopePath,
					matchCount: 0,
					fileCount: 0,
					files: [],
					truncated: false,
				};
				return toolResult(details).text("No matches found").done();
			}

			const outputLines: string[] = [];
			let linesTruncated = false;
			let matchIndex = 0;

			for (const match of result.matches) {
				recordFile(match.path);
				const relativePath = formatPath(match.path);

				matchIndex += 1;
				if (matchIndex > 1) {
					outputLines.push("");
				}
				outputLines.push(`${matchIndex}. ${relativePath}:${match.lineNumber}`);

				const lineNumbers: number[] = [match.lineNumber];
				if (match.contextBefore) {
					for (const ctx of match.contextBefore) {
						lineNumbers.push(ctx.lineNumber);
					}
				}
				if (match.contextAfter) {
					for (const ctx of match.contextAfter) {
						lineNumbers.push(ctx.lineNumber);
					}
				}
				const lineWidth = Math.max(...lineNumbers.map(value => value.toString().length));

				const formatLine = (lineNumber: number, line: string, isMatch: boolean): string => {
					if (useHashLines) {
						const ref = `${lineNumber}:${computeLineHash(lineNumber, line)}`;
						return isMatch ? `>>${ref}|${line}` : `  ${ref}|${line}`;
					}
					const padded = lineNumber.toString().padStart(lineWidth, " ");
					return isMatch ? `>>${padded}|${line}` : `  ${padded}|${line}`;
				};

				// Add context before
				if (match.contextBefore) {
					for (const ctx of match.contextBefore) {
						outputLines.push(formatLine(ctx.lineNumber, ctx.line, false));
					}
				}

				// Add match line
				outputLines.push(formatLine(match.lineNumber, match.line, true));

				if (match.truncated) {
					linesTruncated = true;
				}

				// Add context after
				if (match.contextAfter) {
					for (const ctx of match.contextAfter) {
						outputLines.push(formatLine(ctx.lineNumber, ctx.line, false));
					}
				}

				// Track per-file counts
				fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
			}

			const rawOutput = outputLines.join("\n");
			const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
			const output = truncation.content;

			const truncated = Boolean(result.limitReached || truncation.truncated || linesTruncated);
			const details: GrepToolDetails = {
				scopePath,
				matchCount: result.totalMatches,
				fileCount: result.filesWithMatches,
				files: fileList,
				fileMatches: fileList.map(path => ({
					path,
					count: fileMatchCounts.get(path) ?? 0,
				})),
				truncated,
				matchLimitReached: result.limitReached ? effectiveLimit : undefined,
			};

			if (truncation.truncated) details.truncation = truncation;
			if (linesTruncated) details.linesTruncated = true;

			const resultBuilder = toolResult(details)
				.text(output)
				.limits({
					matchLimit: result.limitReached ? effectiveLimit : undefined,
					columnMax: linesTruncated ? DEFAULT_MAX_COLUMN : undefined,
				});

			if (truncation.truncated) {
				resultBuilder.truncation(truncation, { direction: "head" });
			}

			return resultBuilder.done();
		});
	}
}

// =============================================================================
// TUI Renderer
// =============================================================================

interface GrepRenderArgs {
	pattern: string;
	path?: string;
	glob?: string;
	type?: string;
	i?: boolean;
	pre?: number;
	post?: number;
	multiline?: boolean;
	limit?: number;
	offset?: number;
}

const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;

export const grepToolRenderer = {
	inline: true,
	renderCall(args: GrepRenderArgs, uiTheme: Theme): Component {
		const meta: string[] = [];
		if (args.path) meta.push(`in ${args.path}`);
		if (args.glob) meta.push(`glob:${args.glob}`);
		if (args.type) meta.push(`type:${args.type}`);
		if (args.i) meta.push("case:insensitive");
		if (args.pre !== undefined && args.pre > 0) {
			meta.push(`pre:${args.pre}`);
		}
		if (args.post !== undefined && args.post > 0) {
			meta.push(`post:${args.post}`);
		}
		if (args.multiline) meta.push("multiline");
		if (args.limit !== undefined && args.limit > 0) meta.push(`limit:${args.limit}`);
		if (args.offset !== undefined && args.offset > 0) meta.push(`offset:${args.offset}`);

		const text = renderStatusLine(
			{ icon: "pending", title: "Grep", description: args.pattern || "?", meta },
			uiTheme,
		);
		return new Text(text, 0, 0);
	},

	renderResult(
		result: { content: Array<{ type: string; text?: string }>; details?: GrepToolDetails; isError?: boolean },
		options: RenderResultOptions,
		uiTheme: Theme,
		args?: GrepRenderArgs,
	): Component {
		const details = result.details;

		if (result.isError || details?.error) {
			const errorText = details?.error || result.content?.find(c => c.type === "text")?.text || "Unknown error";
			return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
		}

		const hasDetailedData = details?.matchCount !== undefined || details?.fileCount !== undefined;

		if (!hasDetailedData) {
			const textContent = result.content?.find(c => c.type === "text")?.text;
			if (!textContent || textContent === "No matches found") {
				return new Text(formatEmptyMessage("No matches found", uiTheme), 0, 0);
			}
			const lines = textContent.split("\n").filter(line => line.trim() !== "");
			const description = args?.pattern ?? undefined;
			const header = renderStatusLine(
				{ icon: "success", title: "Grep", description, meta: [formatCount("item", lines.length)] },
				uiTheme,
			);
			let cached: RenderCache | undefined;
			return {
				render(width: number): string[] {
					const { expanded } = options;
					const key = new Hasher().bool(expanded).u32(width).digest();
					if (cached?.key === key) return cached.lines;
					const listLines = renderTreeList(
						{
							items: lines,
							expanded,
							maxCollapsed: COLLAPSED_TEXT_LIMIT,
							itemType: "item",
							renderItem: line => uiTheme.fg("toolOutput", line),
						},
						uiTheme,
					);
					const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
					cached = { key, lines: result };
					return result;
				},
				invalidate() {
					cached = undefined;
				},
			};
		}

		const matchCount = details?.matchCount ?? 0;
		const fileCount = details?.fileCount ?? 0;
		const truncation = details?.meta?.truncation;
		const limits = details?.meta?.limits;
		const truncated = Boolean(
			details?.truncated || truncation || limits?.matchLimit || limits?.resultLimit || limits?.columnTruncated,
		);

		if (matchCount === 0) {
			const header = renderStatusLine(
				{ icon: "warning", title: "Grep", description: args?.pattern, meta: ["0 matches"] },
				uiTheme,
			);
			return new Text([header, formatEmptyMessage("No matches found", uiTheme)].join("\n"), 0, 0);
		}

		const summaryParts = [formatCount("match", matchCount), formatCount("file", fileCount)];
		const meta = [...summaryParts];
		if (details?.scopePath) meta.push(`in ${details.scopePath}`);
		if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
		const description = args?.pattern ?? undefined;
		const header = renderStatusLine(
			{ icon: truncated ? "warning" : "success", title: "Grep", description, meta },
			uiTheme,
		);

		const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
		const rawLines = textContent.split("\n");
		const hasSeparators = rawLines.some(line => line.trim().length === 0);
		const matchGroups: string[][] = [];
		if (hasSeparators) {
			let current: string[] = [];
			for (const line of rawLines) {
				if (line.trim().length === 0) {
					if (current.length > 0) {
						matchGroups.push(current);
						current = [];
					}
					continue;
				}
				current.push(line);
			}
			if (current.length > 0) matchGroups.push(current);
		} else {
			for (const line of rawLines) {
				if (line.trim().length === 0) continue;
				matchGroups.push([line]);
			}
		}

		const getCollapsedMatchLimit = (groups: string[][], maxLines: number): number => {
			if (groups.length === 0) return 0;
			let usedLines = 0;
			let count = 0;
			for (const group of groups) {
				if (count > 0 && usedLines + group.length > maxLines) break;
				usedLines += group.length;
				count += 1;
				if (usedLines >= maxLines) break;
			}
			return count;
		};

		const truncationReasons: string[] = [];
		if (limits?.matchLimit) truncationReasons.push(`limit ${limits.matchLimit.reached} matches`);
		if (limits?.resultLimit) truncationReasons.push(`limit ${limits.resultLimit.reached} results`);
		if (truncation) truncationReasons.push(truncation.truncatedBy === "lines" ? "line limit" : "size limit");
		if (limits?.columnTruncated) truncationReasons.push(`line length ${limits.columnTruncated.maxColumn}`);
		if (truncation?.artifactId) truncationReasons.push(`full output: artifact://${truncation.artifactId}`);

		const extraLines =
			truncationReasons.length > 0 ? [uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)] : [];

		let cached: RenderCache | undefined;
		return {
			render(width: number): string[] {
				const { expanded } = options;
				const key = new Hasher().bool(expanded).u32(width).digest();
				if (cached?.key === key) return cached.lines;
				const maxCollapsed = expanded
					? matchGroups.length
					: getCollapsedMatchLimit(matchGroups, COLLAPSED_TEXT_LIMIT);
				const matchLines = renderTreeList(
					{
						items: matchGroups,
						expanded,
						maxCollapsed,
						itemType: "match",
						renderItem: group => group.map(line => uiTheme.fg("toolOutput", line)),
					},
					uiTheme,
				);
				const result = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
				cached = { key, lines: result };
				return result;
			},
			invalidate() {
				cached = undefined;
			},
		};
	},
	mergeCallAndResult: true,
};
