/**
 * self-feedback-db.test.mjs — DB-backed self-feedback source of truth.
 *
 * Purpose: prove forge-local self-feedback uses SQLite as primary state while
 * keeping markdown as a generated projection and JSONL as versioned fallback.
 */
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, test } from "vitest";
import { registerDbTools } from "../bootstrap/db-tools.js";
import {
	backfillSelfFeedbackJsonl,
	compactSelfFeedbackMarkdown,
	markResolved,
	readAllSelfFeedback,
	recordSelfFeedback,
} from "../self-feedback.js";
import {
	closeDatabase,
	insertSelfFeedbackEntry,
	listSelfFeedbackEntries,
	openDatabase,
} from "../sf-db.js";

const tmpDirs = [];

afterEach(() => {
	closeDatabase();
	while (tmpDirs.length > 0) {
		const dir = tmpDirs.pop();
		if (dir) rmSync(dir, { recursive: true, force: true });
	}
});

function makeForgeProject() {
	const dir = mkdtempSync(join(tmpdir(), "sf-self-feedback-db-"));
	tmpDirs.push(dir);
	mkdirSync(join(dir, ".sf"), { recursive: true });
	writeFileSync(
		join(dir, "package.json"),
		JSON.stringify({ name: "singularity-forge" }),
	);
	openDatabase(join(dir, ".sf", "sf.db"));
	return dir;
}

function getResolveIssueTool() {
	const tools = new Map();
	registerDbTools({
		registerTool(tool) {
			tools.set(tool.name, tool);
		},
	});
	return tools.get("resolve_issue");
}

test("recordSelfFeedback_when_db_available_writes_sqlite_and_versioned_projection", () => {
	const project = makeForgeProject();

	const result = recordSelfFeedback(
		{
			kind: "gap:test-feedback",
			severity: "medium",
			summary: "DB backed feedback",
			evidence: "unit test",
		},
		project,
	);

	assert.ok(result?.entry.id);
	assert.equal(result.entry.schemaVersion, 1);
	assert.equal(existsSync(join(project, ".sf", "self-feedback.jsonl")), false);
	const rows = listSelfFeedbackEntries();
	assert.equal(rows.length, 1);
	assert.equal(rows[0].id, result.entry.id);
	assert.equal(rows[0].summary, "DB backed feedback");
	const markdown = readFileSync(
		join(project, ".sf", "SELF-FEEDBACK.md"),
		"utf-8",
	);
	assert.match(markdown, /durable source of truth is `.sf\/sf.db`/);
	assert.match(markdown, /DB backed feedback/);
});

test("readAllSelfFeedback_imports_legacy_jsonl_once_when_db_available", () => {
	const project = makeForgeProject();
	const legacyEntry = {
		id: "legacy-1",
		ts: "2026-05-07T00:00:00.000Z",
		kind: "gap:legacy-feedback",
		severity: "high",
		blocking: true,
		summary: "Legacy JSONL entry",
		repoIdentity: "forge",
		sfVersion: "old",
		basePath: project,
	};
	writeFileSync(
		join(project, ".sf", "self-feedback.jsonl"),
		`${JSON.stringify(legacyEntry)}\n`,
	);

	const first = readAllSelfFeedback(project);
	const second = readAllSelfFeedback(project);

	assert.equal(first.length, 1);
	assert.equal(second.length, 1);
	assert.equal(first[0].id, "legacy-1");
	assert.equal(listSelfFeedbackEntries().length, 1);
});

test("markResolved_when_db_available_updates_sqlite_and_markdown_projection", () => {
	const project = makeForgeProject();
	const result = recordSelfFeedback(
		{
			kind: "gap:resolvable-feedback",
			severity: "high",
			summary: "Resolve through DB",
		},
		project,
	);
	assert.ok(result?.entry.id);

	const ok = markResolved(
		result.entry.id,
		{
			reason: "verified fix",
			evidence: { kind: "agent-fix", commitSha: "abcdef123456" },
			criteriaMet: ["test passed"],
		},
		project,
	);

	assert.equal(ok, true);
	const [entry] = readAllSelfFeedback(project);
	assert.ok(entry.resolvedAt);
	assert.equal(entry.resolvedReason, "verified fix");
	assert.deepEqual(entry.resolvedCriteriaMet, ["test passed"]);
	const markdown = readFileSync(
		join(project, ".sf", "SELF-FEEDBACK.md"),
		"utf-8",
	);
	assert.match(markdown, /No unresolved self-feedback entries/);
	assert.match(markdown, /Recently Resolved/);
});

test("markResolved_when_existing_resolution_lacks_credible_evidence_restamps_entry", () => {
	const project = makeForgeProject();
	const result = recordSelfFeedback(
		{
			kind: "gap:suspect-resolution",
			severity: "high",
			summary: "Resolved row lacks credible evidence",
		},
		project,
	);
	assert.ok(result?.entry.id);
	const first = markResolved(
		result.entry.id,
		{
			reason: "raw SQL-like resolution",
			evidence: { kind: "auto-version-bump" },
		},
		project,
	);
	assert.equal(first, true);

	const restamped = markResolved(
		result.entry.id,
		{
			reason: "fixed with a real agent patch",
			evidence: { kind: "agent-fix" },
		},
		project,
	);

	assert.equal(restamped, true);
	const [entry] = readAllSelfFeedback(project);
	assert.equal(entry.resolvedReason, "fixed with a real agent patch");
	assert.equal(entry.resolvedEvidence.kind, "agent-fix");
	const idempotent = markResolved(
		result.entry.id,
		{
			reason: "second credible resolution should not mutate",
			evidence: { kind: "human-clear" },
		},
		project,
	);
	assert.equal(idempotent, false);
});

