import { constants, createReadStream } from "node:fs";
import { access as fsAccess, readFile as fsReadFile } from "node:fs/promises";
import { createInterface } from "node:readline";
import { type Static, Type } from "@sinclair/typebox";
import type { AgentTool } from "@singularity-forge/agent-core";
import type { ImageContent, TextContent } from "@singularity-forge/ai";
import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js";
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
import { formatHashLines } from "./hashline.js";
import { resolveReadPath } from "./path-utils.js";
import {
	DEFAULT_MAX_BYTES,
	DEFAULT_MAX_LINES,
	formatSize,
	type TruncationResult,
	truncateHead,
} from "./truncate.js";

const readSchema = Type.Object({
	path: Type.String({
		description: "Path to the file to read (relative or absolute)",
	}),
	offset: Type.Optional(
		Type.Number({
			description: "Line number to start reading from (1-indexed)",
		}),
	),
	limit: Type.Optional(
		Type.Number({ description: "Maximum number of lines to read" }),
	),
	format: Type.Optional(
		Type.Union([Type.Literal("plain"), Type.Literal("tagged")], {
			default: "plain",
			description:
				'Output format. "plain" (default): raw file text. "tagged": prefix each line with LINE#HASH for use with Edit({match:"anchor"}).',
		}),
	),
});

export type ReadToolInput = Static<typeof readSchema>;

export interface ReadToolDetails {
	truncation?: TruncationResult;
}

/**
 * Pluggable operations for the read tool.
 * Override these to delegate file reading to remote systems (e.g., SSH).
 */
export interface ReadOperations {
	/** Read file contents as a Buffer */
	readFile: (absolutePath: string) => Promise<Buffer>;
	/** Check if file is readable (throw if not) */
	access: (absolutePath: string) => Promise<void>;
	/** Detect image MIME type, return null/undefined for non-images */
	detectImageMimeType?: (
		absolutePath: string,
	) => Promise<string | null | undefined>;
}

const defaultReadOperations: ReadOperations = {
	readFile: (path) => fsReadFile(path),
	access: (path) => fsAccess(path, constants.R_OK),
	detectImageMimeType: detectSupportedImageMimeTypeFromFile,
};

export interface ReadToolOptions {
	/** Whether to auto-resize images to 2000x2000 max. Default: true */
	autoResizeImages?: boolean;
	/** Custom operations for file reading. Default: local filesystem */
	operations?: ReadOperations;
}

/**
 * Read specific lines from a file using streaming (memory-efficient).
 * Only reads the requested lines without loading the entire file.
 *
 * @param absolutePath - Path to the file
 * @param startLine - 1-indexed line to start from
 * @param lineCount - Maximum number of lines to read
 * @param signal - Optional abort signal
 * @returns Object with lines array and total line count (if reached)
 */
async function readLinesStreamed(
	absolutePath: string,
	startLine: number,
	lineCount: number,
	signal?: AbortSignal,
): Promise<{ lines: string[]; totalLines: number }> {
	const stream = createReadStream(absolutePath, { encoding: "utf-8" });
	const rl = createInterface({ input: stream });

	const lines: string[] = [];
	let currentLine = 0;
	let totalLines = 0;

	try {
		for await (const line of rl) {
			if (signal?.aborted) {
				throw new Error("Operation aborted");
			}

			currentLine++;

			// Skip lines before startLine
			if (currentLine < startLine) {
				continue;
			}

			// Collect lines within the requested range
			if (currentLine >= startLine && lines.length < lineCount) {
				lines.push(line);
			}

			// Stop if we've collected enough lines
			if (lines.length >= lineCount) {
				// We need to count remaining lines for the "total lines" info
				// But we can stop reading and just count
				totalLines = currentLine;
				break;
			}
		}

		// If we didn't break early, currentLine is the total
		if (totalLines === 0) {
			totalLines = currentLine;
		}
	} finally {
		stream.destroy();
		rl.close();
	}

	return { lines, totalLines };
}

/**
 * Count total lines in a file efficiently.
 *
 * @param absolutePath - Path to the file
 * @param signal - Optional abort signal
 * @returns Total number of lines
 */
async function countLines(
	absolutePath: string,
	signal?: AbortSignal,
): Promise<number> {
	const stream = createReadStream(absolutePath, { encoding: "utf-8" });
	const rl = createInterface({ input: stream });

	let count = 0;

	try {
		for await (const _line of rl) {
			if (signal?.aborted) {
				throw new Error("Operation aborted");
			}
			count++;
		}
	} finally {
		stream.destroy();
		rl.close();
	}

	return count;
}

