import * as fs from "node:fs";
import * as path from "node:path";
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import * as natives from './stubs/natives/index.ts';
import type { Component } from './stubs/tui/index.ts';
import { Text } from './stubs/tui/index.ts';
import { formatGroupedPaths, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import * as z from "zod/v4";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { InternalUrlRouter } from "../internal-urls";
import type { Theme } from "../modes/theme/theme";
import findDescription from "../prompts/tools/find.md" with { type: "text" };
import { type TruncationResult, truncateHead } from "../session/streaming-output";
import { Ellipsis, fileHyperlink, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import type { ToolSession } from ".";
import { applyListLimit } from "./list-limit";
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
import {
	expandDelimitedPathEntries,
	formatPathRelativeToCwd,
	hasGlobPathChars,
	normalizePathLikeInput,
	parseFindPattern,
	partitionExistingPaths,
	resolveExplicitFindPatterns,
	resolveToCwd,
} from "./path-utils";
import {
	createCachedComponent,
	formatCount,
	formatEmptyMessage,
	formatErrorMessage,
	PREVIEW_LIMITS,
} from "./render-utils";
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
import { toolResult } from "./tool-result";

const findSchema = z
	.object({
		paths: z.array(z.string().describe("glob including search path")).min(1).describe("globs including search paths"),
		hidden: z.boolean().default(true).describe("include hidden files").optional(),
		gitignore: z.boolean().default(true).describe("respect gitignore").optional(),
		limit: z.number().default(200).describe("max results (clamped to 1-200)").optional(),
		timeout: z.number().min(0.5).max(60).default(5).describe("timeout in seconds (0.5–60)").optional(),
	})
	.strict();

export type FindToolInput = z.infer<typeof findSchema>;

const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 200;
const DEFAULT_GLOB_TIMEOUT_MS = 5000;
const MIN_GLOB_TIMEOUT_MS = 500;
const MAX_GLOB_TIMEOUT_MS = 60_000;

export interface FindToolDetails {
	truncation?: TruncationResult;
	resultLimitReached?: number;
	meta?: OutputMeta;
	// Fields for TUI rendering
	scopePath?: string;
	fileCount?: number;
	files?: string[];
	truncated?: boolean;
	error?: string;
	/** Working directory at search time. Used by the renderer to resolve relative
	 * file paths to absolute paths for OSC 8 hyperlinks. */
	cwd?: string;
	/** User-supplied paths whose base directory was missing on disk. The tool
	 * skipped these and continued with the surviving entries; surfaced as a
	 * non-fatal warning in the renderer and in the model-facing text. */
	missingPaths?: string[];
}

/**
 * Pluggable operations for the find tool.
 * Override these to delegate file search to remote systems (e.g., SSH).
 */
export interface FindOperations {
	/** Check if path exists */
	exists: (absolutePath: string) => Promise<boolean> | boolean;
	/** Optional stat for distinguishing files vs directories. */
	stat?: (
		absolutePath: string,
	) => Promise<{ isFile(): boolean; isDirectory(): boolean }> | { isFile(): boolean; isDirectory(): boolean };
	/** Find files matching glob pattern. Returns relative paths. */
	glob: (pattern: string, cwd: string, options: { ignore: string[]; limit: number }) => Promise<string[]> | string[];
}

export interface FindToolOptions {
	/** Custom operations for find. Default: local filesystem + rg */
	operations?: FindOperations;
}

interface FindTarget {
	searchPath: string;
	globPattern: string;
	hasGlob: boolean;
}

export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
	readonly name = "find";
	readonly approval = "read" as const;
	readonly summary = "Find files and directories matching a glob pattern";
	readonly loadMode = "discoverable";
	readonly label = "Find";
	readonly description: string;
	readonly parameters = findSchema;
	readonly strict = true;

	readonly #customOps?: FindOperations;

	constructor(
		private readonly session: ToolSession,
		options?: FindToolOptions,
	) {
		this.#customOps = options?.operations;
		this.description = prompt.render(findDescription);
	}

	async execute(
		_toolCallId: string,
		params: z.infer<typeof findSchema>,
		signal?: AbortSignal,
		onUpdate?: AgentToolUpdateCallback<FindToolDetails>,
		_context?: AgentToolContext,
	): Promise<AgentToolResult<FindToolDetails>> {
		const { paths, limit, hidden, gitignore, timeout } = params;

		return untilAborted(signal, async () => {
			const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
			const rawPatternInputs = this.#customOps
				? paths
				: await expandDelimitedPathEntries(paths, this.session.cwd, { splitter: parseFindPattern });
			const rawPatterns = rawPatternInputs.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
			const internalRouter = InternalUrlRouter.instance();
			const normalizedPatterns: string[] = [];
			for (const rawPattern of rawPatterns) {
				if (!internalRouter.canHandle(rawPattern)) {
					normalizedPatterns.push(rawPattern);
					continue;
				}
				if (hasGlobPathChars(rawPattern)) {
					throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPattern}`);
				}
				const resource = await internalRouter.resolve(rawPattern, {
					cwd: this.session.cwd,
					settings: this.session.settings,
					signal,
					localProtocolOptions: this.session.localProtocolOptions,
				});
				if (!resource.sourcePath) {
					throw new ToolError(`Cannot find internal URL without a backing file: ${rawPattern}`);
				}
				normalizedPatterns.push(resource.sourcePath);
			}
			if (normalizedPatterns.some(pattern => pattern.length === 0)) {
				throw new ToolError("`paths` must contain non-empty globs or paths");
			}

			// Tolerate missing entries in a multi-path call: skip ones whose base
			// directory is gone, and only error if every entry is missing. Single
			// missing path keeps the original ENOENT semantics — the user explicitly
			// asked about that one path, so silent empty results would be misleading.
			let missingPaths: string[] = [];
			let effectivePatterns = normalizedPatterns;
			if (normalizedPatterns.length > 1 && !this.#customOps) {
				const partition = await partitionExistingPaths(normalizedPatterns, this.session.cwd, parseFindPattern);
				if (partition.valid.length === 0) {
					throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
				}
				effectivePatterns = partition.valid;
				missingPaths = partition.missing;
			}

			const multiPattern = await resolveExplicitFindPatterns(effectivePatterns, this.session.cwd);
			const isSingle = !multiPattern;
			const targets: FindTarget[] = multiPattern
				? multiPattern.targets.map(target => ({
						searchPath: resolveToCwd(target.basePath, this.session.cwd),
						globPattern: target.globPattern,
						hasGlob: target.hasGlob,
					}))
				: [
						(() => {
							const parsed = parseFindPattern(effectivePatterns[0] ?? ".");
							return {
								searchPath: resolveToCwd(parsed.basePath, this.session.cwd),
								globPattern: parsed.globPattern,
								hasGlob: parsed.hasGlob,
							};
						})(),
					];
			const scopePath = multiPattern?.scopePath ?? formatScopePath(targets[0].searchPath);

			for (const target of targets) {
				if (target.searchPath === "/") {
					throw new ToolError("Searching from root directory '/' is not allowed");
				}
			}

			const requestedLimit = limit ?? DEFAULT_LIMIT;
			if (!Number.isFinite(requestedLimit) || requestedLimit <= 0) {
				throw new ToolError("Limit must be a positive number");
			}
			const effectiveLimit = Math.min(MAX_LIMIT, Math.max(1, Math.floor(requestedLimit)));
			const includeHidden = hidden ?? true;
			const useGitignore = gitignore ?? true;
			const requestedTimeoutMs = timeout != null ? Math.round(timeout * 1000) : DEFAULT_GLOB_TIMEOUT_MS;
			const timeoutMs = Math.min(MAX_GLOB_TIMEOUT_MS, Math.max(MIN_GLOB_TIMEOUT_MS, requestedTimeoutMs));
			const timeoutSignal = AbortSignal.timeout(timeoutMs);
			const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
			const formatMatchPath = (matchPath: string, base: string, fileType?: natives.FileType): string => {
				const hadTrailingSlash = matchPath.endsWith("/") || matchPath.endsWith("\\");
				const absolutePath = path.isAbsolute(matchPath) ? matchPath : path.resolve(base, matchPath);
				return formatPathRelativeToCwd(absolutePath, this.session.cwd, {
					trailingSlash: fileType === natives.FileType.Dir || hadTrailingSlash,
				});
			};

			const missingPathsNote =
				missingPaths.length > 0 ? `Skipped missing paths: ${missingPaths.join(", ")}` : undefined;

			const buildResult = (
				files: string[],
				opts?: { notice?: string; forceTruncated?: boolean },
			): AgentToolResult<FindToolDetails> => {
				const notice = opts?.notice;
				const forceTruncated = opts?.forceTruncated ?? false;
				if (files.length === 0) {
					const details: FindToolDetails = {
						scopePath,
						fileCount: 0,
						files: [],
						truncated: forceTruncated,
						cwd: this.session.cwd,
						missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
					};
					const parts = ["No files found matching pattern"];
					if (notice) parts.push(notice);
					if (missingPathsNote) parts.push(missingPathsNote);
					return toolResult(details).text(parts.join("\n")).done();
				}

				const listLimit = applyListLimit(files, { limit: effectiveLimit });
				const limited = listLimit.items;
				const limitMeta = listLimit.meta;
				const baseOutput = formatGroupedPaths(limited);
				const trailingNotes: string[] = [];
				if (notice) trailingNotes.push(notice);
				if (missingPathsNote) trailingNotes.push(missingPathsNote);
				const rawOutput = trailingNotes.length > 0 ? `${baseOutput}\n\n${trailingNotes.join("\n")}` : baseOutput;
				const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });

				const details: FindToolDetails = {
					scopePath,
					fileCount: limited.length,
					files: limited,
					truncated: Boolean(forceTruncated || limitMeta.resultLimit || truncation.truncated),
					resultLimitReached: limitMeta.resultLimit?.reached,
					truncation: truncation.truncated ? truncation : undefined,
					cwd: this.session.cwd,
					missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
				};

				const resultBuilder = toolResult(details)
					.text(truncation.content)
					.limits({ resultLimit: limitMeta.resultLimit?.reached });
				if (truncation.truncated) {
					resultBuilder.truncation(truncation, { direction: "head" });
				}

				return resultBuilder.done();
			};

			// Walk each user path as its own root and run the globs concurrently.
			// Collapsing multiple paths to a shared base would force the walker to
			// traverse and stat every unrelated sibling under that ancestor; per-path
			// roots keep each scan bounded to exactly what the user asked for.
			if (this.#customOps?.glob) {
				const customOps = this.#customOps;
				const perTarget = await Promise.all(
					targets.map(async target => {
						if (!(await customOps.exists(target.searchPath))) {
							if (isSingle) throw new ToolError(`Path not found: ${scopePath}`);
							return [] as string[];
						}
						if (!target.hasGlob && customOps.stat) {
							const stat = await customOps.stat(target.searchPath);
							if (stat.isFile()) return [formatScopePath(target.searchPath)];
						}
						const results = await customOps.glob(target.globPattern, target.searchPath, {
							ignore: ["**/node_modules/**", "**/.git/**"],
							limit: effectiveLimit,
						});
						return results.map(matchPath => formatMatchPath(matchPath, target.searchPath));
					}),
				);
				const seen = new Set<string>();
				const merged: string[] = [];
				for (const group of perTarget) {
					for (const entry of group) {
						if (seen.has(entry)) continue;
						seen.add(entry);
						merged.push(entry);
					}
				}
				return buildResult(merged);
			}

			const onUpdateMatches: string[] = [];
			const onUpdateMtimes: number[] = [];
			const updateIntervalMs = 200;
			let lastUpdate = 0;
			const emitUpdate = () => {
				if (!onUpdate) return;
				const now = Date.now();
				if (now - lastUpdate < updateIntervalMs) return;
				lastUpdate = now;
				const details: FindToolDetails = {
					scopePath,
					fileCount: onUpdateMatches.length,
					files: onUpdateMatches.slice(),
					truncated: false,
				};
				onUpdate({
					content: [{ type: "text", text: onUpdateMatches.join("\n") }],
					details,
				});
			};
			const streamed = new Set<string>();
			const makeOnMatch =
				(base: string) =>
				(err: Error | null, match: natives.GlobMatch | null): void => {
					if (err || combinedSignal.aborted || !match?.path) return;
					const relativePath = formatMatchPath(match.path, base, match.fileType);
					if (streamed.has(relativePath)) return;
					streamed.add(relativePath);
					onUpdateMatches.push(relativePath);
					onUpdateMtimes.push(match.mtime ?? 0);
					emitUpdate();
				};

			let timedOut = false;
			const runTarget = async (target: FindTarget): Promise<Array<{ path: string; mtime: number }>> => {
				throwIfAborted(signal);
				let stat: fs.Stats;
				try {
					stat = await fs.promises.stat(target.searchPath);
				} catch (err) {
					if (isEnoent(err)) {
						if (isSingle) throw new ToolError(`Path not found: ${scopePath}`);
						return [];
					}
					throw err;
				}
				if (!target.hasGlob && stat.isFile()) {
					return [{ path: formatScopePath(target.searchPath), mtime: stat.mtimeMs }];
				}
				if (!stat.isDirectory()) {
					if (isSingle) throw new ToolError(`Path is not a directory: ${target.searchPath}`);
					return [];
				}
				try {
					const result = await untilAborted(combinedSignal, () =>
						natives.glob(
							{
								pattern: target.globPattern,
								path: target.searchPath,
								hidden: includeHidden,
								maxResults: effectiveLimit,
								sortByMtime: true,
								gitignore: useGitignore,
								// parseFindPattern explicitly prepends "**/" when the user's
								// pattern begins with a glob (so `*.ts` becomes `**/*.ts`).
								// Anything that arrives here without "**/" was scoped to a
								// single directory by the user (e.g. `dir/*`); disable the
								// native auto-recursion so `dir/*` does not silently match
								// `dir/sub/nested.ts`.
								recursive: false,
								signal: combinedSignal,
							},
							makeOnMatch(target.searchPath),
						),
					);
					throwIfAborted(signal);
					const out: Array<{ path: string; mtime: number }> = [];
					for (const match of result.matches) {
						if (!match.path) continue;
						out.push({
							path: formatMatchPath(match.path, target.searchPath, match.fileType),
							mtime: match.mtime ?? 0,
						});
					}
					return out;
				} catch (error) {
					if (error instanceof Error && error.name === "AbortError") {
						if (timeoutSignal.aborted && !signal?.aborted) {
							timedOut = true;
							return [];
						}
						throw new ToolAbortError();
					}
					throw error;
				}
			};

			const perTarget = await Promise.all(targets.map(runTarget));

			if (timedOut) {
				// Drain the partial matches accumulated during streaming and return them
				// instead of throwing — empty results after a multi-second wait force the
				// caller to retry blind, which is the worst possible outcome.
				const partial = onUpdateMatches.map((entry, index) => ({ p: entry, m: onUpdateMtimes[index] ?? 0 }));
				partial.sort((a, b) => b.m - a.m);
				const sortedPaths = partial.map(entry => entry.p);
				const seconds = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}` : (timeoutMs / 1000).toFixed(1);
				const notice = `find timed out after ${seconds}s; returning ${sortedPaths.length} partial matches — increase timeout or narrow pattern`;
				return buildResult(sortedPaths, { notice, forceTruncated: true });
			}

			// Merge per-target results: native glob already ranks each target's own
			// matches by mtime and caps them at the limit, so a global mtime re-sort
			// plus dedup yields the correct top-N across all roots.
			const seen = new Set<string>();
			const merged: Array<{ path: string; mtime: number }> = [];
			for (const group of perTarget) {
				for (const entry of group) {
					if (seen.has(entry.path)) continue;
					seen.add(entry.path);
					merged.push(entry);
				}
			}
			merged.sort((a, b) => b.mtime - a.mtime);
			return buildResult(merged.map(entry => entry.path));
		});
	}
}

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

interface FindRenderArgs {
	paths?: string | string[];
	limit?: number;
}

function formatFindRenderPaths(paths: FindRenderArgs["paths"]): string | undefined {
	return Array.isArray(paths) ? paths.join(", ") : paths;
}

const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;

function findStatusIcon(uiTheme: Theme): string {
	return uiTheme.fg("toolTitle", uiTheme.symbol("icon.search"));
}

export const findToolRenderer = {
	inline: true,
	renderCall(args: FindRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
		const meta: string[] = [];
		if (args.limit !== undefined) meta.push(`limit:${args.limit}`);

		const text = renderStatusLine(
			{
				icon: "pending",
				title: "Find",
				titleColor: "toolTitle",
				description: formatFindRenderPaths(args.paths) || "*",
				meta,
			},
			uiTheme,
		);
		return new Text(text, 1, 0);
	},

	renderResult(
		result: { content: Array<{ type: string; text?: string }>; details?: FindToolDetails; isError?: boolean },
		options: RenderResultOptions,
		uiTheme: Theme,
		args?: FindRenderArgs,
	): 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), 1, 0);
		}

		const hasDetailedData = details?.fileCount !== undefined;
		const textContent = result.content?.find(c => c.type === "text")?.text;

		if (!hasDetailedData) {
			if (
				!textContent ||
				textContent.includes("No files matching") ||
				textContent.includes("No files found") ||
				textContent.trim() === ""
			) {
				return new Text(formatEmptyMessage("No files found", uiTheme), 1, 0);
			}

			const lines = textContent.split("\n").filter(l => l.trim());
			const header = renderStatusLine(
				{
					iconOverride: findStatusIcon(uiTheme),
					title: "Find",
					titleColor: "toolTitle",
					description: formatFindRenderPaths(args?.paths),
					meta: [formatCount("file", lines.length)],
				},
				uiTheme,
			);
			return createCachedComponent(
				() => options.expanded,
				width => {
					const listLines = renderTreeList(
						{
							items: lines,
							expanded: options.expanded,
							maxCollapsed: COLLAPSED_LIST_LIMIT,
							itemType: "file",
							renderItem: line => uiTheme.fg("accent", line),
						},
						uiTheme,
					);
					return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
				},
				{ paddingX: 1 },
			);
		}

		const fileCount = details?.fileCount ?? 0;
		const truncation = details?.truncation ?? details?.meta?.truncation;
		const limits = details?.meta?.limits;
		const truncated = Boolean(details?.truncated || truncation || details?.resultLimitReached || limits?.resultLimit);
		const files = details?.files ?? [];

		const missingPaths = details?.missingPaths ?? [];
		const missingNote =
			missingPaths.length > 0 ? uiTheme.fg("warning", `skipped missing: ${missingPaths.join(", ")}`) : undefined;

		if (fileCount === 0) {
			const header = renderStatusLine(
				{
					icon: "warning",
					title: "Find",
					titleColor: "toolTitle",
					description: formatFindRenderPaths(args?.paths),
					meta: ["0 files"],
				},
				uiTheme,
			);
			const lines = [header, formatEmptyMessage("No files found", uiTheme)];
			if (missingNote) lines.push(missingNote);
			return new Text(lines.join("\n"), 1, 0);
		}
		const meta: string[] = [formatCount("file", fileCount)];
		if (details?.scopePath) meta.push(`in ${details.scopePath}`);
		if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
		const header = renderStatusLine(
			{
				...(truncated ? { icon: "warning" as const } : { iconOverride: findStatusIcon(uiTheme) }),
				title: "Find",
				titleColor: "toolTitle",
				description: formatFindRenderPaths(args?.paths),
				meta,
			},
			uiTheme,
		);

		const truncationReasons: string[] = [];
		if (details?.resultLimitReached) truncationReasons.push(`limit ${details.resultLimitReached} results`);
		if (limits?.resultLimit) truncationReasons.push(`limit ${limits.resultLimit.reached} results`);
		if (truncation) truncationReasons.push(truncation.truncatedBy === "lines" ? "line limit" : "size limit");
		const artifactId = truncation && "artifactId" in truncation ? truncation.artifactId : undefined;
		if (artifactId) truncationReasons.push(formatFullOutputReference(artifactId));

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

		return createCachedComponent(
			() => options.expanded,
			width => {
				const cwd = details?.cwd;
				const fileLines = renderFileList(
					{
						files: files.map(entry => ({
							path: entry,
							isDirectory: entry.endsWith("/"),
							absPath: cwd && !entry.endsWith("/") ? path.resolve(cwd, entry) : undefined,
						})),
						expanded: options.expanded,
						maxCollapsed: COLLAPSED_LIST_LIMIT,
						hyperlinkFn: fileHyperlink,
					},
					uiTheme,
				);
				return [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
			},
			{ paddingX: 1 },
		);
	},
	mergeCallAndResult: true,
};
