/**
 * Tests for scaffold-keeper (ADR-021 Phase D).
 *
 * Covers the four observable behaviors:
 *   1. editing-drift produces a .proposed file and one notification
 *   2. silent path: no editing-drift → no notification, no .proposed writes
 *   3. failures inside the dispatcher do not throw to the caller
 *   4. completed/pending state files do not trigger the dispatcher
 */
import assert from "node:assert/strict";
import {
	existsSync,
	mkdtempSync,
	readFileSync,
	rmSync,
	writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, test } from 'vitest';

import { SCAFFOLD_FILES } from "../agentic-docs-scaffold.ts";
import {
	applyOnlyFilter,
	matchesOnly,
	parseScaffoldSyncArgs,
} from "../commands-scaffold-sync.ts";
import { detectScaffoldDrift } from "../scaffold-drift.ts";
import {
	dispatchScaffoldKeeperFireAndForget,
	dispatchScaffoldKeeperIfNeeded,
} from "../scaffold-keeper.ts";
import { stampScaffoldFile } from "../scaffold-versioning.ts";

interface NotifyCall {
	message: string;
	type?: string;
	metadata?: Record<string, unknown>;
}

function makeStubCtx(): {
	ctx: {
		ui: {
			notify: (
				message: string,
				type?: "info" | "warning" | "error" | "success",
				metadata?: Record<string, unknown>,
			) => void;
		};
	};
	calls: NotifyCall[];
} {
	const calls: NotifyCall[] = [];
	const ctx = {
		ui: {
			notify: (
				message: string,
				type?: "info" | "warning" | "error" | "success",
				metadata?: Record<string, unknown>,
			) => {
				calls.push({ message, type, metadata });
			},
		},
	};
	return { ctx, calls };
}

function makeTmp(): string {
	return mkdtempSync(join(tmpdir(), "sf-scaffold-keeper-"));
}

function pickMarkdownTarget(): { path: string; content: string } {
	const f = SCAFFOLD_FILES.find((s) => s.path === "AGENTS.md");
	if (!f) throw new Error("AGENTS.md must be in SCAFFOLD_FILES for this test");
	return { path: f.path, content: f.content };
}

/**
 * Set up a file in editing-drift state: stamped marker, then body mutated.
 */
function makeEditingDrift(dir: string): string {
	const target = pickMarkdownTarget();
	const fp = join(dir, target.path);
	writeFileSync(fp, target.content, "utf-8");
	stampScaffoldFile(fp, target.path, "1.0.0");
	const stamped = readFileSync(fp, "utf-8");
	const lines = stamped.split("\n");
	const mutated =
		lines[0] + "\n" + lines.slice(1).join("\n") + "\n# user customisation\n";
	writeFileSync(fp, mutated, "utf-8");
	return fp;
}

