import assert from "node:assert/strict";
import {
	existsSync,
	mkdirSync,
	mkdtempSync,
	readdirSync,
	readFileSync,
	rmSync,
	writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from 'vitest';

import {
	dispatchRecordPromoterFireAndForget,
	parseRecordFrontmatter,
	promoteActionableRecords,
} from "../record-promoter.ts";

// ─── Helpers ─────────────────────────────────────────────────────────────────

function makeTmpDir(): string {
	return mkdtempSync(join(tmpdir(), "sf-record-promoter-"));
}

/** Create a minimal project layout with a records dir and .sf dir. */
function makeProject(base: string): void {
	mkdirSync(join(base, "docs", "records"), { recursive: true });
	mkdirSync(join(base, ".sf", "milestones"), { recursive: true });
}

function writeRecord(base: string, name: string, content: string): string {
	const p = join(base, "docs", "records", name);
	writeFileSync(p, content, "utf-8");
	return p;
}

// ─── parseRecordFrontmatter ───────────────────────────────────────────────────

test("parseRecordFrontmatter: extracts scalar YAML fields", () => {
	const dir = makeTmpDir();
	try {
		const p = join(dir, "test.md");
		writeFileSync(
			p,
			"---\nactionable: true\nkind: bug-hunt\ndate: 2026-05-02\n---\n\n# Body\n",
			"utf-8",
		);
		const fm = parseRecordFrontmatter(p);
		assert.ok(fm !== null, "should return frontmatter");
		assert.equal(fm.actionable, true);
		assert.equal(fm.kind, "bug-hunt");
		assert.equal(fm.date, "2026-05-02");
		assert.equal(fm.promoted, undefined);
	} finally {
		rmSync(dir, { recursive: true, force: true });
	}
});

test("parseRecordFrontmatter: returns null for files without frontmatter", () => {
	const dir = makeTmpDir();
	try {
		const p = join(dir, "plain.md");
		writeFileSync(p, "# Just a heading\n\nNo frontmatter here.\n", "utf-8");
		const fm = parseRecordFrontmatter(p);
		assert.equal(fm, null);
	} finally {
		rmSync(dir, { recursive: true, force: true });
	}
});

test("parseRecordFrontmatter: returns null for non-existent file", () => {
	const fm = parseRecordFrontmatter("/tmp/__nonexistent_sf_test__.md");
	assert.equal(fm, null);
});

test("parseRecordFrontmatter: recognises promoted and promoted_to fields", () => {
	const dir = makeTmpDir();
	try {
		const p = join(dir, "promo.md");
		writeFileSync(
			p,
			"---\nactionable: true\npromoted: true\npromoted_to: M008\n---\n",
			"utf-8",
		);
		const fm = parseRecordFrontmatter(p);
		assert.ok(fm !== null);
		assert.equal(fm.promoted, true);
		assert.equal(fm.promoted_to, "M008");
	} finally {
		rmSync(dir, { recursive: true, force: true });
	}
});

// ─── promoteActionableRecords ─────────────────────────────────────────────────

test("promoteActionableRecords: skips records without actionable: true", () => {
	const dir = makeTmpDir();
	try {
		makeProject(dir);
		writeRecord(
			dir,
			"2026-01-01-no-action.md",
			"---\nkind: notes\n---\n\n## Heading\n",
		);
		const result = promoteActionableRecords(dir);
		assert.equal(result.promoted.length, 0);
		assert.equal(result.skipped.length, 1);
		assert.match(result.skipped[0].reason, /not actionable/);
	} finally {
		rmSync(dir, { recursive: true, force: true });
	}
});

test("promoteActionableRecords: skips already-promoted records", () => {
	const dir = makeTmpDir();
	try {
		makeProject(dir);
		writeRecord(
			dir,
			"2026-01-01-already.md",
			"---\nactionable: true\npromoted: true\npromoted_to: M001\n---\n\n## Heading\n",
		);
		const result = promoteActionableRecords(dir);
		assert.equal(result.promoted.length, 0);
		assert.equal(result.skipped.length, 1);
		assert.match(result.skipped[0].reason, /already promoted/);
	} finally {
		rmSync(dir, { recursive: true, force: true });
	}
});

test("promoteActionableRecords: generates milestone directory structure", () => {
	const dir = makeTmpDir();
	try {
		makeProject(dir);
		writeRecord(
			dir,
			"2026-05-02-bug-hunt.md",
			[
				"---",
				"actionable: true",
				"kind: bug-hunt",
				"---",
				"",
				"# Bug Hunt Findings",
				"",
				"## Cluster: engine",
				"",
				"Engine issues here.",
				"",
				"## Cluster: memory",
				"",
				"Memory issues here.",
				"",
			].join("\n"),
		);

		const result = promoteActionableRecords(dir);
		assert.equal(result.promoted.length, 1, "one record promoted");

		const milestoneId = result.promoted[0].milestoneId;
		assert.match(milestoneId, /^M\d{3}$/, "milestone ID format");

		const milestoneDir = join(dir, ".sf", "milestones", milestoneId);
		assert.ok(existsSync(milestoneDir), "milestone dir created");
		assert.ok(
			existsSync(join(milestoneDir, `${milestoneId}-CONTEXT.md`)),
			"CONTEXT.md created",
		);
		assert.ok(
			existsSync(join(milestoneDir, `${milestoneId}-ROADMAP.md`)),
			"ROADMAP.md created",
		);

		// Two H2 headings → two slice directories
		const slicesDir = join(milestoneDir, "slices");
		assert.ok(existsSync(slicesDir), "slices dir created");
		const sliceDirs = readDirNames(slicesDir);
		assert.equal(sliceDirs.length, 2, "two slices derived from H2 headings");
		assert.match(sliceDirs[0], /^S01-/);
		assert.match(sliceDirs[1], /^S02-/);
	} finally {
		rmSync(dir, { recursive: true, force: true });
	}
});

test("promoteActionableRecords: updates record frontmatter to mark promoted", () => {
	const dir = makeTmpDir();
	try {
		makeProject(dir);
		const recordPath = writeRecord(
			dir,
			"2026-05-02-fix-me.md",
			"---\nactionable: true\nkind: task\n---\n\n## Heading One\n",
		);

		const result = promoteActionableRecords(dir);
		assert.equal(result.promoted.length, 1);

		const updated = readFileSync(recordPath, "utf-8");
		assert.match(updated, /promoted: true/);
		assert.match(updated, /promoted_to: M\d{3}/);
	} finally {
		rmSync(dir, { recursive: true, force: true });
	}
});

test("promoteActionableRecords: appends milestone ID to .sf/QUEUE.md", () => {
	const dir = makeTmpDir();
	try {
		makeProject(dir);
		writeRecord(
			dir,
			"2026-05-02-queued.md",
			"---\nactionable: true\n---\n\n## Work\n",
		);

		const result = promoteActionableRecords(dir);
		assert.equal(result.promoted.length, 1);

		const queuePath = join(dir, ".sf", "QUEUE.md");
		assert.ok(existsSync(queuePath), "QUEUE.md created");
		const queue = readFileSync(queuePath, "utf-8");
		assert.match(queue, /M\d{3}/);
	} finally {
		rmSync(dir, { recursive: true, force: true });
	}
});

test("promoteActionableRecords: is idempotent — second call is a no-op", () => {
	const dir = makeTmpDir();
	try {
		makeProject(dir);
		writeRecord(
			dir,
			"2026-05-02-idempotent.md",
			"---\nactionable: true\n---\n\n## Heading\n",
		);

		const first = promoteActionableRecords(dir);
		assert.equal(first.promoted.length, 1);

		const second = promoteActionableRecords(dir);
		assert.equal(second.promoted.length, 0, "second call promotes nothing");
		assert.equal(second.skipped.length, 1);
		assert.match(second.skipped[0].reason, /already promoted/);

		// No extra milestones should have been created
		const milestones = readDirNames(join(dir, ".sf", "milestones"));
		assert.equal(milestones.length, 1, "only one milestone created");
	} finally {
		rmSync(dir, { recursive: true, force: true });
	}
});

test("promoteActionableRecords: returns empty result when docs/records is absent", () => {
	const dir = makeTmpDir();
	try {
		// No docs/records dir
		const result = promoteActionableRecords(dir);
		assert.equal(result.promoted.length, 0);
		assert.equal(result.skipped.length, 0);
	} finally {
		rmSync(dir, { recursive: true, force: true });
	}
});

// ─── dispatchRecordPromoterFireAndForget ──────────────────────────────────────

test("dispatchRecordPromoterFireAndForget: returns synchronously (fire-and-forget)", () => {
	const dir = makeTmpDir();
	try {
		makeProject(dir);
		// Returns void immediately — the call itself must not throw or return a
		// Promise visible to the caller.
		const ret = dispatchRecordPromoterFireAndForget(dir, undefined);
		assert.equal(ret, undefined, "return value is undefined (void)");
	} finally {
		rmSync(dir, { recursive: true, force: true });
	}
});

test("dispatchRecordPromoterFireAndForget: emits kind:notice notification when records promoted", async () => {
	const dir = makeTmpDir();
	try {
		makeProject(dir);
		writeRecord(
			dir,
			"2026-05-02-notify.md",
			"---\nactionable: true\nkind: task\n---\n\n## Slice One\n",
		);

		const notifications: Array<{
			msg: string;
			type?: string;
			metadata?: Record<string, unknown>;
		}> = [];

		const ctx = {
			ui: {
				notify(
					msg: string,
					type?: "info" | "warning" | "error" | "success",
					metadata?: Record<string, unknown>,
				) {
					notifications.push({ msg, type, metadata });
				},
			},
		};

		dispatchRecordPromoterFireAndForget(dir, ctx);

		// Allow microtask queue and async body to run
		await new Promise((resolve) => setImmediate(resolve));

		assert.equal(notifications.length, 1, "exactly one notification emitted");
		const n = notifications[0];
		assert.match(n.msg, /Promoted 1 actionable record/);
		assert.equal(n.type, "info");
		assert.equal(n.metadata?.kind, "notice", "kind must be 'notice'");
		assert.equal(n.metadata?.dedupe_key, "record-promoter", "stable dedupe_key");
		assert.equal(n.metadata?.blocking, false, "non-blocking");
	} finally {
		rmSync(dir, { recursive: true, force: true });
	}
});

test("dispatchRecordPromoterFireAndForget: does not notify when no records are promoted", async () => {
	const dir = makeTmpDir();
	try {
		makeProject(dir);
		// No records in the dir — empty no-op run

		const notifications: unknown[] = [];
		const ctx = {
			ui: {
				notify(...args: unknown[]) {
					notifications.push(args);
				},
			},
		};

		dispatchRecordPromoterFireAndForget(dir, ctx);
		await new Promise((resolve) => setImmediate(resolve));

		assert.equal(notifications.length, 0, "silent on no-op run");
	} finally {
		rmSync(dir, { recursive: true, force: true });
	}
});

test("dispatchRecordPromoterFireAndForget: failure inside promoter does not throw to caller", async () => {
	// We simulate a bad basePath that will cause an internal error — record-promoter
	// handles that gracefully (non-fatal). The dispatch itself must not throw.
	const notifications: unknown[] = [];
	const ctx = {
		ui: {
			notify(...args: unknown[]) {
				notifications.push(args);
			},
		},
	};

	// Passing a path with a null byte causes readdirSync to throw on Linux.
	// dispatchRecordPromoterFireAndForget must absorb the error without rethrowing.
	let threw = false;
	try {
		// basePath that will fail silently: docs/records won't exist → no-op, no throw
		dispatchRecordPromoterFireAndForget("/nonexistent/path/that/cant/exist", ctx);
	} catch {
		threw = true;
	}

	assert.equal(threw, false, "must not throw synchronously");

	await new Promise((resolve) => setImmediate(resolve));
	// No notification emitted for a no-op/error path
	assert.equal(notifications.length, 0);
});

// ─── Utility ──────────────────────────────────────────────────────────────────

function readDirNames(dir: string): string[] {
	try {
		return readdirSync(dir).sort();
	} catch {
		return [];
	}
}