test("markResolved_rejects_resolution_with_non_canonical_evidence_kind", () => {
	const project = makeForgeProject();
	const result = recordSelfFeedback(
		{
			kind: "gap:writer-layer-test",
			severity: "medium",
			summary: "Must be rejected when evidence kind is non-canonical",
		},
		project,
	);
	assert.ok(result?.entry.id);

	// Non-canonical shape — exactly what the dogfood agent wrote (sf-mp4qoby4-meiir7)
	const okBadKind = markResolved(
		result.entry.id,
		{
			reason: "claims a fix",
			evidence: { file: "src/foo.js", line: 42 },
		},
		project,
	);
	assert.equal(okBadKind, false);

	// Missing evidence entirely
	const okMissing = markResolved(
		result.entry.id,
		{ reason: "no evidence" },
		project,
	);
	assert.equal(okMissing, false);

	// Evidence kind not in the allowed set
	const okWrongKind = markResolved(
		result.entry.id,
		{ reason: "unknown kind", evidence: { kind: "spontaneous-resolution" } },
		project,
	);
	assert.equal(okWrongKind, false);

	// All three rejections must have left the entry OPEN
	const [entry] = readAllSelfFeedback(project);
	assert.equal(entry.resolvedAt, undefined);
});

test("markResolved_accepts_each_canonical_evidence_kind", () => {
	const project = makeForgeProject();
	const kinds = [
		{ kind: "agent-fix", commitSha: "abc1234" },
		{ kind: "human-clear" },
		{ kind: "promoted-to-requirement", requirementId: "R042" },
		{ kind: "auto-version-bump" },
		{ kind: "agent-fix-unverified", reasonForUnverified: "no test yet" },
	];
	for (const evidence of kinds) {
		const filed = recordSelfFeedback(
			{ kind: `gap:accepts-${evidence.kind}`, severity: "low", summary: "x" },
			project,
		);
		const ok = markResolved(
			filed.entry.id,
			{ reason: `using ${evidence.kind}`, evidence },
			project,
		);
		assert.equal(ok, true, `expected ${evidence.kind} to be accepted`);
	}
});

test("resolve_issue_tool_defaults_to_agent_fix_for_existing_callers", async () => {
	const project = makeForgeProject();
	const filed = recordSelfFeedback(
		{ kind: "gap:tool-default", severity: "medium", summary: "x" },
		project,
	);
	const tool = getResolveIssueTool();
	const originalCwd = process.cwd();
	process.chdir(project);
	try {
		const result = await tool.execute(
			"tool-call",
			{
				id: filed.entry.id,
				reason: "fixed",
				summary_narrative: "verified manually",
			},
			undefined,
			undefined,
			undefined,
		);
		assert.equal(result.details.resolved, true);
	} finally {
		process.chdir(originalCwd);
	}
	const [entry] = readAllSelfFeedback(project);
	assert.equal(entry.resolvedEvidence.kind, "agent-fix");
	assert.equal(entry.resolvedEvidence.summaryNarrative, "verified manually");
});

test("resolve_issue_tool_records_human_clear_and_promoted_requirement_evidence", async () => {
	const project = makeForgeProject();
	const human = recordSelfFeedback(
		{ kind: "gap:tool-human", severity: "low", summary: "x" },
		project,
	);
	const promoted = recordSelfFeedback(
		{ kind: "gap:tool-promote", severity: "low", summary: "x" },
		project,
	);
	const tool = getResolveIssueTool();
	const originalCwd = process.cwd();
	process.chdir(project);
	try {
		const humanResult = await tool.execute(
			"tool-call",
			{
				id: human.entry.id,
				reason: "stale",
				evidence_kind: "human-clear",
			},
			undefined,
			undefined,
			undefined,
		);
		assert.equal(humanResult.details.resolved, true);
		const promotedResult = await tool.execute(
			"tool-call",
			{
				id: promoted.entry.id,
				reason: "tracked as requirement",
				evidence_kind: "promoted-to-requirement",
				requirement_id: "R042",
			},
			undefined,
			undefined,
			undefined,
		);
		assert.equal(promotedResult.details.resolved, true);
	} finally {
		process.chdir(originalCwd);
	}
	const entries = readAllSelfFeedback(project);
	assert.equal(
		entries.find((entry) => entry.id === human.entry.id).resolvedEvidence.kind,
		"human-clear",
	);
	assert.equal(
		entries.find((entry) => entry.id === promoted.entry.id).resolvedEvidence
			.kind,
		"promoted-to-requirement",
	);
	assert.equal(
		entries.find((entry) => entry.id === promoted.entry.id).resolvedEvidence
			.requirementId,
		"R042",
	);
});