describe("dispatchScaffoldKeeperIfNeeded", () => {
	let dir: string;
	beforeEach(() => {
		dir = makeTmp();
	});
	afterEach(() => {
		rmSync(dir, { recursive: true, force: true });
	});

	test("editing-drift produces .proposed file and one notification", async () => {
		makeEditingDrift(dir);
		const { ctx, calls } = makeStubCtx();

		const written = await dispatchScaffoldKeeperIfNeeded(dir, ctx);

		assert.equal(written, 1);
		const proposedPath = join(dir, "AGENTS.md.proposed");
		assert.ok(existsSync(proposedPath), ".proposed file must exist");
		const body = readFileSync(proposedPath, "utf-8");
		assert.match(body, /<!-- sf-proposed:/);
		assert.match(body, /skill=records-keeper/);

		assert.equal(calls.length, 1);
		assert.equal(calls[0].type, "warning");
		assert.equal(calls[0].metadata?.kind, "approval_request");
		assert.equal(calls[0].metadata?.dedupe_key, "scaffold-drift");
		assert.equal(calls[0].metadata?.source, "scaffold-keeper");
		assert.equal(calls[0].metadata?.blocking, false);
	});

	test("never overwrites the original file", async () => {
		const fp = makeEditingDrift(dir);
		const before = readFileSync(fp, "utf-8");
		const { ctx } = makeStubCtx();

		await dispatchScaffoldKeeperIfNeeded(dir, ctx);

		const after = readFileSync(fp, "utf-8");
		assert.equal(after, before, "original file must be untouched");
	});

	test("silent path: no editing-drift means no notification and no .proposed", async () => {
		// All scaffold files are missing → all classified as `missing`, none as
		// `editing-drift`. No notification, no .proposed files.
		const { ctx, calls } = makeStubCtx();

		const written = await dispatchScaffoldKeeperIfNeeded(dir, ctx);

		assert.equal(written, 0);
		assert.equal(calls.length, 0);
	});

	test("dedupe_key is stable across repeated invocations", async () => {
		makeEditingDrift(dir);
		const { ctx, calls } = makeStubCtx();

		await dispatchScaffoldKeeperIfNeeded(dir, ctx);
		await dispatchScaffoldKeeperIfNeeded(dir, ctx);

		// Both calls should fire the notification with the same dedupe_key.
		// Deduplication is the consumer's job; the keeper's contract is "stable
		// key" so the consumer can collapse them.
		assert.equal(calls.length, 2);
		assert.equal(calls[0].metadata?.dedupe_key, "scaffold-drift");
		assert.equal(calls[1].metadata?.dedupe_key, "scaffold-drift");
	});

	test("completed marker does not trigger dispatch", async () => {
		const target = pickMarkdownTarget();
		const fp = join(dir, target.path);
		writeFileSync(fp, target.content, "utf-8");
		stampScaffoldFile(fp, target.path, "1.0.0", "completed");

		const { ctx, calls } = makeStubCtx();

		const written = await dispatchScaffoldKeeperIfNeeded(dir, ctx);

		assert.equal(written, 0);
		assert.equal(calls.length, 0);
		assert.ok(!existsSync(join(dir, "AGENTS.md.proposed")));
	});

	test("pending (not drifted) does not trigger dispatch", async () => {
		const target = pickMarkdownTarget();
		const fp = join(dir, target.path);
		writeFileSync(fp, target.content, "utf-8");
		// Stamp current SF_VERSION → bucket is `customized`/`upgradable`, never editing-drift.
		stampScaffoldFile(fp, target.path, process.env.SF_VERSION || "0.0.0");

		const { ctx, calls } = makeStubCtx();

		const written = await dispatchScaffoldKeeperIfNeeded(dir, ctx);

		assert.equal(written, 0);
		assert.equal(calls.length, 0);
		assert.ok(!existsSync(join(dir, "AGENTS.md.proposed")));
	});

	test("notify failure inside dispatcher does not throw to caller", async () => {
		makeEditingDrift(dir);
		const ctx = {
			ui: {
				notify: () => {
					throw new Error("notify exploded");
				},
			},
		};

		// Must not throw despite notify throwing.
		const written = await dispatchScaffoldKeeperIfNeeded(dir, ctx);
		assert.equal(written, 1);
		assert.ok(existsSync(join(dir, "AGENTS.md.proposed")));
	});

	test("smoke check — full happy-path workflow", async () => {
		// Mirrors the ADR's verification step: stamp a file, mutate the body,
		// run the keeper, assert .proposed exists and notify was called once.
		const fp = makeEditingDrift(dir);
		assert.ok(existsSync(fp));

		const { ctx, calls } = makeStubCtx();
		const written = await dispatchScaffoldKeeperIfNeeded(dir, ctx);

		assert.equal(written, 1);
		assert.equal(calls.length, 1);
		assert.ok(existsSync(join(dir, "AGENTS.md.proposed")));
	});
});

// ─── dispatchScaffoldKeeperFireAndForget ─────────────────────────────────────

