import assert from "node:assert/strict";
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, test } from "vitest";
import {
	discoverExtensionEntryPaths,
	resolveExtensionEntries,
} from "../extension-discovery.ts";

function makeTempDir(): string {
	const dir = join(
		tmpdir(),
		`ext-discovery-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
	);
	mkdirSync(dir, { recursive: true });
	return dir;
}

describe("resolveExtensionEntries", () => {
	test("returns index.ts when no package.json exists", (_t) => {
		const dir = makeTempDir();
		afterEach(() => rmSync(dir, { recursive: true, force: true }));
		writeFileSync(join(dir, "index.ts"), "export default function() {}");
		const entries = resolveExtensionEntries(dir);
		assert.equal(entries.length, 1);
		assert.ok(entries[0].endsWith("index.ts"));
	});

	test("returns index.js when no package.json and no index.ts", (_t) => {
		const dir = makeTempDir();
		afterEach(() => rmSync(dir, { recursive: true, force: true }));
		writeFileSync(join(dir, "index.js"), "module.exports = function() {}");
		const entries = resolveExtensionEntries(dir);
		assert.equal(entries.length, 1);
		assert.ok(entries[0].endsWith("index.js"));
	});

	test("returns declared extensions from pi.extensions array", (_t) => {
		const dir = makeTempDir();
		afterEach(() => rmSync(dir, { recursive: true, force: true }));
		writeFileSync(
			join(dir, "package.json"),
			JSON.stringify({
				pi: { extensions: ["main.js"] },
			}),
		);
		writeFileSync(join(dir, "main.js"), "module.exports = function() {}");
		writeFileSync(join(dir, "index.js"), "should not be returned");
		const entries = resolveExtensionEntries(dir);
		assert.equal(entries.length, 1);
		assert.ok(entries[0].endsWith("main.js"));
	});

	test("returns empty array when pi manifest has no extensions (library opt-out)", (_t) => {
		const dir = makeTempDir();
		afterEach(() => rmSync(dir, { recursive: true, force: true }));
		writeFileSync(
			join(dir, "package.json"),
			JSON.stringify({
				name: "@singularity-forge/cmux",
				pi: {},
			}),
		);
		writeFileSync(join(dir, "index.js"), "export function utility() {}");
		const entries = resolveExtensionEntries(dir);
		assert.equal(
			entries.length,
			0,
			"pi: {} should opt out of extension discovery",
		);
	});

	test("returns empty array when pi.extensions is an empty array", (_t) => {
		const dir = makeTempDir();
		afterEach(() => rmSync(dir, { recursive: true, force: true }));
		writeFileSync(
			join(dir, "package.json"),
			JSON.stringify({
				pi: { extensions: [] },
			}),
		);
		writeFileSync(join(dir, "index.js"), "should not be returned");
		const entries = resolveExtensionEntries(dir);
		assert.equal(entries.length, 0);
	});

	test("falls back to index.ts when package.json has no pi field", (_t) => {
		const dir = makeTempDir();
		afterEach(() => rmSync(dir, { recursive: true, force: true }));
		writeFileSync(
			join(dir, "package.json"),
			JSON.stringify({ name: "some-pkg" }),
		);
		writeFileSync(join(dir, "index.ts"), "export default function() {}");
		const entries = resolveExtensionEntries(dir);
		assert.equal(entries.length, 1);
		assert.ok(entries[0].endsWith("index.ts"));
	});
});

describe("discoverExtensionEntryPaths", () => {
	test("ignores TypeScript declaration files in top-level extensions directory", (_t) => {
		const root = makeTempDir();
		afterEach(() => rmSync(root, { recursive: true, force: true }));
		writeFileSync(join(root, "ask-user-questions.d.ts"), "export {}");
		writeFileSync(join(root, "get-secrets-from-user.d.ts"), "export {}");
		writeFileSync(
			join(root, "real-extension.ts"),
			"export default function() {}",
		);

		const paths = discoverExtensionEntryPaths(root);
		assert.deepEqual(paths.map((p) => p.split(/[\\/]/).at(-1)).sort(), [
			"real-extension.ts",
		]);
	});

	test("skips library directories with pi: {} opt-out", (_t) => {
		const root = makeTempDir();
		afterEach(() => rmSync(root, { recursive: true, force: true }));
		// Real extension
		const extDir = join(root, "my-ext");
		mkdirSync(extDir);
		writeFileSync(join(extDir, "index.js"), "module.exports = function() {}");

		// Library with opt-out (like cmux)
		const libDir = join(root, "cmux");
		mkdirSync(libDir);
		writeFileSync(join(libDir, "package.json"), JSON.stringify({ pi: {} }));
		writeFileSync(join(libDir, "index.js"), "export function utility() {}");

		const paths = discoverExtensionEntryPaths(root);
		assert.equal(paths.length, 1, "should discover my-ext but skip cmux");
		assert.ok(paths[0].includes("my-ext"));
		assert.ok(
			!paths.some((p) => p.includes("cmux")),
			"cmux should not be discovered",
		);
	});

	test("deduplicates directories when a symlink points to an already-discovered real dir", (_t) => {
		const root = makeTempDir();
		afterEach(() => rmSync(root, { recursive: true, force: true }));

		// Real extension directory
		const extDir = join(root, "sf");
		mkdirSync(extDir);
		writeFileSync(join(extDir, "index.js"), "export default function() {}");

		// Symlink pointing to the same directory under a different name
		const linkDir = join(root, "sf-link");
		symlinkSync(extDir, linkDir, "dir");

		const paths = discoverExtensionEntryPaths(root);
		// Should only return index.js once, not twice
		const indexJsPaths = paths.filter((p) => p.endsWith("index.js"));
		assert.equal(
			indexJsPaths.length,
			1,
			`Expected 1 index.js entry but got ${indexJsPaths.length}: ${JSON.stringify(indexJsPaths)}`,
		);
	});

	test("deduplicates top-level file extensions when a symlink aliases a real .js file", (_t) => {
		const root = makeTempDir();
		afterEach(() => rmSync(root, { recursive: true, force: true }));

		// Real top-level extension file
		writeFileSync(join(root, "ask-user-questions.js"), "export {}");

		// Symlink to the same file under a different name
		symlinkSync(
			join(root, "ask-user-questions.js"),
			join(root, "ask-user-questions-alias.js"),
		);

		const paths = discoverExtensionEntryPaths(root);
		// Both filenames resolve to the same inode → deduplicated to 1
		assert.equal(
			paths.length,
			1,
			`Expected 1 entry after dedup but got ${paths.length}: ${JSON.stringify(paths)}`,
		);
	});
});