test("resolve_issue_tool_rejects_promoted_requirement_without_requirement_id", async () => {
	const project = makeForgeProject();
	const filed = recordSelfFeedback(
		{ kind: "gap:tool-promote-invalid", severity: "low", summary: "x" },
		project,
	);
	const tool = getResolveIssueTool();
	const originalCwd = process.cwd();
	process.chdir(project);
	let result;
	try {
		result = await tool.execute(
			"tool-call",
			{
				id: filed.entry.id,
				reason: "tracked as requirement",
				evidence_kind: "promoted-to-requirement",
			},
			undefined,
			undefined,
			undefined,
		);
	} finally {
		process.chdir(originalCwd);
	}
	assert.equal(result.details.error, "missing_requirement_id");
	const [entry] = readAllSelfFeedback(project);
	assert.equal(entry.resolvedAt, undefined);
});

test("markResolved_rejects_agent_fix_with_nonexistent_commit_sha", async () => {
	const project = makeForgeProject();
	// Initialize a real git repo so the verifier can distinguish "commit
	// missing" from "not a repo". Without a repo, the carve-out returns
	// "ungrokable" and the resolution would be accepted.
	const { execFileSync } = await import("node:child_process");
	execFileSync("git", ["init", "-q", "-b", "main"], { cwd: project });
	execFileSync("git", ["config", "user.email", "test@example.com"], {
		cwd: project,
	});
	execFileSync("git", ["config", "user.name", "test"], { cwd: project });
	execFileSync("git", ["commit", "--allow-empty", "-m", "init", "-q"], {
		cwd: project,
	});

	const filed = recordSelfFeedback(
		{ kind: "gap:commit-verif-test", severity: "high", summary: "x" },
		project,
	);

	// Bogus SHA — git rev-parse --verify exits non-zero
	const okBogus = markResolved(
		filed.entry.id,
		{
			reason: "phantom fix",
			evidence: {
				kind: "agent-fix",
				commitSha: "0000000000000000000000000000000000000000",
			},
		},
		project,
	);
	assert.equal(okBogus, false);

	// Real SHA from the init commit — accepted
	const headSha = execFileSync("git", ["rev-parse", "HEAD"], {
		cwd: project,
		encoding: "utf-8",
	}).trim();
	const okReal = markResolved(
		filed.entry.id,
		{
			reason: "verified fix",
			evidence: { kind: "agent-fix", commitSha: headSha },
		},
		project,
	);
	assert.equal(okReal, true);
});

test("markResolved_rejects_agent_fix_when_commit_does_not_touch_AC_files", async () => {
	const project = makeForgeProject();
	const { execFileSync } = await import("node:child_process");
	execFileSync("git", ["init", "-q", "-b", "main"], { cwd: project });
	execFileSync("git", ["config", "user.email", "test@example.com"], {
		cwd: project,
	});
	execFileSync("git", ["config", "user.name", "test"], { cwd: project });

	// Two commits touching different files
	const { writeFileSync: writeFs, mkdirSync: mkFs } = await import("node:fs");
	mkFs(join(project, "src"), { recursive: true });
	writeFs(join(project, "src", "expected.js"), "// initial\n");
	writeFs(join(project, "src", "unrelated.js"), "// initial\n");
	execFileSync("git", ["add", "-A"], { cwd: project });
	execFileSync("git", ["commit", "-m", "init", "-q"], { cwd: project });

	// "Real fix" commit only touches src/unrelated.js
	writeFs(join(project, "src", "unrelated.js"), "// updated\n");
	execFileSync("git", ["add", "-A"], { cwd: project });
	execFileSync("git", ["commit", "-m", "unrelated change", "-q"], {
		cwd: project,
	});
	const unrelatedSha = execFileSync("git", ["rev-parse", "HEAD"], {
		cwd: project,
		encoding: "utf-8",
	}).trim();

	// File an entry whose AC mentions src/expected.js — the unrelated
	// commit must be rejected because it doesn't touch that file.
	const filed = recordSelfFeedback(
		{
			kind: "gap:ac-mismatch-test",
			severity: "high",
			summary: "AC mentions a specific file",
			acceptanceCriteria:
				"AC1: `src/expected.js` is modified to use the new helper",
		},
		project,
	);

	const okWrongFile = markResolved(
		filed.entry.id,
		{
			reason: "claims fix but commit touched the wrong file",
			evidence: { kind: "agent-fix", commitSha: unrelatedSha },
		},
		project,
	);
	assert.equal(okWrongFile, false);

	// Now make a commit that DOES touch src/expected.js — accepted
	writeFs(join(project, "src", "expected.js"), "// actually-fixed\n");
	execFileSync("git", ["add", "-A"], { cwd: project });
	execFileSync("git", ["commit", "-m", "real fix", "-q"], { cwd: project });
	const realSha = execFileSync("git", ["rev-parse", "HEAD"], {
		cwd: project,
		encoding: "utf-8",
	}).trim();

	const okRealFile = markResolved(
		filed.entry.id,
		{
			reason: "real fix touches the AC-mentioned file",
			evidence: { kind: "agent-fix", commitSha: realSha },
		},
		project,
	);
	assert.equal(okRealFile, true);
});

