/**
 * Tests for scaffold-versioning (ADR-021 Phase A + Phase C).
 *
 * Phase A: marker parse/format round-trip, stamp file behavior (new and replace),
 * body hash determinism, manifest read/write/dedup.
 *
 * Phase C: drift-aware ensureAgenticDocsScaffold, migrateLegacyScaffold against
 * the seeded SCAFFOLD_VERSION_ARCHIVE, and the silent-path edge cases.
 */
import assert from "node:assert/strict";
import {
	existsSync,
	mkdirSync,
	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 {
	ensureAgenticDocsScaffold,
	SCAFFOLD_FILES,
} from "../agentic-docs-scaffold.ts";
import {
	migrateLegacyScaffold,
	SCAFFOLD_VERSION_ARCHIVE,
} from "../scaffold-drift.ts";
import {
	bodyHash,
	extractMarker,
	formatMarker,
	parseMarker,
	readScaffoldManifest,
	recordScaffoldApply,
	SCAFFOLD_MANIFEST_RELPATH,
	stampScaffoldFile,
	writeScaffoldManifest,
	type ScaffoldManifestEntry,
	type ScaffoldMarker,
} from "../scaffold-versioning.ts";

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

// ─── parseMarker / formatMarker ──────────────────────────────────────────────

describe("parseMarker", () => {
	test("parses a well-formed marker", () => {
		const m = parseMarker(
			"<!-- sf-doc: version=2.75.2 template=AGENTS.md state=pending hash=sha256:abc -->",
		);
		assert.deepEqual(m, {
			version: "2.75.2",
			template: "AGENTS.md",
			state: "pending",
			hash: "sha256:abc",
		});
	});

	test("tolerates extra whitespace", () => {
		const m = parseMarker(
			"   <!-- sf-doc:  version=1.0.0   template=X.md   state=editing   hash=sha256:def  -->   ",
		);
		assert.equal(m?.state, "editing");
	});

	test("ignores unknown extra fields", () => {
		const m = parseMarker(
			"<!-- sf-doc: version=1 template=X.md state=pending hash=sha256:0 future=ok -->",
		);
		assert.ok(m);
		assert.equal(m?.template, "X.md");
	});

	test("returns null on missing prefix", () => {
		assert.equal(parseMarker("<!-- not-sf-doc: version=1 -->"), null);
	});

	test("returns null on invalid state", () => {
		const m = parseMarker(
			"<!-- sf-doc: version=1 template=X state=bogus hash=sha256:abc -->",
		);
		assert.equal(m, null);
	});

	test("returns null on missing required field", () => {
		const m = parseMarker(
			"<!-- sf-doc: version=1 template=X state=pending -->",
		);
		assert.equal(m, null);
	});

	test("returns null on non-string input", () => {
		assert.equal(parseMarker(undefined as unknown as string), null);
	});
});

describe("formatMarker round-trip", () => {
	test("formatMarker → parseMarker preserves all fields", () => {
		const original: ScaffoldMarker = {
			version: "2.75.2",
			template: "harness/specs/bootstrap.md",
			state: "completed",
			hash: "sha256:f00ba4",
		};
		const formatted = formatMarker(original);
		const parsed = parseMarker(formatted);
		assert.deepEqual(parsed, original);
	});

	test("formatMarker emits a single line terminated with newline", () => {
		const out = formatMarker({
			version: "1",
			template: "X.md",
			state: "pending",
			hash: "sha256:0",
		});
		assert.ok(out.endsWith("\n"));
		// Should contain exactly one newline (the trailing terminator)
		assert.equal(out.split("\n").length, 2);
	});
});

// ─── bodyHash ────────────────────────────────────────────────────────────────

describe("bodyHash", () => {
	test("returns the same hash for the same body", () => {
		const a = bodyHash("# Hello\n\nworld\n");
		const b = bodyHash("# Hello\n\nworld\n");
		assert.equal(a, b);
		assert.match(a, /^sha256:[0-9a-f]{64}$/);
	});

	test("different bodies hash differently", () => {
		assert.notEqual(bodyHash("a"), bodyHash("b"));
	});

	test("normalizes a single leading newline", () => {
		// stamp prepends a newline-terminated marker line, so the body as
		// written has a leading newline boundary; bodyHash strips one for
		// stability across representations.
		assert.equal(bodyHash("body\n"), bodyHash("\nbody\n"));
	});
});

// ─── stampScaffoldFile ───────────────────────────────────────────────────────

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

	test("on a missing file, writes marker + content", () => {
		const path = join(dir, "AGENTS.md");
		writeFileSync(path, "# Agent Map\n\nbody\n", "utf-8");
		stampScaffoldFile(path, "AGENTS.md", "2.75.2");

		const raw = readFileSync(path, "utf-8");
		const lines = raw.split("\n");
		const marker = parseMarker(lines[0]);
		assert.ok(marker, "marker should be parseable");
		assert.equal(marker?.template, "AGENTS.md");
		assert.equal(marker?.version, "2.75.2");
		assert.equal(marker?.state, "pending");

		const body = lines.slice(1).join("\n");
		assert.match(body, /# Agent Map/);
	});

	test("on existing-with-marker file, replaces marker + preserves body", () => {
		const path = join(dir, "X.md");
		writeFileSync(
			path,
			[
				"<!-- sf-doc: version=1.0.0 template=X.md state=pending hash=sha256:old -->",
				"# Heading",
				"body content",
			].join("\n"),
			"utf-8",
		);
		stampScaffoldFile(path, "X.md", "2.0.0");

		const raw = readFileSync(path, "utf-8");
		const lines = raw.split("\n");
		const marker = parseMarker(lines[0]);
		assert.equal(marker?.version, "2.0.0");
		assert.match(raw, /# Heading/);
		assert.match(raw, /body content/);
	});

	test("hash in marker matches bodyHash of extracted body", () => {
		const path = join(dir, "Y.md");
		writeFileSync(path, "# Y\n\nlorem ipsum\n", "utf-8");
		stampScaffoldFile(path, "Y.md", "1.0.0");

		const { marker, body } = extractMarker(path);
		assert.ok(marker);
		assert.equal(marker?.hash, bodyHash(body));
	});

	test("creates parent directories when needed", () => {
		const path = join(dir, "nested/deep/F.md");
		mkdirSync(join(dir, "nested/deep"), { recursive: true });
		writeFileSync(path, "body\n", "utf-8");
		stampScaffoldFile(path, "nested/deep/F.md", "0.1.0");
		const { marker } = extractMarker(path);
		assert.ok(marker);
	});
});

// ─── extractMarker ───────────────────────────────────────────────────────────

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

	test("file without marker returns null marker, body = full content", () => {
		const path = join(dir, "plain.md");
		writeFileSync(path, "# Plain\n\nno marker here\n", "utf-8");
		const { marker, body } = extractMarker(path);
		assert.equal(marker, null);
		assert.match(body, /# Plain/);
	});

	test("missing file returns null marker, empty body", () => {
		const path = join(dir, "does-not-exist.md");
		const { marker, body } = extractMarker(path);
		assert.equal(marker, null);
		assert.equal(body, "");
	});
});