export function createReadTool(
	cwd: string,
	options?: ReadToolOptions,
): AgentTool<typeof readSchema> {
	const autoResizeImages = options?.autoResizeImages ?? true;
	const ops = options?.operations ?? defaultReadOperations;

	return {
		name: "Read",
		label: "Read",
		description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete. Use format="tagged" to get LINE#HASH prefixes on each line (for anchor-mode editing with Edit({match:"anchor"})).`,
		parameters: readSchema,
		execute: async (
			_toolCallId: string,
			{
				path,
				offset,
				limit,
				format,
			}: { path: string; offset?: number; limit?: number; format?: "plain" | "tagged" },
			signal?: AbortSignal,
		) => {
			const absolutePath = resolveReadPath(path, cwd);

			return new Promise<{
				content: (TextContent | ImageContent)[];
				details: ReadToolDetails | 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 });
				}

				// Perform the read operation
				(async () => {
					try {
						// Check if file exists
						await ops.access(absolutePath);

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

						const mimeType = ops.detectImageMimeType
							? await ops.detectImageMimeType(absolutePath)
							: undefined;

						// Read the file based on type
						let content: (TextContent | ImageContent)[];
						let details: ReadToolDetails | undefined;

						if (mimeType) {
							// Read as image (binary)
							const buffer = await ops.readFile(absolutePath);
							const base64 = buffer.toString("base64");

							if (autoResizeImages) {
								// Resize image if needed
								const resized = await resizeImage({
									type: "image",
									data: base64,
									mimeType,
								});
								const dimensionNote = formatDimensionNote(resized);

								let textNote = `Read image file [${resized.mimeType}]`;
								if (dimensionNote) {
									textNote += `\n${dimensionNote}`;
								}

								content = [
									{ type: "text", text: textNote },
									{
										type: "image",
										data: resized.data,
										mimeType: resized.mimeType,
									},
								];
							} else {
								const textNote = `Read image file [${mimeType}]`;
								content = [
									{ type: "text", text: textNote },
									{ type: "image", data: base64, mimeType },
								];
							}
						} else {
							// Read as text
							let outputText: string;
							let _details: ReadToolDetails | undefined;

							// Use streaming when offset or limit are specified (memory-efficient for large files)
							if (offset !== undefined || limit !== undefined) {
								const startLine = offset ? Math.max(1, offset) : 1;
								const lineCount = limit ?? DEFAULT_MAX_LINES;

								// First, count total lines for the notice
								const totalFileLines = await countLines(absolutePath, signal);

								// Clamp offset to file bounds (#3007)
								let effectiveStartLine = startLine;
								let offsetClamped = false;
								if (effectiveStartLine > totalFileLines) {
									effectiveStartLine = Math.max(1, totalFileLines);
									offsetClamped = true;
								}

								// Stream only the requested lines
								const { lines } = await readLinesStreamed(
									absolutePath,
									effectiveStartLine,
									lineCount,
									signal,
								);
								const selectedContent = lines.join("\n");

								// Apply truncation to the selected content
								const truncation = truncateHead(selectedContent);

								if (truncation.firstLineExceedsLimit) {
									const firstLineSize = formatSize(
										Buffer.byteLength(lines[0] ?? "", "utf-8"),
									);
									outputText = `[Line ${effectiveStartLine} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${effectiveStartLine}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
									_details = { truncation };
								} else if (truncation.truncated) {
									const endLineDisplay =
										effectiveStartLine + truncation.outputLines - 1;
									const nextOffset = endLineDisplay + 1;

									const bodyText =
										format === "tagged"
											? formatHashLines(truncation.content, effectiveStartLine)
											: truncation.content;
									outputText = bodyText;

									if (truncation.truncatedBy === "lines") {
										outputText += `\n\n[Showing lines ${effectiveStartLine}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
									} else {
										outputText += `\n\n[Showing lines ${effectiveStartLine}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
									}
									_details = { truncation };
								} else if (
									lines.length >= lineCount &&
									effectiveStartLine + lines.length - 1 < totalFileLines
								) {
									// User limit reached, more content available
									const nextOffset = effectiveStartLine + lines.length;
									const remaining =
										totalFileLines - (effectiveStartLine + lines.length - 1);

									const bodyText =
										format === "tagged"
											? formatHashLines(truncation.content, effectiveStartLine)
											: truncation.content;
									outputText = bodyText;
									outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
								} else {
									outputText =
										format === "tagged"
											? formatHashLines(truncation.content, effectiveStartLine)
											: truncation.content;
								}

								if (offsetClamped) {
									outputText = `[Offset ${offset} beyond end of file (${totalFileLines} lines). Clamped to line ${effectiveStartLine}.]\n\n${outputText}`;
								}
							} else {
								// No offset/limit - read entire file (existing behavior)
								const buffer = await ops.readFile(absolutePath);
								const textContent = buffer.toString("utf-8");
								const allLines = textContent.split("\n");
								const totalFileLines = allLines.length;

								const truncation = truncateHead(textContent);

								if (truncation.firstLineExceedsLimit) {
									outputText = `[File exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: head -c ${DEFAULT_MAX_BYTES} ${path}]`;
									_details = { truncation };
								} else if (truncation.truncated) {
									const endLineDisplay = truncation.outputLines;
									const nextOffset = endLineDisplay + 1;

									const bodyText =
										format === "tagged"
											? formatHashLines(truncation.content, 1)
											: truncation.content;
									outputText = bodyText;

									if (truncation.truncatedBy === "lines") {
										outputText += `\n\n[Showing lines 1-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
									} else {
										outputText += `\n\n[Showing lines 1-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
									}
									_details = { truncation };
								} else {
									outputText =
										format === "tagged"
											? formatHashLines(truncation.content, 1)
											: truncation.content;
								}
							}

							content = [{ type: "text", text: outputText }];
						}

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

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

						resolve({ content, details });
					} catch (error: any) {
						// Clean up abort handler
						if (signal) {
							signal.removeEventListener("abort", onAbort);
						}

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

/** Default read tool using process.cwd() - for backwards compatibility */
export const readTool = createReadTool(process.cwd());