test("markResolved_skips_AC_file_check_when_AC_has_no_extractable_paths", async () => {
	const project = makeForgeProject();
	const { execFileSync } = await import("node:child_process");
	execFileSync("git", ["init", "-q", "-b", "main"], { cwd: project });
	execFileSync("git", ["config", "user.email", "test@example.com"], {
		cwd: project,
	});
	execFileSync("git", ["config", "user.name", "test"], { cwd: project });
	execFileSync("git", ["commit", "--allow-empty", "-m", "init", "-q"], {
		cwd: project,
	});
	const sha = execFileSync("git", ["rev-parse", "HEAD"], {
		cwd: project,
		encoding: "utf-8",
	}).trim();

	// AC is prose-only — no extractable file paths. Commit acceptance
	// must succeed without file-match check (we don't fabricate
	// rejections when there's nothing to compare against).
	const filed = recordSelfFeedback(
		{
			kind: "gap:prose-only-ac-test",
			severity: "low",
			summary: "prose AC",
			acceptanceCriteria:
				"AC1: behavior is intuitive AC2: operators can recover quickly",
		},
		project,
	);

	const ok = markResolved(
		filed.entry.id,
		{
			reason: "prose AC accepts any commit",
			evidence: { kind: "agent-fix", commitSha: sha },
		},
		project,
	);
	assert.equal(ok, true);
});

test("markResolved_AC_file_check_tolerates_basename_match", async () => {
	const project = makeForgeProject();
	const { execFileSync } = await import("node:child_process");
	const fsMod = await import("node:fs");
	const writeFs = fsMod.writeFileSync;
	const mkFs = fsMod.mkdirSync;
	execFileSync("git", ["init", "-q", "-b", "main"], { cwd: project });
	execFileSync("git", ["config", "user.email", "test@example.com"], {
		cwd: project,
	});
	execFileSync("git", ["config", "user.name", "test"], { cwd: project });
	mkFs(join(project, "packages", "ai", "src"), { recursive: true });
	writeFs(
		join(project, "packages", "ai", "src", "types.ts"),
		"export type X = {};\n",
	);
	execFileSync("git", ["add", "-A"], { cwd: project });
	execFileSync("git", ["commit", "-m", "init", "-q"], { cwd: project });
	const sha = execFileSync("git", ["rev-parse", "HEAD"], {
		cwd: project,
		encoding: "utf-8",
	}).trim();

	// AC mentions "src/types.ts" but the actual commit touched
	// "packages/ai/src/types.ts". Basename match should accept.
	const filed = recordSelfFeedback(
		{
			kind: "gap:basename-tolerance-test",
			severity: "low",
			summary: "basename match",
			acceptanceCriteria: "AC1: `src/types.ts` is extended with a new field",
		},
		project,
	);

	const ok = markResolved(
		filed.entry.id,
		{
			reason: "basename match across packages",
			evidence: { kind: "agent-fix", commitSha: sha },
		},
		project,
	);
	assert.equal(ok, true);
});

test("markResolved_accepts_agent_fix_with_no_commit_sha_or_ungrokable_path", () => {
	const project = makeForgeProject();
	// No git init → ungrokable → carve-out allows the resolution
	const filed = recordSelfFeedback(
		{ kind: "gap:no-git-test", severity: "low", summary: "x" },
		project,
	);
	const ok = markResolved(
		filed.entry.id,
		{
			reason: "verified some other way",
			evidence: { kind: "agent-fix", commitSha: "anything-goes-no-repo" },
		},
		project,
	);
	assert.equal(ok, true);

	// agent-fix without any commitSha is also accepted (commit not always
	// the right artifact — summaryNarrative or testPath may be)
	const filed2 = recordSelfFeedback(
		{ kind: "gap:no-sha-test", severity: "low", summary: "x" },
		project,
	);
	const ok2 = markResolved(
		filed2.entry.id,
		{
			reason: "test-based verification",
			evidence: {
				kind: "agent-fix",
				testPath: "src/foo.test.mjs",
				summaryNarrative: "added a test that pins the behavior",
			},
		},
		project,
	);
	assert.equal(ok2, true);
});

test("markResolved_mirrors_resolution_into_memory_store_with_tags", async () => {
	const project = makeForgeProject();
	const filed = recordSelfFeedback(
		{
			kind: "gap:visible-in-memory",
			severity: "medium",
			summary: "test entry whose closure should land in memory",
		},
		project,
	);

	const ok = markResolved(
		filed.entry.id,
		{
			reason: "fixed by deadbeef in src/foo.js",
			evidence: { kind: "agent-fix", commitSha: "deadbeefcafe" },
		},
		project,
	);
	assert.equal(ok, true);

	// Verify the memory entry landed. getActiveMemories returns camelCase
	// fields (sourceUnitId, not source_unit_id).
	const { getActiveMemories } = await import("../sf-db.js");
	const memories = getActiveMemories();
	const closureMem = memories.find((m) => m.sourceUnitId === filed.entry.id);
	assert.ok(
		closureMem,
		"expected a memory entry sourced to the resolved entry",
	);
	assert.equal(closureMem.category, "self-feedback-resolution");
	assert.match(closureMem.content, /\[gap:visible-in-memory\]/);
	assert.match(closureMem.content, /agent-fix: fixed by deadbeef/);
	const tags = Array.isArray(closureMem.tags) ? closureMem.tags : [];
	assert.ok(tags.includes("gap:visible-in-memory"), "kind tag missing");
	assert.ok(tags.includes("evidence:agent-fix"), "evidence-kind tag missing");
	assert.ok(
		tags.some((t) => t.startsWith("commit:deadbeef")),
		"commit tag missing",
	);
});