// ─── manifest ────────────────────────────────────────────────────────────────

describe("scaffold manifest", () => {
	let dir: string;
	beforeEach(() => {
		dir = makeTmp();
		mkdirSync(join(dir, ".sf"), { recursive: true });
	});
	afterEach(() => {
		rmSync(dir, { recursive: true, force: true });
	});

	test("readScaffoldManifest on missing file returns empty manifest", () => {
		const m = readScaffoldManifest(dir);
		assert.equal(m.schemaVersion, 1);
		assert.deepEqual(m.applied, []);
	});

	test("readScaffoldManifest on corrupt JSON returns empty manifest, doesn't throw", () => {
		writeFileSync(join(dir, SCAFFOLD_MANIFEST_RELPATH), "not json", "utf-8");
		const m = readScaffoldManifest(dir);
		assert.deepEqual(m.applied, []);
	});

	test("writeScaffoldManifest persists, readScaffoldManifest reads back", () => {
		const entry: ScaffoldManifestEntry = {
			path: "AGENTS.md",
			template: "AGENTS.md",
			version: "2.75.2",
			appliedAt: new Date().toISOString(),
			stateAtApply: "pending",
			contentHash: "sha256:abc",
		};
		writeScaffoldManifest(dir, { schemaVersion: 1, applied: [entry] });
		const m = readScaffoldManifest(dir);
		assert.equal(m.applied.length, 1);
		assert.equal(m.applied[0].path, "AGENTS.md");
	});

	test("recordScaffoldApply deduplicates by path (latest wins)", () => {
		const entry1: ScaffoldManifestEntry = {
			path: "AGENTS.md",
			template: "AGENTS.md",
			version: "1.0.0",
			appliedAt: "2026-01-01T00:00:00Z",
			stateAtApply: "pending",
			contentHash: "sha256:old",
		};
		const entry2: ScaffoldManifestEntry = {
			...entry1,
			version: "2.0.0",
			contentHash: "sha256:new",
		};
		recordScaffoldApply(dir, entry1);
		recordScaffoldApply(dir, entry2);
		const m = readScaffoldManifest(dir);
		assert.equal(m.applied.length, 1);
		assert.equal(m.applied[0].version, "2.0.0");
		assert.equal(m.applied[0].contentHash, "sha256:new");
	});

	test("recordScaffoldApply preserves entries for other paths", () => {
		const a: ScaffoldManifestEntry = {
			path: "A.md",
			template: "A.md",
			version: "1",
			appliedAt: "t",
			stateAtApply: "pending",
			contentHash: "sha256:1",
		};
		const b: ScaffoldManifestEntry = {
			...a,
			path: "B.md",
			template: "B.md",
			contentHash: "sha256:2",
		};
		recordScaffoldApply(dir, a);
		recordScaffoldApply(dir, b);
		const m = readScaffoldManifest(dir);
		assert.equal(m.applied.length, 2);
	});
});