describe("dispatchScaffoldKeeperFireAndForget", () => {
	let dir: string;
	beforeEach(() => {
		dir = makeTmp();
	});
	afterEach(() => {
		rmSync(dir, { recursive: true, force: true });
	});

	test("is synchronous — returns void immediately without awaiting I/O", () => {
		const { ctx } = makeStubCtx();
		// Must return undefined synchronously; no Promise returned.
		const result = dispatchScaffoldKeeperFireAndForget(dir, ctx);
		assert.equal(result, undefined, "fire-and-forget must return void");
	});

	test("editing-drift eventually writes .proposed and notifies", async () => {
		makeEditingDrift(dir);
		const { ctx, calls } = makeStubCtx();

		dispatchScaffoldKeeperFireAndForget(dir, ctx);

		// The microtask + async I/O completes within one event-loop turn if we
		// await a resolved Promise after yielding via setImmediate.
		await new Promise<void>((resolve) => setImmediate(resolve));
		// Give the async chain a moment to settle.
		await new Promise<void>((resolve) => setImmediate(resolve));

		assert.ok(existsSync(join(dir, "AGENTS.md.proposed")), ".proposed file must exist");
		assert.equal(calls.length, 1, "exactly one notification must fire");
		assert.equal(calls[0].metadata?.kind, "approval_request");
	});

	test("silent path: no editing-drift means no notification side-effect", async () => {
		const { ctx, calls } = makeStubCtx();

		dispatchScaffoldKeeperFireAndForget(dir, ctx);

		await new Promise<void>((resolve) => setImmediate(resolve));
		await new Promise<void>((resolve) => setImmediate(resolve));

		assert.equal(calls.length, 0, "no notification on silent path");
	});

	test("notify failure does not propagate to caller", async () => {
		makeEditingDrift(dir);
		const throwingCtx = {
			ui: { notify: () => { throw new Error("notify exploded"); } },
		};

		// Must not throw synchronously or asynchronously.
		dispatchScaffoldKeeperFireAndForget(dir, throwingCtx);
		await new Promise<void>((resolve) => setImmediate(resolve));
		await new Promise<void>((resolve) => setImmediate(resolve));

		// Still wrote the file — failure is in notify only, not in the write.
		assert.ok(existsSync(join(dir, "AGENTS.md.proposed")));
	});
});

// ─── ADR-021 Phase E: /sf scaffold sync command ─────────────────────────────

describe("parseScaffoldSyncArgs", () => {
	test("empty input → all-false defaults", () => {
		const opts = parseScaffoldSyncArgs("");
		assert.equal(opts.dryRun, false);
		assert.equal(opts.includeEditing, false);
		assert.equal(opts.only, undefined);
	});

	test("--dry-run sets dryRun", () => {
		const opts = parseScaffoldSyncArgs("--dry-run");
		assert.equal(opts.dryRun, true);
	});

	test("--include-editing sets includeEditing", () => {
		const opts = parseScaffoldSyncArgs("--include-editing");
		assert.equal(opts.includeEditing, true);
	});

	test("--only=<glob> captures the glob value", () => {
		const opts = parseScaffoldSyncArgs("--only=harness/**");
		assert.equal(opts.only, "harness/**");
	});

	test("flags compose without ordering constraints", () => {
		const opts = parseScaffoldSyncArgs(
			"--include-editing --only=docs/RELIABILITY.md --dry-run",
		);
		assert.equal(opts.dryRun, true);
		assert.equal(opts.includeEditing, true);
		assert.equal(opts.only, "docs/RELIABILITY.md");
	});

	test("--only= with empty value is ignored", () => {
		const opts = parseScaffoldSyncArgs("--only=");
		assert.equal(opts.only, undefined);
	});
});

describe("matchesOnly", () => {
	test("undefined glob matches everything", () => {
		assert.equal(matchesOnly("docs/RELIABILITY.md", undefined), true);
	});

	test("exact path matches", () => {
		assert.equal(matchesOnly("AGENTS.md", "AGENTS.md"), true);
	});

	test("prefix match", () => {
		assert.equal(matchesOnly("harness/specs/bootstrap.md", "harness/"), true);
		assert.equal(matchesOnly("docs/AGENTS.md", "harness/"), false);
	});

	test("suffix match", () => {
		assert.equal(matchesOnly("docs/RELIABILITY.md", "RELIABILITY.md"), true);
	});

	test("wildcard *", () => {
		assert.equal(matchesOnly("harness/specs/bootstrap.md", "harness/**"), true);
		assert.equal(matchesOnly("harness/specs/AGENTS.md", "harness/*"), true);
		assert.equal(matchesOnly("docs/RELIABILITY.md", "harness/**"), false);
	});
});

describe("applyOnlyFilter", () => {
	test("undefined glob returns the report unchanged", () => {
		const before = detectScaffoldDrift(mkdtempSync(join(tmpdir(), "sf-only-")));
		const after = applyOnlyFilter(before, undefined);
		assert.equal(after, before);
	});

	test("filter narrows items and recomputes counts", () => {
		const subdir = mkdtempSync(join(tmpdir(), "sf-only-"));
		const before = detectScaffoldDrift(subdir);
		const after = applyOnlyFilter(before, "AGENTS.md");
		assert.ok(after.items.length >= 1);
		// All retained items must end with AGENTS.md per the suffix-match rule.
		for (const item of after.items) {
			assert.ok(item.path.endsWith("AGENTS.md"));
		}
		// Counts must be consistent with retained items.
		const total = Object.values(after.countsByBucket).reduce(
			(a, b) => a + b,
			0,
		);
		assert.equal(total, after.items.length);
		rmSync(subdir, { recursive: true, force: true });
	});
});