test("markResolved_memory_mirror_handles_human_clear_without_commit_tags", async () => {
	const project = makeForgeProject();
	const filed = recordSelfFeedback(
		{
			kind: "upstream-rollup:noise-cluster",
			severity: "medium",
			summary: "out-of-scope close — no commit/requirement to tag",
		},
		project,
	);

	const ok = markResolved(
		filed.entry.id,
		{
			reason: "out of scope for inline-fix worker",
			evidence: { kind: "human-clear" },
		},
		project,
	);
	assert.equal(ok, true);

	const { getActiveMemories } = await import("../sf-db.js");
	const closureMem = getActiveMemories().find(
		(m) => m.sourceUnitId === filed.entry.id,
	);
	assert.ok(closureMem, "human-clear closures should also land in memory");
	const tags = Array.isArray(closureMem.tags) ? closureMem.tags : [];
	assert.ok(tags.includes("upstream-rollup:noise-cluster"));
	assert.ok(tags.includes("evidence:human-clear"));
	assert.ok(
		!tags.some((t) => t.startsWith("commit:")),
		"no commit tag when no commitSha supplied",
	);
});

test("recordSelfFeedback_kind_validation_accepts_canonical_shapes", () => {
	const project = makeForgeProject();
	const cases = [
		"gap", // 1-segment legacy (allowed-domain)
		"gap:routing", // 2-segment domain:family
		"gap:routing:tiebreak-cost-only", // 3-segment domain:family:specific
		"architecture-defect:foo", // hyphenated domain works
		"architecture-defect:solver:executor-conflation",
		"runaway-loop", // 1-segment legacy
		"upstream-rollup:gap-audit-orphan-prompt",
	];
	for (const kind of cases) {
		const result = recordSelfFeedback(
			{ kind, severity: "low", summary: `test ${kind}` },
			project,
		);
		assert.ok(result, `expected ${kind} to be accepted`);
		assert.equal(result.entry.kind, kind);
	}
});

test("recordSelfFeedback_kind_validation_accepts_report_issue_prompt_kinds", () => {
	const project = makeForgeProject();
	const cases = [
		"prompt-quality-issue",
		"improvement-idea",
		"agent-friction",
		"design-thought",
		"missing-feature",
		"brittle-predicate",
		"git-empty-pathspec",
		"advisory-downgrade",
	];
	for (const kind of cases) {
		const result = recordSelfFeedback(
			{ kind, severity: "medium", summary: `report_issue accepts ${kind}` },
			project,
		);
		assert.ok(result, `expected report_issue kind ${kind} to be accepted`);
		assert.equal(result.entry.kind, kind);
	}
});

test("recordSelfFeedback_kind_validation_rejects_malformed", () => {
	const project = makeForgeProject();
	const cases = [
		{ kind: "" }, // empty
		{ kind: "Unknown" }, // not in domain allow-list
		{ kind: "gap:" }, // empty family
		{ kind: ":family" }, // empty domain
		{ kind: "gap:routing:tiebreak:extra" }, // 4 segments
		{ kind: "gap:Routing" }, // uppercase in family (not kebab)
		{ kind: "gap:routing_cost" }, // underscore (not kebab)
		{ kind: "gap:-routing" }, // leading hyphen
		{ kind: "gap:routing-" }, // trailing hyphen
		{ kind: "9gap:routing" }, // domain starts with digit
		{ kind: "weird-domain:family" }, // unknown domain
	];
	for (const { kind } of cases) {
		const result = recordSelfFeedback(
			{ kind, severity: "low", summary: `should reject ${kind}` },
			project,
		);
		assert.equal(
			result,
			null,
			`expected ${JSON.stringify(kind)} to be rejected`,
		);
	}
});

test("recordSelfFeedback_kind_validation_rejects_non_string", () => {
	const project = makeForgeProject();
	for (const kind of [undefined, null, 42, ["gap"], { domain: "gap" }]) {
		const result = recordSelfFeedback(
			{ kind, severity: "low", summary: "non-string kind" },
			project,
		);
		assert.equal(
			result,
			null,
			`expected non-string ${JSON.stringify(kind)} to be rejected`,
		);
	}
});

