import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import * as os from "node:os";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { RULES_INJECTOR_STORAGE } from "./constants";
import {
	clearParsedRuleCache,
	createRuleInjectionProcessor,
	getParsedRuleCacheStats,
} from "./injector";

type StatSnapshot = { mtimeMs: number; size: number };

let trackedRulePath = "";
let statSnapshots: Array<StatSnapshot | Error> = [];
let trackedReadFileCount = 0;
let trackedShouldApplyRuleCount = 0;
let mockedHomeDir = "";

const originalReadFileSync = fs.readFileSync.bind(fs);
const originalStatSync = fs.statSync.bind(fs);
const originalHomedir = os.homedir.bind(os);

function createOutput(): { title: string; output: string; metadata: unknown } {
	return { title: "tool", output: "", metadata: {} };
}

async function createProcessor(projectRoot: string): Promise<{
	processFilePathForInjection: (
		filePath: string,
		sessionID: string,
		output: { title: string; output: string; metadata: unknown },
	) => Promise<void>;
}> {
	const sessionCaches = new Map<
		string,
		{ contentHashes: Set<string>; realPaths: Set<string> }
	>();

	return createRuleInjectionProcessor({
		workspaceDirectory: projectRoot,
		truncator: {
			truncate: async (_sessionID: string, content: string) => ({
				result: content,
				truncated: false,
			}),
		},
		getSessionCache: (sessionID: string) => {
			if (!sessionCaches.has(sessionID)) {
				sessionCaches.set(sessionID, {
					contentHashes: new Set<string>(),
					realPaths: new Set<string>(),
				});
			}
			const cache = sessionCaches.get(sessionID);
			if (!cache) {
				throw new Error("Session cache should exist");
			}
			return cache;
		},
		readFileSync: (filePath: string, encoding: "utf-8") => {
			if (filePath === trackedRulePath) {
				trackedReadFileCount += 1;
			}
			return originalReadFileSync(filePath, encoding);
		},
		statSync: (filePath: fs.PathLike) => {
			if (filePath === trackedRulePath) {
				const next = statSnapshots.shift();
				if (next instanceof Error) {
					throw next;
				}
				if (next) {
					return {
						mtimeMs: next.mtimeMs,
						size: next.size,
						isFile: () => true,
					} as ReturnType<typeof originalStatSync>;
				}
			}
			return originalStatSync(filePath);
		},
		homedir: () => mockedHomeDir || originalHomedir(),
		shouldApplyRule: () => {
			trackedShouldApplyRuleCount += 1;
			return { applies: true, reason: "matched" };
		},
		isDuplicateByRealPath: (realPath: string, cache: Set<string>) =>
			cache.has(realPath),
		createContentHash: (content: string) => `hash:${content}`,
		isDuplicateByContentHash: (hash: string, cache: Set<string>) =>
			cache.has(hash),
	});
}

function getInjectedRulesPath(sessionID: string): string {
	return join(RULES_INJECTOR_STORAGE, `${sessionID}.json`);
}