/**
 * Smoke tests for `handleScaffoldSync`. The handler calls `projectRoot()`,
 * which derives from `process.cwd()`/`SF_PROJECT_ROOT` and runs
 * `validateDirectory`. We isolate by setting `SF_PROJECT_ROOT` to a tmpdir,
 * which short-circuits `resolveProjectRoot` and keeps validation inside a
 * known-safe path.
 */
describe("handleScaffoldSync (Phase E smoke)", () => {
	let dir2: string;
	let prevRoot: string | undefined;
	beforeEach(() => {
		dir2 = mkdtempSync(join(tmpdir(), "sf-scaffold-sync-"));
		prevRoot = process.env.SF_PROJECT_ROOT;
		process.env.SF_PROJECT_ROOT = dir2;
	});
	afterEach(() => {
		if (prevRoot === undefined) {
			delete process.env.SF_PROJECT_ROOT;
		} else {
			process.env.SF_PROJECT_ROOT = prevRoot;
		}
		rmSync(dir2, { recursive: true, force: true });
	});

	test("--dry-run emits a report and does not write any scaffold files", async () => {
		const { handleScaffoldSync } = await import(
			"../commands-scaffold-sync.ts"
		);
		const { ctx, calls } = makeStubCtx();

		await handleScaffoldSync("--dry-run", ctx as never);

		assert.equal(calls.length, 1);
		assert.match(calls[0].message, /Scaffold drift report:/);
		// No files should have been written by the dry-run path.
		assert.ok(!existsSync(join(dir2, "AGENTS.md")));
		assert.ok(!existsSync(join(dir2, "ARCHITECTURE.md")));
	});

	test("default invocation calls ensureAgenticDocsScaffold (writes scaffold files)", async () => {
		const { handleScaffoldSync } = await import(
			"../commands-scaffold-sync.ts"
		);
		const { ctx, calls } = makeStubCtx();

		await handleScaffoldSync("", ctx as never);

		assert.ok(calls.length >= 1);
		// Default sync is the same code path as automatic startup sync, so the
		// scaffold files should exist on disk after one invocation.
		assert.ok(existsSync(join(dir2, "AGENTS.md")));
		assert.ok(existsSync(join(dir2, "ARCHITECTURE.md")));
	});

	test("--only filter narrows output and still applies sync", async () => {
		const { handleScaffoldSync } = await import(
			"../commands-scaffold-sync.ts"
		);
		const { ctx, calls } = makeStubCtx();

		await handleScaffoldSync("--dry-run --only=harness/", ctx as never);

		assert.equal(calls.length, 1);
		// The drift report should not contain non-harness files. Counts under the
		// filter must be consistent: total of bucket counts matches a small
		// fraction of SCAFFOLD_FILES (those starting with `harness/`).
		const harnessCount = SCAFFOLD_FILES.filter((f) =>
			f.path.startsWith("harness/"),
		).length;
		const message = calls[0].message;
		// Sanity: the table prints the harness-only counts; validate by parsing
		// the count lines and asserting their sum equals harnessCount.
		const counts = ["Missing", "Pending", "Editing-drift", "Untracked", "Customized"]
			.map((label) => {
				const m = message.match(new RegExp(`${label}\\s*:\\s*(\\d+)`));
				return m ? Number.parseInt(m[1], 10) : 0;
			})
			.reduce((a, b) => a + b, 0);
		assert.equal(counts, harnessCount);
	});

	test("--include-editing without editing-drift items reports nothing to merge", async () => {
		const { handleScaffoldSync } = await import(
			"../commands-scaffold-sync.ts"
		);
		// First populate the scaffold so all files exist in pending state.
		const { ctx: warmCtx } = makeStubCtx();
		await handleScaffoldSync("", warmCtx as never);

		const { ctx, calls } = makeStubCtx();
		await handleScaffoldSync("--include-editing", ctx as never);

		// Should produce a report notification + a "no editing-drift" notice.
		const messages = calls.map((c) => c.message).join("\n");
		assert.match(messages, /No editing-drift items to merge\./);
	});
});