test("markResolved_regenerates_jsonl_from_db_with_resolved_state", () => {
	// DB-first Phase 2: after resolution, JSONL is regenerated from DB (not
	// appended to). The resolved entry appears in JSONL with its resolution
	// payload, and there is no separate `recordType: "resolution"` record.
	const project = makeForgeProject();
	const result = recordSelfFeedback(
		{
			kind: "gap:audit-trail",
			severity: "high",
			summary: "Resolution must land in JSONL too",
		},
		project,
	);
	assert.ok(result?.entry.id);

	const ok = markResolved(
		result.entry.id,
		{
			reason: "fixed by 0123abc",
			evidence: { kind: "agent-fix", commitSha: "0123abc456789" },
		},
		project,
	);
	assert.equal(ok, true);

	// JSONL is now a DB render: comment header line + one entry line.
	const lines = readFileSync(
		join(project, ".sf", "self-feedback.jsonl"),
		"utf-8",
	)
		.split("\n")
		.filter((l) => l.trim());
	// First non-empty line is the generated meta marker (valid JSON object so
	// strict JSONL parsers like the doctor accept it; carries _meta sentinel).
	const meta = JSON.parse(lines[0]);
	assert.ok(meta._meta, "expected JSONL meta header on line 1");
	// Second line is the resolved entry
	const entry = JSON.parse(lines[1]);
	assert.equal(entry.id, result.entry.id);
	assert.ok(entry.resolvedAt, "entry must carry resolvedAt");
	assert.equal(entry.resolvedEvidence.kind, "agent-fix");
	assert.equal(entry.resolvedEvidence.commitSha, "0123abc456789");
	assert.equal(entry.resolvedReason, "fixed by 0123abc");
	// No `recordType: "resolution"` record — DB-first render has entry rows only
	assert.ok(
		!lines.some((l) => {
			try {
				return JSON.parse(l).recordType === "resolution";
			} catch {
				return false;
			}
		}),
		"no separate resolution event record in DB-first JSONL",
	);
});

test("importLegacyJsonlToDb_replays_resolution_events_onto_rebuilt_db", () => {
	const project = makeForgeProject();

	// Build a JSONL audit log with: entry creation + resolution event. This
	// is the shape an existing JSONL will have after markResolved runs.
	const entry = {
		schemaVersion: 1,
		id: "rebuild-1",
		ts: "2026-05-13T10:00:00.000Z",
		kind: "gap:foo",
		severity: "medium",
		blocking: false,
		summary: "Entry from before DB rebuild",
		repoIdentity: "forge",
		sfVersion: "2.75.3",
		basePath: project,
	};
	const resolution = {
		recordType: "resolution",
		entryId: "rebuild-1",
		resolvedAt: "2026-05-13T11:30:00.000Z",
		resolvedReason: "fixed by sha=def456",
		resolvedEvidence: { kind: "agent-fix", commitSha: "def456" },
		resolvedBySfVersion: "2.75.3",
	};
	writeFileSync(
		join(project, ".sf", "self-feedback.jsonl"),
		`${JSON.stringify(entry)}\n${JSON.stringify(resolution)}\n`,
	);

	// Simulate a DB rebuild: trigger import via readAllSelfFeedback.
	const result = readAllSelfFeedback(project);

	assert.equal(result.length, 1);
	const reloaded = result[0];
	assert.equal(reloaded.id, "rebuild-1");
	assert.equal(reloaded.resolvedAt, "2026-05-13T11:30:00.000Z");
	assert.equal(reloaded.resolvedReason, "fixed by sha=def456");
	assert.deepEqual(reloaded.resolvedEvidence, {
		kind: "agent-fix",
		commitSha: "def456",
	});
});

test("importLegacyJsonlToDb_resolution_event_for_missing_entry_is_a_noop", () => {
	const project = makeForgeProject();
	const orphan = {
		recordType: "resolution",
		entryId: "never-existed",
		resolvedAt: "2026-05-13T11:30:00.000Z",
		resolvedReason: "stale audit log entry",
		resolvedEvidence: { kind: "agent-fix", commitSha: "abc" },
	};
	writeFileSync(
		join(project, ".sf", "self-feedback.jsonl"),
		`${JSON.stringify(orphan)}\n`,
	);

	// Should not throw, should not insert anything.
	const result = readAllSelfFeedback(project);
	assert.equal(result.length, 0);
});

test("compactSelfFeedbackMarkdown_when_projection_stale_rewrites_from_sqlite", () => {
	const project = makeForgeProject();
	const result = recordSelfFeedback(
		{
			kind: "gap:stale-projection",
			severity: "medium",
			summary: "Projection should be repaired",
		},
		project,
	);
	assert.ok(result?.entry.id);
	writeFileSync(
		join(project, ".sf", "SELF-FEEDBACK.md"),
		"durable source of truth is `self-feedback.jsonl`\n",
	);

	assert.equal(compactSelfFeedbackMarkdown(project), true);

	const markdown = readFileSync(
		join(project, ".sf", "SELF-FEEDBACK.md"),
		"utf-8",
	);
	assert.match(markdown, /durable source of truth is `.sf\/sf.db`/);
	assert.match(markdown, /Projection should be repaired/);
	assert.doesNotMatch(
		markdown,
		/durable source of truth is `self-feedback\.jsonl`/,
	);
});

// ─── Phase 2 DB-first: self-feedback JSONL and Markdown as render targets ────