// ─── Phase C: drift-aware ensureAgenticDocsScaffold ──────────────────────

/** AGENTS.md template entry — used by Phase C tests. Asserted to exist. */
function getAgentsMdTemplate() {
	const file = SCAFFOLD_FILES.find((f) => f.path === "AGENTS.md");
	if (!file) throw new Error("AGENTS.md missing from SCAFFOLD_FILES");
	return file;
}

describe("Phase C: ensureAgenticDocsScaffold drift-aware sync", () => {
	let dir: string;
	let prevVersion: string | undefined;

	beforeEach(() => {
		dir = makeTmp();
		// Pin SF_VERSION so the archive seeding and stamp behaviour are
		// deterministic regardless of host environment.
		prevVersion = process.env.SF_VERSION;
		process.env.SF_VERSION = "9.9.9";
	});

	afterEach(() => {
		rmSync(dir, { recursive: true, force: true });
		if (prevVersion === undefined) delete process.env.SF_VERSION;
		else process.env.SF_VERSION = prevVersion;
	});

	test("on empty dir, creates all SCAFFOLD_FILES with markers and manifest entries", () => {
		ensureAgenticDocsScaffold(dir);

		// Every entry in SCAFFOLD_FILES exists on disk.
		for (const file of SCAFFOLD_FILES) {
			assert.ok(
				existsSync(join(dir, file.path)),
				`expected ${file.path} to exist`,
			);
		}

		// Markdown templates have first-line markers stamped to current version.
		const agents = getAgentsMdTemplate();
		const { marker } = extractMarker(join(dir, agents.path));
		assert.ok(marker, "AGENTS.md should be stamped");
		assert.equal(marker?.version, "9.9.9");
		assert.equal(marker?.state, "pending");

		// Manifest captures every (markered + non-markered) scaffold file.
		const manifest = readScaffoldManifest(dir);
		assert.equal(manifest.applied.length, SCAFFOLD_FILES.length);
	});

	test("on dir with old-version pending marker, silently re-renders to current", () => {
		const agents = getAgentsMdTemplate();
		const target = join(dir, agents.path);

		// Plant the file with current template body but stamped to an older version.
		writeFileSync(target, agents.content, "utf-8");
		stampScaffoldFile(target, agents.path, "0.1.0", "pending");

		// Confirm pre-state.
		const before = extractMarker(target).marker;
		assert.equal(before?.version, "0.1.0");

		ensureAgenticDocsScaffold(dir);

		const after = extractMarker(target).marker;
		assert.equal(after?.version, "9.9.9", "marker should be restamped");
		assert.equal(after?.state, "pending");
		// Body should match the current template content.
		const { body } = extractMarker(target);
		assert.equal(body, agents.content);
	});

	test("on dir with editing-drift file (marker + diverged body), leaves untouched", () => {
		const agents = getAgentsMdTemplate();
		const target = join(dir, agents.path);

		// Stamp at old version then user edited the body so the hash diverges.
		writeFileSync(target, agents.content, "utf-8");
		stampScaffoldFile(target, agents.path, "0.1.0", "pending");
		// Re-write with edited body that no longer matches stamp's hash.
		const stamped = readFileSync(target, "utf-8");
		const newlineIdx = stamped.indexOf("\n");
		const markerLine = stamped.slice(0, newlineIdx);
		const userEditedBody = "USER WROTE THIS\n";
		writeFileSync(target, `${markerLine}\n${userEditedBody}`, "utf-8");

		ensureAgenticDocsScaffold(dir);

		const { marker, body } = extractMarker(target);
		// Marker version should NOT be bumped — Phase C must not touch
		// editing-drift files.
		assert.equal(marker?.version, "0.1.0");
		assert.equal(body, userEditedBody);
	});

	test("on dir with marker-less verbatim-template file, promotes to pending and stamps", () => {
		const agents = getAgentsMdTemplate();
		const target = join(dir, agents.path);

		// Plant the file with the **current** template body and no marker —
		// this simulates a project that pre-dates ADR-021 Phase A but is
		// still on the current SF release.
		writeFileSync(target, agents.content, "utf-8");
		assert.equal(extractMarker(target).marker, null);

		ensureAgenticDocsScaffold(dir);

		const { marker, body } = extractMarker(target);
		assert.ok(marker, "file should be stamped after legacy migration");
		assert.equal(marker?.template, agents.path);
		assert.equal(marker?.state, "pending");
		// Body unchanged — the migration path stamps without rewriting.
		assert.equal(body, agents.content);

		// Manifest contains the file at the matched (current) version.
		const manifest = readScaffoldManifest(dir);
		const entry = manifest.applied.find((e) => e.path === agents.path);
		assert.ok(entry, "manifest should record the migrated file");
		assert.equal(entry?.version, "9.9.9");
	});

	test("on dir with marker-less customized file (no archive match), leaves untouched", () => {
		const agents = getAgentsMdTemplate();
		const target = join(dir, agents.path);

		const customised = "# My Custom AGENTS.md\n\nNothing like the template.\n";
		writeFileSync(target, customised, "utf-8");

		ensureAgenticDocsScaffold(dir);

		// File body unchanged, no marker stamped.
		assert.equal(readFileSync(target, "utf-8"), customised);
		assert.equal(extractMarker(target).marker, null);

		// Manifest must not record it.
		const manifest = readScaffoldManifest(dir);
		const entry = manifest.applied.find((e) => e.path === agents.path);
		assert.equal(entry, undefined);
	});
});

