import { constants } from "node:fs";
import {
	access as fsAccess,
	readFile as fsReadFile,
	unlink as fsUnlink,
	writeFile as fsWriteFile,
} from "node:fs/promises";
import { type Static, Type } from "@sinclair/typebox";
import type { AgentTool } from "@singularity-forge/agent-core";
import { notifyFileChanged } from "../lsp/client.js";
import {
	applyEditsToNormalizedContent,
	detectLineEnding,
	fuzzyFindText,
	generateDiffString,
	normalizeForFuzzyMatch,
	normalizeToLF,
	type ReplaceEdit,
	restoreLineEndings,
	stripBom,
} from "./edit-diff.js";
import {
	type Anchor,
	applyHashlineEdits,
	type HashlineEdit,
	parseHashlineText,
	parseTag,
} from "./hashline.js";
import { resolveToCwd } from "./path-utils.js";

// ─── Anchor-mode schema helpers ───────────────────────────────────────────────

const anchorEditItemSchema = Type.Object(
	{
		op: Type.Union([
			Type.Literal("replace"),
			Type.Literal("append"),
			Type.Literal("prepend"),
		]),
		pos: Type.Optional(
			Type.String({ description: 'Anchor tag (e.g. "5#QQ")' }),
		),
		end: Type.Optional(
			Type.String({ description: "End anchor for range replace" }),
		),
		lines: Type.Union([
			Type.Array(Type.String(), { description: "Replacement content lines" }),
			Type.String(),
			Type.Null(),
		]),
	},
	{ additionalProperties: false },
);

export type AnchorEditItem = Static<typeof anchorEditItemSchema>;

const editSchema = Type.Object({
	path: Type.String({
		description: "Path to the file to edit (relative or absolute)",
	}),
	match: Type.Optional(
		Type.Union([Type.Literal("substring"), Type.Literal("anchor")], {
			default: "substring",
			description:
				'Edit mode. "substring" (default): exact/fuzzy text replacement via oldText/newText. "anchor": line-hash anchor edits via anchorEdits[].',
		}),
	),
	// ── substring mode ──────────────────────────────────────────────────────
	oldText: Type.Optional(
		Type.String({
			description:
				"(substring mode) Exact text to find and replace. Use for a single replacement.",
		}),
	),
	newText: Type.Optional(
		Type.String({
			description:
				"(substring mode) New text to replace oldText with. Required when oldText is provided.",
		}),
	),
	edits: Type.Optional(
		Type.Array(
			Type.Object({
				oldText: Type.String({
					description: "Exact text to find (must be unique in the file)",
				}),
				newText: Type.String({ description: "Replacement text" }),
			}),
			{
				description:
					"(substring mode) Multiple disjoint replacements in the same file. Use instead of oldText/newText for multi-region edits.",
			},
		),
	),
	// ── anchor mode ─────────────────────────────────────────────────────────
	anchorEdits: Type.Optional(
		Type.Array(anchorEditItemSchema, {
			description:
				'(anchor mode) Edits referenced by LINE#ID tags from a tagged read. Only used when match="anchor".',
		}),
	),
	delete: Type.Optional(
		Type.Boolean({
			description: "(anchor mode) If true, delete the file.",
		}),
	),
	move: Type.Optional(
		Type.String({
			description: "(anchor mode) If set, move/rename the file to this path.",
		}),
	),
});

export type EditToolInput = Static<typeof editSchema>;

/** Parse a tag, returning undefined instead of throwing on garbage. */
function tryParseTag(raw: string): Anchor | undefined {
	try {
		return parseTag(raw);
	} catch {
		return undefined;
	}
}

/**
 * Map flat tool-schema anchor edits into typed HashlineEdit objects.
 */
function resolveEditAnchors(edits: AnchorEditItem[]): HashlineEdit[] {
	const result: HashlineEdit[] = [];
	for (const edit of edits) {
		const lines = parseHashlineText(edit.lines);
		const tag = edit.pos ? tryParseTag(edit.pos) : undefined;
		const end = edit.end ? tryParseTag(edit.end) : undefined;

		const op =
			edit.op === "append" || edit.op === "prepend" ? edit.op : "replace";
		switch (op) {
			case "replace": {
				if (tag && end) {
					result.push({ op: "replace", pos: tag, end, lines });
				} else if (tag || end) {
					result.push({ op: "replace", pos: tag || end!, lines });
				} else {
					throw new Error("Replace requires at least one anchor (pos or end).");
				}
				break;
			}
			case "append": {
				result.push({ op: "append", pos: tag ?? end, lines });
				break;
			}
			case "prepend": {
				result.push({ op: "prepend", pos: end ?? tag, lines });
				break;
			}
		}
	}
	return result;
}

export interface EditToolDetails {
	/** Unified diff of the changes made */
	diff: string;
	/** Line number of the first change in the new file (for editor navigation) */
	firstChangedLine?: number;
}

/**
 * Pluggable operations for the edit tool.
 * Override these to delegate file editing to remote systems (e.g., SSH).
 */