test("markResolved_db_only_entry_resolves_without_jsonl_lookup", () => {
	// Core regression: entries inserted directly into DB (missing from JSONL)
	// must be resolvable via markResolved without any JSONL-based lookup.
	// This is the structural fix for the 4 stuck entries (sf-mp5tuvdx-ibyk9b,
	// sf-mp5tp6uh-8eafni, sf-mp6ed4xq-2sgx8w, sf-mp6eitbh-9krkd9).
	const project = makeForgeProject();

	// Insert directly into DB — no JSONL write.
	const dbOnlyEntry = {
		id: "db-only-test-entry-1",
		ts: new Date().toISOString(),
		kind: "architecture-defect:db-first-test",
		severity: "high",
		blocking: true,
		summary: "DB-only entry should be resolvable",
		repoIdentity: "forge",
		sfVersion: "test",
		basePath: project,
		schemaVersion: 1,
	};
	insertSelfFeedbackEntry(dbOnlyEntry);

	// Verify no JSONL exists yet
	assert.equal(
		existsSync(join(project, ".sf", "self-feedback.jsonl")),
		false,
		"JSONL must not exist before resolution",
	);

	// markResolved must succeed even though the entry is not in JSONL
	const ok = markResolved(
		"db-only-test-entry-1",
		{
			reason: "fixed in code",
			evidence: { kind: "human-clear" },
		},
		project,
	);
	assert.equal(ok, true, "resolution must succeed for DB-only entry");

	// DB row is resolved
	const entries = listSelfFeedbackEntries();
	const resolved = entries.find((e) => e.id === "db-only-test-entry-1");
	assert.ok(
		resolved?.resolvedAt,
		"DB row must carry resolvedAt after resolution",
	);
});

test("markResolved_db_only_entry_populates_jsonl_and_markdown_after_resolution", () => {
	// After resolving a DB-only entry, both JSONL and Markdown must reflect
	// the resolved state (regenerated from DB).
	const project = makeForgeProject();

	const entry = {
		id: "db-only-render-test",
		ts: new Date().toISOString(),
		kind: "runaway-loop:idle-halt",
		severity: "high",
		blocking: true,
		summary: "Runaway loop entry — DB only, missing from JSONL",
		repoIdentity: "forge",
		sfVersion: "test",
		basePath: project,
		schemaVersion: 1,
	};
	insertSelfFeedbackEntry(entry);

	const ok = markResolved(
		"db-only-render-test",
		{
			reason: "idle-halt logic corrected",
			evidence: { kind: "human-clear" },
		},
		project,
	);
	assert.equal(ok, true);

	// JSONL must exist and contain the resolved entry
	assert.equal(
		existsSync(join(project, ".sf", "self-feedback.jsonl")),
		true,
		"JSONL must be created after resolution",
	);
	const jsonlContent = readFileSync(
		join(project, ".sf", "self-feedback.jsonl"),
		"utf-8",
	);
	// Generated meta marker must be present on line 1 (JSON object with _meta key)
	assert.ok(
		jsonlContent.includes('"_meta":"generated from .sf/sf.db"'),
		"JSONL must have generated meta header",
	);
	// Entry must appear as resolved — filter out the _meta header line
	const jsonlLines = jsonlContent.split("\n").filter((l) => {
		try {
			const parsed = JSON.parse(l);
			return parsed && typeof parsed === "object" && !parsed._meta;
		} catch {
			return false;
		}
	});
	assert.equal(jsonlLines.length, 1, "JSONL must have exactly one entry line");
	const jsonlEntry = JSON.parse(jsonlLines[0]);
	assert.equal(jsonlEntry.id, "db-only-render-test");
	assert.ok(jsonlEntry.resolvedAt, "entry in JSONL must carry resolvedAt");

	// Markdown must reflect resolved state
	const markdown = readFileSync(
		join(project, ".sf", "SELF-FEEDBACK.md"),
		"utf-8",
	);
	assert.match(markdown, /No unresolved self-feedback entries/);
	assert.match(markdown, /Recently Resolved/);
});

test("backfillSelfFeedbackJsonl_makes_jsonl_equal_to_db_render", () => {
	// backfillSelfFeedbackJsonl must enforce JSONL = DB-render(DB-entries).
	const project = makeForgeProject();

	// Insert two entries directly into DB
	for (const [i, kind] of [
		["sf-backfill-1", "architecture-defect:sf-print-mode-hangs"],
		["sf-backfill-2", "runaway-loop:idle-halt"],
	]) {
		insertSelfFeedbackEntry({
			id: i,
			ts: new Date().toISOString(),
			kind,
			severity: "high",
			blocking: true,
			summary: `Backfill test entry ${i}`,
			repoIdentity: "forge",
			sfVersion: "test",
			basePath: project,
			schemaVersion: 1,
		});
	}

	// JSONL does not exist yet
	assert.equal(existsSync(join(project, ".sf", "self-feedback.jsonl")), false);

	// First backfill creates JSONL with both entries
	const result = backfillSelfFeedbackJsonl(project);
	assert.equal(result, true);
	assert.equal(existsSync(join(project, ".sf", "self-feedback.jsonl")), true);

	const firstContent = readFileSync(
		join(project, ".sf", "self-feedback.jsonl"),
		"utf-8",
	);
	const firstEntryLines = firstContent.split("\n").filter((l) => {
		try {
			const parsed = JSON.parse(l);
			return parsed && typeof parsed === "object" && !parsed._meta;
		} catch {
			return false;
		}
	});
	assert.equal(firstEntryLines.length, 2, "JSONL must have both DB entries");

	// Second backfill is idempotent — exact same content
	backfillSelfFeedbackJsonl(project);
	const secondContent = readFileSync(
		join(project, ".sf", "self-feedback.jsonl"),
		"utf-8",
	);
	assert.equal(
		firstContent,
		secondContent,
		"backfillSelfFeedbackJsonl must be idempotent",
	);
});

