/**
 * Tests for bundle utility functions.
 *
 * Focuses on the functions where bugs would be non-obvious:
 * - Tarball round-trip (custom tar implementation)
 * - Manifest extraction (shape transformation, function stripping)
 * - Source entry resolution (path mapping logic)
 * - Node.js built-in detection (regex against bundled output)
 */

import { execSync } from "node:child_process";
import { mkdtemp, rm, writeFile, mkdir, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";

import {
	extractManifest,
	createTarball,
	resolveSourceEntry,
	findNodeBuiltinImports,
	findBuildOutput,
} from "../../../src/cli/commands/bundle-utils.js";
import type { ResolvedPlugin } from "../../../src/plugins/types.js";

function mockPlugin(overrides: Partial<ResolvedPlugin> = {}): ResolvedPlugin {
	return {
		id: "test-plugin",
		version: "1.0.0",
		capabilities: [],
		allowedHosts: [],
		storage: {},
		hooks: {},
		routes: {},
		admin: { pages: [], widgets: [] },
		...overrides,
	};
}

describe("extractManifest", () => {
	it("converts hooks from handler objects to name array", () => {
		const plugin = mockPlugin({
			hooks: {
				"content:beforeSave": {
					handler: vi.fn(),
					priority: 100,
					timeout: 5000,
					dependencies: [],
					errorPolicy: "abort",
					pluginId: "test",
					exclusive: false,
				},
				"media:afterUpload": {
					handler: vi.fn(),
					priority: 50,
					timeout: 5000,
					dependencies: [],
					errorPolicy: "abort",
					pluginId: "test",
					exclusive: false,
				},
			},
		});

		const manifest = extractManifest(plugin);
		// content:beforeSave has all defaults → plain string
		// media:afterUpload has non-default priority → structured entry
		expect(manifest.hooks).toEqual([
			"content:beforeSave",
			{ name: "media:afterUpload", priority: 50 },
		]);
	});

	it("converts routes from handler objects to name array", () => {
		const plugin = mockPlugin({
			routes: {
				sync: { handler: vi.fn() },
				webhook: { handler: vi.fn() },
			},
		});

		const manifest = extractManifest(plugin);
		expect(manifest.routes).toEqual(["sync", "webhook"]);
	});

	it("strips admin.entry (host-only concern, not in bundles)", () => {
		const plugin = mockPlugin({
			admin: {
				entry: "@test/plugin/admin",
				settingsSchema: { apiKey: { type: "string", label: "Key" } as any },
				pages: [{ id: "settings", title: "Settings" }],
				widgets: [],
			},
		});

		const manifest = extractManifest(plugin);
		expect((manifest.admin as any).entry).toBeUndefined();
		expect(manifest.admin.settingsSchema).toBeDefined();
		expect(manifest.admin.pages).toHaveLength(1);
	});

	it("result is JSON-serializable (no functions survive)", () => {
		const plugin = mockPlugin({
			hooks: {
				"content:beforeSave": {
					handler: vi.fn(),
					priority: 100,
					timeout: 5000,
					dependencies: [],
					errorPolicy: "abort",
					pluginId: "test",
					exclusive: false,
				},
			},
			routes: { sync: { handler: vi.fn() } },
		});

		const manifest = extractManifest(plugin);
		const json = JSON.stringify(manifest);
		const parsed = JSON.parse(json);

		expect(parsed.hooks).toEqual(["content:beforeSave"]);
		expect(parsed.routes).toEqual(["sync"]);
	});
});

describe("createTarball", () => {
	let tempDir: string;

	beforeEach(async () => {
		tempDir = await mkdtemp(join(tmpdir(), "emdash-tar-test-"));
	});

	afterEach(async () => {
		await rm(tempDir, { recursive: true, force: true });
	});

	it("produces a tarball that system tar can list", async () => {
		const srcDir = join(tempDir, "src");
		await mkdir(srcDir);
		await writeFile(join(srcDir, "manifest.json"), '{"id":"test"}');
		await writeFile(join(srcDir, "backend.js"), "export default {}");

		const out = join(tempDir, "out.tar.gz");
		await createTarball(srcDir, out);

		const listing = execSync(`tar tzf "${out}"`, { encoding: "utf-8" });
		const files = listing.trim().split("\n").toSorted();
		expect(files).toContain("manifest.json");
		expect(files).toContain("backend.js");
	});

	it("preserves file content through pack/unpack", async () => {
		const srcDir = join(tempDir, "src");
		await mkdir(srcDir);
		const content = JSON.stringify({ id: "round-trip", version: "2.0.0" });
		await writeFile(join(srcDir, "manifest.json"), content);

		const out = join(tempDir, "out.tar.gz");
		await createTarball(srcDir, out);

		const extractDir = join(tempDir, "extract");
		await mkdir(extractDir);
		execSync(`tar xzf "${out}" -C "${extractDir}"`);

		expect(await readFile(join(extractDir, "manifest.json"), "utf-8")).toBe(content);
	});

	it("handles nested directories (screenshots/)", async () => {
		const srcDir = join(tempDir, "src");
		await mkdir(join(srcDir, "screenshots"), { recursive: true });
		await writeFile(join(srcDir, "manifest.json"), "{}");
		await writeFile(join(srcDir, "screenshots", "shot1.png"), "fake");

		const out = join(tempDir, "out.tar.gz");
		await createTarball(srcDir, out);

		const listing = execSync(`tar tzf "${out}"`, { encoding: "utf-8" });
		expect(listing).toContain("screenshots/shot1.png");
	});

	it("handles binary content without corruption", async () => {
		const srcDir = join(tempDir, "src");
		await mkdir(srcDir);
		// Write bytes that would break text-mode handling
		const binary = Buffer.from([0x00, 0xff, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
		await writeFile(join(srcDir, "icon.png"), binary);

		const out = join(tempDir, "out.tar.gz");
		await createTarball(srcDir, out);

		const extractDir = join(tempDir, "extract");
		await mkdir(extractDir);
		execSync(`tar xzf "${out}" -C "${extractDir}"`);

		const extracted = await readFile(join(extractDir, "icon.png"));
		expect(extracted.equals(binary)).toBe(true);
	});
});

describe("resolveSourceEntry", () => {
	let tempDir: string;

	beforeEach(async () => {
		tempDir = await mkdtemp(join(tmpdir(), "emdash-resolve-test-"));
	});

	afterEach(async () => {
		await rm(tempDir, { recursive: true, force: true });
	});

	it("maps ./dist/index.mjs → src/index.ts", async () => {
		await mkdir(join(tempDir, "src"), { recursive: true });
		await writeFile(join(tempDir, "src", "index.ts"), "");

		const result = await resolveSourceEntry(tempDir, "./dist/index.mjs");
		expect(result).toBe(join(tempDir, "src", "index.ts"));
	});

	it("maps ./dist/index.js → src/index.ts", async () => {
		await mkdir(join(tempDir, "src"), { recursive: true });
		await writeFile(join(tempDir, "src", "index.ts"), "");

		const result = await resolveSourceEntry(tempDir, "./dist/index.js");
		expect(result).toBe(join(tempDir, "src", "index.ts"));
	});

	it("falls back to .tsx when .ts doesn't exist", async () => {
		await mkdir(join(tempDir, "src"), { recursive: true });
		await writeFile(join(tempDir, "src", "index.tsx"), "");

		const result = await resolveSourceEntry(tempDir, "./dist/index.mjs");
		expect(result).toBe(join(tempDir, "src", "index.tsx"));
	});

	it("returns the direct path if it already exists", async () => {
		await mkdir(join(tempDir, "src"), { recursive: true });
		await writeFile(join(tempDir, "src", "index.ts"), "");

		const result = await resolveSourceEntry(tempDir, "src/index.ts");
		expect(result).toBe(join(tempDir, "src", "index.ts"));
	});

	it("returns undefined when nothing matches", async () => {
		const result = await resolveSourceEntry(tempDir, "./dist/missing.mjs");
		expect(result).toBeUndefined();
	});
});

describe("findBuildOutput", () => {
	let tempDir: string;

	beforeEach(async () => {
		tempDir = await mkdtemp(join(tmpdir(), "emdash-build-test-"));
	});

	afterEach(async () => {
		await rm(tempDir, { recursive: true, force: true });
	});

	it("prefers .mjs over .js", async () => {
		await writeFile(join(tempDir, "index.mjs"), "");
		await writeFile(join(tempDir, "index.js"), "");

		expect(await findBuildOutput(tempDir, "index")).toBe(join(tempDir, "index.mjs"));
	});

	it("falls back through .js then .cjs", async () => {
		await writeFile(join(tempDir, "index.cjs"), "");
		expect(await findBuildOutput(tempDir, "index")).toBe(join(tempDir, "index.cjs"));
	});

	it("returns undefined when no match", async () => {
		expect(await findBuildOutput(tempDir, "index")).toBeUndefined();
	});
});

describe("findNodeBuiltinImports", () => {
	it("detects require('node:fs') in bundled output", () => {
		expect(findNodeBuiltinImports(`const fs = require("node:fs");`)).toEqual(["fs"]);
	});

	it("detects require('fs') without node: prefix", () => {
		expect(findNodeBuiltinImports(`const fs = require("fs");`)).toEqual(["fs"]);
	});

	it("detects dynamic import('node:child_process')", () => {
		expect(findNodeBuiltinImports(`await import("node:child_process")`)).toEqual(["child_process"]);
	});

	it("returns empty for code with no builtins", () => {
		expect(findNodeBuiltinImports(`import("emdash"); require("lodash");`)).toEqual([]);
	});

	it("deduplicates repeated requires", () => {
		const code = `require("node:fs"); require("node:fs");`;
		expect(findNodeBuiltinImports(code)).toEqual(["fs"]);
	});
});