describe("createRuleInjectionProcessor", () => {
	let testRoot: string;
	let projectRoot: string;
	let homeRoot: string;
	let targetFile: string;
	let ruleFile: string;
	let ruleRealPath: string;

	beforeEach(() => {
		clearParsedRuleCache();
		testRoot = join(tmpdir(), `rules-injector-injector-${Date.now()}`);
		projectRoot = join(testRoot, "project");
		homeRoot = join(testRoot, "home");
		targetFile = join(projectRoot, "src", "index.ts");
		ruleFile = join(
			projectRoot,
			".github",
			"instructions",
			"typescript.instructions.md",
		);

		mkdirSync(join(projectRoot, ".git"), { recursive: true });
		mkdirSync(join(projectRoot, "src"), { recursive: true });
		mkdirSync(join(projectRoot, ".github", "instructions"), {
			recursive: true,
		});
		mkdirSync(homeRoot, { recursive: true });

		writeFileSync(targetFile, "export const value = 1;\n");
		writeFileSync(ruleFile, "rule-content\n");

		ruleRealPath = fs.realpathSync(ruleFile);
		trackedRulePath = ruleFile;
		statSnapshots = [];
		trackedReadFileCount = 0;
		trackedShouldApplyRuleCount = 0;
		mockedHomeDir = homeRoot;
	});

	afterEach(() => {
		clearParsedRuleCache();
		if (fs.existsSync(testRoot)) {
			rmSync(testRoot, { recursive: true, force: true });
		}
	});

	it("reads and parses same file once when stat is unchanged", async () => {
		// given
		statSnapshots = [
			{ mtimeMs: 1000, size: 13 },
			{ mtimeMs: 1000, size: 13 },
		];
		const processor = await createProcessor(projectRoot);

		// when
		await processor.processFilePathForInjection(
			targetFile,
			"session-1",
			createOutput(),
		);
		await processor.processFilePathForInjection(
			targetFile,
			"session-2",
			createOutput(),
		);

		// then
		expect(trackedReadFileCount).toBe(1);
	});

	it("re-reads file when mtime changes", async () => {
		// given
		statSnapshots = [
			{ mtimeMs: 1000, size: 13 },
			{ mtimeMs: 2000, size: 13 },
		];
		const processor = await createProcessor(projectRoot);

		// when
		await processor.processFilePathForInjection(
			targetFile,
			"session-1",
			createOutput(),
		);
		await processor.processFilePathForInjection(
			targetFile,
			"session-2",
			createOutput(),
		);

		// then
		expect(trackedReadFileCount).toBe(2);
	});

	it("re-reads file when size changes", async () => {
		// given
		statSnapshots = [
			{ mtimeMs: 1000, size: 13 },
			{ mtimeMs: 1000, size: 21 },
		];
		const processor = await createProcessor(projectRoot);

		// when
		await processor.processFilePathForInjection(
			targetFile,
			"session-1",
			createOutput(),
		);
		await processor.processFilePathForInjection(
			targetFile,
			"session-2",
			createOutput(),
		);

		// then
		expect(trackedReadFileCount).toBe(2);
	});

	it("reuses match decision when stat fingerprint and target are unchanged", async () => {
		// given
		statSnapshots = [
			{ mtimeMs: 1000, size: 13 },
			{ mtimeMs: 1000, size: 13 },
		];
		const processor = await createProcessor(projectRoot);

		// when
		await processor.processFilePathForInjection(
			targetFile,
			"session-1",
			createOutput(),
		);
		await processor.processFilePathForInjection(
			targetFile,
			"session-2",
			createOutput(),
		);

		// then
		expect(trackedShouldApplyRuleCount).toBe(1);
	});

	it("re-evaluates match decision when stat fingerprint changes", async () => {
		// given
		statSnapshots = [
			{ mtimeMs: 1000, size: 13 },
			{ mtimeMs: 2000, size: 13 },
		];
		const processor = await createProcessor(projectRoot);

		// when
		await processor.processFilePathForInjection(
			targetFile,
			"session-1",
			createOutput(),
		);
		await processor.processFilePathForInjection(
			targetFile,
			"session-2",
			createOutput(),
		);

		// then
		expect(trackedShouldApplyRuleCount).toBe(2);
	});

	it("keeps match decisions separate for different target files", async () => {
		// given
		const secondTargetFile = join(projectRoot, "src", "other.ts");
		writeFileSync(secondTargetFile, "export const other = 2;\n");
		statSnapshots = [
			{ mtimeMs: 1000, size: 13 },
			{ mtimeMs: 1000, size: 13 },
		];
		const processor = await createProcessor(projectRoot);

		// when
		await processor.processFilePathForInjection(
			targetFile,
			"session-1",
			createOutput(),
		);
		await processor.processFilePathForInjection(
			secondTargetFile,
			"session-2",
			createOutput(),
		);

		// then
		expect(trackedShouldApplyRuleCount).toBe(2);
	});

	it("does not cache oversized parsed rule bodies", async () => {
		// given
		const largeBody = "x".repeat(70 * 1024);
		writeFileSync(ruleFile, largeBody);
		statSnapshots = [
			{ mtimeMs: 1000, size: largeBody.length },
			{ mtimeMs: 1000, size: largeBody.length },
		];
		const processor = await createProcessor(projectRoot);

		// when
		await processor.processFilePathForInjection(
			targetFile,
			"session-1",
			createOutput(),
		);
		await processor.processFilePathForInjection(
			targetFile,
			"session-2",
			createOutput(),
		);

		// then
		expect(trackedReadFileCount).toBe(2);
		expect(getParsedRuleCacheStats()).toEqual({ entries: 0, bodyBytes: 0 });
	});

	it("does not save injected rules when all candidates are already cached", async () => {
		// given
		const sessionID = `dirty-no-new-${Date.now()}`;
		const injectedPath = getInjectedRulesPath(sessionID);
		if (fs.existsSync(injectedPath)) {
			fs.unlinkSync(injectedPath);
		}

		const { createRuleInjectionProcessor } = await import("./injector");
		const processor = createRuleInjectionProcessor({
			workspaceDirectory: projectRoot,
			truncator: {
				truncate: async (_sessionID: string, content: string) => ({
					result: content,
					truncated: false,
				}),
			},
			getSessionCache: () => ({
				contentHashes: new Set<string>(),
				realPaths: new Set<string>([ruleRealPath]),
			}),
		});

		// when
		await processor.processFilePathForInjection(
			targetFile,
			sessionID,
			createOutput(),
		);

		// then
		expect(fs.existsSync(injectedPath)).toBe(false);
	});

	it("saves injected rules when a new rule is added", async () => {
		// given
		const sessionID = `dirty-new-${Date.now()}`;
		const injectedPath = getInjectedRulesPath(sessionID);
		if (fs.existsSync(injectedPath)) {
			fs.unlinkSync(injectedPath);
		}
		const processor = await createProcessor(projectRoot);

		// when
		await processor.processFilePathForInjection(
			targetFile,
			sessionID,
			createOutput(),
		);

		// then
		expect(fs.existsSync(injectedPath)).toBe(true);

		if (fs.existsSync(injectedPath)) {
			fs.unlinkSync(injectedPath);
		}
	});

	it("falls back to direct read and parse when statSync throws", async () => {
		// given
		statSnapshots = [new Error("stat failed"), new Error("stat failed")];
		const processor = await createProcessor(projectRoot);

		// when
		await processor.processFilePathForInjection(
			targetFile,
			"session-1",
			createOutput(),
		);
		await processor.processFilePathForInjection(
			targetFile,
			"session-2",
			createOutput(),
		);

		// then
		expect(trackedReadFileCount).toBe(2);
		expect(trackedShouldApplyRuleCount).toBe(2);
	});
});