test("regenerateSelfFeedbackJsonl_includes_generated_comment_header", () => {
	// The JSONL file must start with a JSON-valid meta marker (so strict
	// JSONL parsers like the doctor accept the file). readJsonl skips entries
	// carrying the _meta sentinel so consumers don't see them as records.
	const project = makeForgeProject();
	recordSelfFeedback(
		{ kind: "gap:header-test", severity: "low", summary: "header check" },
		project,
	);

	// Trigger regeneration via backfill (which calls regenerateSelfFeedbackJsonl)
	backfillSelfFeedbackJsonl(project);

	const content = readFileSync(
		join(project, ".sf", "self-feedback.jsonl"),
		"utf-8",
	);
	const firstLine = content.split("\n")[0];
	const meta = JSON.parse(firstLine);
	assert.ok(
		meta._meta,
		"JSONL first line must be a JSON object with _meta key",
	);
	assert.ok(
		meta._meta.includes("generated from .sf/sf.db"),
		"_meta must mention sf.db",
	);
	assert.ok(
		firstLine.includes("resolve_issue"),
		"generated header must mention the canonical tool",
	);
});

test("backfillSelfFeedbackJsonl_four_stuck_entries_appear_in_both_outputs", () => {
	// Regression test for sf-mp5tuvdx-ibyk9b, sf-mp5tp6uh-8eafni,
	// sf-mp6ed4xq-2sgx8w, sf-mp6eitbh-9krkd9: these entries were in DB but
	// not JSONL. After backfill + resolution via human-clear, they must
	// appear as resolved in both JSONL and Markdown.
	const project = makeForgeProject();
	const stuckEntries = [
		{
			id: "sf-mp5tuvdx-ibyk9b",
			kind: "architecture-defect:sf-print-mode-hangs",
		},
		{
			id: "sf-mp5tp6uh-8eafni",
			kind: "architecture-defect:no-subagent-dispatch-observability",
		},
		{ id: "sf-mp6ed4xq-2sgx8w", kind: "runaway-loop:idle-halt" },
		{ id: "sf-mp6eitbh-9krkd9", kind: "runaway-loop:idle-halt" },
	];
	for (const e of stuckEntries) {
		insertSelfFeedbackEntry({
			id: e.id,
			ts: "2026-05-01T00:00:00.000Z",
			kind: e.kind,
			severity: "high",
			blocking: true,
			summary: `Stuck entry ${e.id}`,
			repoIdentity: "forge",
			sfVersion: "2.74.0",
			basePath: project,
			schemaVersion: 1,
		});
	}

	// Backfill brings JSONL in sync with DB
	backfillSelfFeedbackJsonl(project);
	const jsonlAfterBackfill = readFileSync(
		join(project, ".sf", "self-feedback.jsonl"),
		"utf-8",
	);
	for (const e of stuckEntries) {
		assert.ok(
			jsonlAfterBackfill.includes(e.id),
			`${e.id} must appear in JSONL after backfill`,
		);
	}

	// Now resolve each via markResolved with human-clear evidence
	for (const e of stuckEntries) {
		const ok = markResolved(
			e.id,
			{
				reason: "Fixed in code; resolution path now DB-first",
				evidence: { kind: "human-clear" },
			},
			project,
		);
		assert.equal(ok, true, `markResolved must succeed for ${e.id}`);
	}

	// After resolution, JSONL must show all 4 as resolved
	const jsonlAfterResolve = readFileSync(
		join(project, ".sf", "self-feedback.jsonl"),
		"utf-8",
	);
	const resolvedLines = jsonlAfterResolve.split("\n").filter((l) => {
		try {
			const parsed = JSON.parse(l);
			return parsed.resolvedAt !== undefined;
		} catch {
			return false;
		}
	});
	assert.equal(
		resolvedLines.length,
		4,
		"all 4 stuck entries must appear as resolved in JSONL",
	);

	// Markdown must show all 4 in "Recently Resolved"
	const markdown = readFileSync(
		join(project, ".sf", "SELF-FEEDBACK.md"),
		"utf-8",
	);
	assert.match(markdown, /Recently Resolved/);
	assert.match(markdown, /No unresolved self-feedback entries/);
	for (const e of stuckEntries) {
		assert.ok(
			markdown.includes(e.kind),
			`${e.kind} must appear in resolved Markdown`,
		);
	}
});