describe("Phase C: migrateLegacyScaffold", () => {
	let dir: string;
	let prevVersion: string | undefined;

	beforeEach(() => {
		dir = makeTmp();
		prevVersion = process.env.SF_VERSION;
		process.env.SF_VERSION = "9.9.9";
	});

	afterEach(() => {
		rmSync(dir, { recursive: true, force: true });
		if (prevVersion === undefined) delete process.env.SF_VERSION;
		else process.env.SF_VERSION = prevVersion;
	});

	test("returns matched + skipped lists correctly", () => {
		const agents = getAgentsMdTemplate();
		const arch = SCAFFOLD_FILES.find((f) => f.path === "ARCHITECTURE.md");
		assert.ok(arch);

		// AGENTS.md: verbatim current template, no marker → should be migrated.
		writeFileSync(join(dir, agents.path), agents.content, "utf-8");

		// ARCHITECTURE.md: customised, no marker → should be skipped.
		mkdirSync(join(dir, "."), { recursive: true });
		writeFileSync(
			join(dir, arch.path),
			"# Custom Architecture\n\nNot the template.\n",
			"utf-8",
		);

		const result = migrateLegacyScaffold(dir);

		assert.ok(
			result.migrated.includes(agents.path),
			"AGENTS.md should be in migrated list",
		);
		assert.ok(
			result.skipped.includes(arch.path),
			"ARCHITECTURE.md should be in skipped list",
		);
		// Files not present on disk should appear in neither list.
		assert.ok(!result.migrated.includes("docs/RELIABILITY.md"));
		assert.ok(!result.skipped.includes("docs/RELIABILITY.md"));
	});

	test("seeds SCAFFOLD_VERSION_ARCHIVE with current ship version on first call", () => {
		// Trigger seeding.
		migrateLegacyScaffold(dir);
		const agents = getAgentsMdTemplate();
		const archive = SCAFFOLD_VERSION_ARCHIVE[agents.path];
		assert.ok(archive, "archive should have entries for AGENTS.md");
		assert.ok(
			archive.some((e) => e.hash === bodyHash(agents.content)),
			"archive should contain the current AGENTS.md body hash",
		);
	});

	test("idempotent — second invocation finds nothing to migrate", () => {
		const agents = getAgentsMdTemplate();
		writeFileSync(join(dir, agents.path), agents.content, "utf-8");

		const first = migrateLegacyScaffold(dir);
		assert.ok(first.migrated.includes(agents.path));

		const second = migrateLegacyScaffold(dir);
		assert.ok(
			!second.migrated.includes(agents.path),
			"second migration should not re-migrate already-stamped file",
		);
	});
});