export interface EditOperations {
	/** Read file contents as a Buffer */
	readFile: (absolutePath: string) => Promise<Buffer>;
	/** Write content to a file */
	writeFile: (absolutePath: string, content: string) => Promise<void>;
	/** Check if file is readable and writable (throw if not) */
	access: (absolutePath: string) => Promise<void>;
	/** Delete a file */
	unlink: (absolutePath: string) => Promise<void>;
}

const defaultEditOperations: EditOperations = {
	readFile: (path) => fsReadFile(path),
	writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
	access: (path) => fsAccess(path, constants.R_OK | constants.W_OK),
	unlink: (path) => fsUnlink(path),
};

export interface EditToolOptions {
	/** Custom operations for file editing. Default: local filesystem */
	operations?: EditOperations;
}

export function createEditTool(
	cwd: string,
	options?: EditToolOptions,
): AgentTool<typeof editSchema> {
	const ops = options?.operations ?? defaultEditOperations;

	return {
		name: "Edit",
		label: "Edit",
		description:
			'Edit a file. Two modes:\n\n• match="substring" (default): exact/fuzzy text replacement. Use oldText/newText for a single replacement, or edits[] for multiple disjoint replacements in one call. Each edits[].oldText must be unique and non-overlapping.\n\n• match="anchor": line-hash anchor edits. Read the file first with format="tagged" to obtain LINE#ID anchors, then submit anchorEdits[] (replace/append/prepend). Supports delete:true and move:"dest" for file-level operations.',
		parameters: editSchema,
		execute: async (
			_toolCallId: string,
			{
				path,
				match,
				oldText,
				newText,
				edits: editsInput,
				anchorEdits,
				delete: deleteFile,
				move,
			}: EditToolInput,
			signal?: AbortSignal,
		) => {
			const absolutePath = resolveToCwd(path, cwd);

			return new Promise<{
				content: Array<{ type: "text"; text: string }>;
				details: EditToolDetails | undefined;
			}>((resolve, reject) => {
				// Check if already aborted
				if (signal?.aborted) {
					reject(new Error("Operation aborted"));
					return;
				}

				let aborted = false;

				// Set up abort handler
				const onAbort = () => {
					aborted = true;
					reject(new Error("Operation aborted"));
				};

				if (signal) {
					signal.addEventListener("abort", onAbort, { once: true });
				}

				// ── Anchor mode ──────────────────────────────────────────────────────
				if (match === "anchor") {
					(async () => {
						try {
							// Handle delete
							if (deleteFile) {
								let fileExists = true;
								try {
									await ops.access(absolutePath);
								} catch {
									fileExists = false;
								}
								if (fileExists) {
									await ops.unlink(absolutePath);
								}
								if (signal) signal.removeEventListener("abort", onAbort);
								resolve({
									content: [
										{
											type: "text",
											text: fileExists
												? `Deleted ${path}`
												: `File not found, nothing to delete: ${path}`,
										},
									],
									details: { diff: "" },
								});
								return;
							}

							// Handle file creation (no existing file, anchorless appends/prepends)
							let fileExists = true;
							try {
								await ops.access(absolutePath);
							} catch {
								fileExists = false;
							}

							if (!fileExists) {
								const newLines: string[] = [];
								for (const edit of anchorEdits ?? []) {
									if (
										(edit.op === "append" || edit.op === "prepend") &&
										!edit.pos &&
										!edit.end
									) {
										if (edit.op === "prepend") {
											newLines.unshift(...parseHashlineText(edit.lines));
										} else {
											newLines.push(...parseHashlineText(edit.lines));
										}
									} else {
										throw new Error(`File not found: ${path}`);
									}
								}
								await ops.writeFile(absolutePath, newLines.join("\n"));
								if (signal) signal.removeEventListener("abort", onAbort);
								resolve({
									content: [{ type: "text", text: `Created ${path}` }],
									details: { diff: "" },
								});
								return;
							}

							if (aborted) return;

							// Read file
							const rawContent = (await ops.readFile(absolutePath)).toString(
								"utf-8",
							);
							const { bom, text } = stripBom(rawContent);
							const originalEnding = detectLineEnding(text);
							const originalNormalized = normalizeToLF(text);

							if (aborted) return;

							// Resolve and apply edits
							const hashlineEdits = resolveEditAnchors(anchorEdits ?? []);
							const result = applyHashlineEdits(
								originalNormalized,
								hashlineEdits,
							);

							if (originalNormalized === result.lines && !move) {
								let diagnostic = `No changes made to ${path}. The edits produced identical content.`;
								if (result.noopEdits && result.noopEdits.length > 0) {
									const details = result.noopEdits
										.map(
											(e) =>
												`Edit ${e.editIndex}: replacement for ${e.loc} is identical to current content:\n  ${e.loc}| ${e.current}`,
										)
										.join("\n");
									diagnostic += `\n${details}`;
									diagnostic +=
										"\nYour content must differ from what the file already contains. Re-read the file to see the current state.";
								}
								throw new Error(diagnostic);
							}

							if (aborted) return;

							// Write result
							const finalContent =
								bom + restoreLineEndings(result.lines, originalEnding);
							const writePath = move ? resolveToCwd(move, cwd) : absolutePath;

							// Prevent silent overwrite when moving to an existing file
							if (move && writePath !== absolutePath) {
								try {
									await ops.access(writePath);
									throw new Error(
										`Destination file already exists: ${writePath}. Use a different path or delete the existing file first.`,
									);
								} catch (err: any) {
									if (
										err.message?.startsWith("Destination file already exists:")
									)
										throw err;
								}
							}

							await ops.writeFile(writePath, finalContent);

							if (move && writePath !== absolutePath) {
								await ops.unlink(absolutePath);
							}

							if (aborted) return;

							if (signal) signal.removeEventListener("abort", onAbort);

							const diffResult = generateDiffString(
								originalNormalized,
								result.lines,
							);
							const resultText = move
								? `Moved ${path} to ${move}`
								: `Updated ${path}`;
							const warningsBlock = result.warnings?.length
								? `\nWarnings:\n${result.warnings.join("\n")}`
								: "";

							resolve({
								content: [
									{ type: "text", text: `${resultText}${warningsBlock}` },
								],
								details: {
									diff: diffResult.diff,
									firstChangedLine:
										result.firstChangedLine ?? diffResult.firstChangedLine,
								},
							});
						} catch (error: any) {
							if (signal) signal.removeEventListener("abort", onAbort);
							if (!aborted) reject(error);
						}
					})();
					return;
				}

				// ── Substring mode (default) ─────────────────────────────────────────
				(async () => {
					try {
						// Check if file exists
						try {
							await ops.access(absolutePath);
						} catch {
							if (signal) {
								signal.removeEventListener("abort", onAbort);
							}
							reject(new Error(`File not found: ${path}`));
							return;
						}

						// Check if aborted before reading
						if (aborted) {
							return;
						}

						// Read the file
						const buffer = await ops.readFile(absolutePath);
						const rawContent = buffer.toString("utf-8");

						// Check if aborted after reading
						if (aborted) {
							return;
						}

						// Strip BOM before matching (LLM won't include invisible BOM in oldText)
						const { bom, text: content } = stripBom(rawContent);

						const originalEnding = detectLineEnding(content);
						const normalizedContent = normalizeToLF(content);

						let baseContent: string;
						let newContent: string;

						if (editsInput && editsInput.length > 0) {
							// Multi-edit mode: apply all edits at once
							const result = applyEditsToNormalizedContent(
								normalizedContent,
								editsInput as ReplaceEdit[],
								path,
							);
							baseContent = result.baseContent;
							newContent = result.newContent;
						} else if (oldText !== undefined && newText !== undefined) {
							// Single-edit mode: fuzzy match single replacement
							const normalizedOldText = normalizeToLF(oldText);
							const normalizedNewText = normalizeToLF(newText);

							const matchResult = fuzzyFindText(
								normalizedContent,
								normalizedOldText,
							);
							if (!matchResult.found) {
								if (signal) signal.removeEventListener("abort", onAbort);
								reject(
									new Error(
										`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
									),
								);
								return;
							}

							const fuzzyContent = normalizeForFuzzyMatch(normalizedContent);
							const fuzzyOldText = normalizeForFuzzyMatch(normalizedOldText);
							const occurrences = fuzzyContent.split(fuzzyOldText).length - 1;
							if (occurrences > 1) {
								if (signal) signal.removeEventListener("abort", onAbort);
								reject(
									new Error(
										`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
									),
								);
								return;
							}

							baseContent = matchResult.contentForReplacement;
							newContent =
								baseContent.substring(0, matchResult.index) +
								normalizedNewText +
								baseContent.substring(
									matchResult.index + matchResult.matchLength,
								);

							if (baseContent === newContent) {
								if (signal) signal.removeEventListener("abort", onAbort);
								reject(
									new Error(
										`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
									),
								);
								return;
							}
						} else {
							if (signal) signal.removeEventListener("abort", onAbort);
							reject(
								new Error(
									"Edit tool input is invalid. Provide either oldText and newText, or edits[].",
								),
							);
							return;
						}

						// Check if aborted before writing
						if (aborted) {
							return;
						}

						const finalContent =
							bom + restoreLineEndings(newContent, originalEnding);
						await ops.writeFile(absolutePath, finalContent);

						try {
							notifyFileChanged(absolutePath);
						} catch {
							/* best-effort */
						}

						// Check if aborted after writing
						if (aborted) {
							return;
						}

						// Clean up abort handler
						if (signal) {
							signal.removeEventListener("abort", onAbort);
						}

						const diffResult = generateDiffString(baseContent, newContent);
						resolve({
							content: [
								{
									type: "text",
									text: `Successfully replaced text in ${path}.`,
								},
							],
							details: {
								diff: diffResult.diff,
								firstChangedLine: diffResult.firstChangedLine,
							},
						});
					} catch (error: any) {
						// Clean up abort handler
						if (signal) {
							signal.removeEventListener("abort", onAbort);
						}

						if (!aborted) {
							reject(error);
						}
					}
				})();
			});
		},
	};
}

/** Default edit tool using process.cwd() - for backwards compatibility */
export const editTool = createEditTool(process.cwd());
