/**
 * Unit tests for the triage-backlog agent persona.
 *
 * Tests:
 *  1. loadTriageBacklogVars — returns correct shape for a tmpdir with sample feedback
 *  2. applyTriageReport — writes new REQUIREMENTS rows and resolves entries
 *  3. Idempotency — applying the same report twice mutates only once
 *  4. Resolution evidence kind — agent-fix / human-clear, never auto-version-bump
 *  5. Prompt contract — loadPrompt("triage-backlog", vars) succeeds without throwing
 */

import assert from "node:assert/strict";
import {
	existsSync,
	mkdirSync,
	mkdtempSync,
	readFileSync,
	rmSync,
	writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterAll, test } from "vitest";
import { fileURLToPath } from "node:url";
import {
	readAllSelfFeedback,
	recordSelfFeedback,
} from "../self-feedback.ts";
import {
	applyTriageReport,
	buildTriageBacklogPrompt,
	loadTriageBacklogVars,
	parseTriageReport,
	type TriageReport,
} from "../triage-backlog.ts";

const __dirname = dirname(fileURLToPath(import.meta.url));
const promptsDir = join(__dirname, "..", "prompts");

// ─── Test helpers ─────────────────────────────────────────────────────────────

function makeForgeProject(): string {
	const root = mkdtempSync(join(tmpdir(), "sf-triage-backlog-"));
	mkdirSync(join(root, ".sf"), { recursive: true });
	// Give it a forge identity so entries land in <root>/.sf/self-feedback.jsonl
	writeFileSync(
		join(root, "package.json"),
		JSON.stringify({ name: "singularity-forge", version: "0.0.1" }),
		"utf-8",
	);
	return root;
}

function cleanup(root: string): void {
	rmSync(root, { recursive: true, force: true });
}

function sampleReport(entryId: string): TriageReport {
	return {
		clusterDecisions: [
			{
				clusterKey: "test-cluster",
				entryIds: [entryId],
				count: 1,
				summary: "A test cluster",
				recommendation: "mark-resolved-as-wontfix",
				rationale: "Not actionable in the current roadmap.",
			},
		],
		promotedRequirements: [
			{
				id: "R101",
				title: "Test Requirement",
				class: "quality-attribute",
				description: "System must handle test scenario X",
				whyItMatters: "Avoids silent data loss",
				source: "execution",
				validation: "unmapped",
				notes: "Surfaced by triage-backlog agent",
			},
		],
		resolutions: [
			{
				entryId,
				reason: "Marked as wontfix by triage agent",
				evidenceKind: "human-clear",
			},
		],
	};
}

// Collect roots for cleanup
const roots: string[] = [];

afterAll(() => {
	for (const root of roots) {
		cleanup(root);
	}
});

// ─── Test 1: loadTriageBacklogVars shape ──────────────────────────────────────

test("loadTriageBacklogVars: returns correct shape for a tmpdir with sample feedback", () => {
	const root = makeForgeProject();
	roots.push(root);

	// Record two feedback entries
	recordSelfFeedback(
		{
			kind: "test-anomaly",
			severity: "medium",
			summary: "Test anomaly for triage",
			source: "agent",
		},
		root,
	);
	recordSelfFeedback(
		{
			kind: "test-anomaly",
			severity: "low",
			summary: "Second test anomaly",
			source: "detector",
		},
		root,
	);

	const vars = loadTriageBacklogVars(root);

	assert.ok(
		"forgeBacklogJson" in vars,
		"vars must have forgeBacklogJson",
	);
	assert.ok(
		"upstreamRollups" in vars,
		"vars must have upstreamRollups",
	);
	assert.ok(
		"existingRequirementsTable" in vars,
		"vars must have existingRequirementsTable",
	);
	assert.ok(
		"existingRoadmapSummary" in vars,
		"vars must have existingRoadmapSummary",
	);

	const forgeEntries = JSON.parse(vars.forgeBacklogJson) as unknown[];
	assert.equal(forgeEntries.length, 2, "should have 2 forge-local entries");

	const upstreamEntries = JSON.parse(vars.upstreamRollups) as unknown[];
	assert.equal(
		upstreamEntries.length,
		0,
		"no upstream-rollup entries in a forge project",
	);

	assert.ok(
		typeof vars.existingRequirementsTable === "string",
		"existingRequirementsTable is a string",
	);
	assert.ok(
		typeof vars.existingRoadmapSummary === "string",
		"existingRoadmapSummary is a string",
	);
});

// ─── Test 2: applyTriageReport — adds requirements and resolves entries ────────

test("applyTriageReport: adds REQUIREMENTS row and resolves entries", () => {
	const root = makeForgeProject();
	roots.push(root);

	// Write a minimal REQUIREMENTS.md with an Active section
	const reqPath = join(root, ".sf", "REQUIREMENTS.md");
	writeFileSync(
		reqPath,
		"# Requirements\n\n## Active\n\n## Validated\n\n## Deferred\n\n## Out of Scope\n",
		"utf-8",
	);

	// Record a feedback entry to resolve
	const result = recordSelfFeedback(
		{
			kind: "test-kind",
			severity: "low",
			summary: "Resolvable entry",
			source: "agent",
		},
		root,
	);
	assert.ok(result, "recordSelfFeedback must return a result");
	const entryId = result!.entry.id;

	const report = sampleReport(entryId);
	const applied = applyTriageReport(root, report);

	assert.equal(applied.requirementsAdded, 1, "should have added 1 requirement");
	assert.equal(applied.entriesResolved, 1, "should have resolved 1 entry");

	// Verify REQUIREMENTS.md now has the new row
	const reqContent = readFileSync(reqPath, "utf-8");
	assert.match(reqContent, /### R101 — Test Requirement/);
	assert.match(reqContent, /Class: quality-attribute/);
	assert.match(reqContent, /Status: active/);

	// Verify the entry is marked resolved in the jsonl
	const entries = readAllSelfFeedback(root);
	const resolved = entries.find((e) => e.id === entryId);
	assert.ok(resolved?.resolvedAt, "entry should be marked resolved");
	assert.equal(
		resolved?.resolvedEvidence?.kind,
		"human-clear",
		"evidence kind must be human-clear",
	);
});

// ─── Test 3: Idempotency ──────────────────────────────────────────────────────

test("applyTriageReport: idempotent — applying the same report twice mutates only once", () => {
	const root = makeForgeProject();
	roots.push(root);

	const reqPath = join(root, ".sf", "REQUIREMENTS.md");
	writeFileSync(
		reqPath,
		"# Requirements\n\n## Active\n\n## Validated\n\n## Deferred\n\n## Out of Scope\n",
		"utf-8",
	);

	const result = recordSelfFeedback(
		{
			kind: "test-idempotent",
			severity: "low",
			summary: "Idempotency test entry",
			source: "agent",
		},
		root,
	);
	assert.ok(result, "recordSelfFeedback must return a result");
	const entryId = result!.entry.id;

	const report = sampleReport(entryId);

	// Apply once
	const first = applyTriageReport(root, report);
	assert.equal(first.requirementsAdded, 1, "first apply: 1 requirement added");
	assert.equal(first.entriesResolved, 1, "first apply: 1 entry resolved");

	// Apply again — same report
	const second = applyTriageReport(root, report);
	assert.equal(
		second.requirementsAdded,
		0,
		"second apply: no new requirements (idempotent)",
	);
	assert.equal(
		second.entriesResolved,
		0,
		"second apply: no new resolutions (idempotent)",
	);

	// REQUIREMENTS.md should have exactly one R101 entry
	const reqContent = readFileSync(reqPath, "utf-8");
	const matches = reqContent.match(/### R101 — Test Requirement/g);
	assert.equal(
		matches?.length ?? 0,
		1,
		"requirement ID should appear exactly once",
	);
});

// ─── Test 4: Resolution evidence kind ────────────────────────────────────────

test("applyTriageReport: agent-fix evidence is stored correctly", () => {
	const root = makeForgeProject();
	roots.push(root);

	const result = recordSelfFeedback(
		{
			kind: "test-agent-fix",
			severity: "medium",
			summary: "Entry to resolve via agent-fix",
			source: "agent",
		},
		root,
	);
	assert.ok(result, "recordSelfFeedback must return a result");
	const entryId = result!.entry.id;

	const report: TriageReport = {
		clusterDecisions: [],
		promotedRequirements: [],
		resolutions: [
			{
				entryId,
				reason: "Fixed by agent in commit abc123",
				evidenceKind: "agent-fix",
			},
		],
	};

	applyTriageReport(root, report);

	const entries = readAllSelfFeedback(root);
	const resolved = entries.find((e) => e.id === entryId);
	assert.ok(resolved?.resolvedAt, "entry should be resolved");
	assert.equal(
		resolved?.resolvedEvidence?.kind,
		"agent-fix",
		"evidence kind must be agent-fix",
	);
	// Must not be auto-version-bump
	assert.notEqual(
		resolved?.resolvedEvidence?.kind,
		"auto-version-bump",
		"evidence kind must never be auto-version-bump",
	);
});

test("applyTriageReport: human-clear evidence is stored correctly", () => {
	const root = makeForgeProject();
	roots.push(root);

	const result = recordSelfFeedback(
		{
			kind: "test-human-clear",
			severity: "low",
			summary: "Entry to resolve via human-clear",
			source: "detector",
		},
		root,
	);
	assert.ok(result, "recordSelfFeedback must return a result");
	const entryId = result!.entry.id;

	const report: TriageReport = {
		clusterDecisions: [],
		promotedRequirements: [],
		resolutions: [
			{
				entryId,
				reason: "Operator cleared manually",
				evidenceKind: "human-clear",
			},
		],
	};

	applyTriageReport(root, report);

	const entries = readAllSelfFeedback(root);
	const resolved = entries.find((e) => e.id === entryId);
	assert.ok(resolved?.resolvedAt, "entry should be resolved");
	assert.equal(
		resolved?.resolvedEvidence?.kind,
		"human-clear",
		"evidence kind must be human-clear",
	);
	assert.notEqual(
		resolved?.resolvedEvidence?.kind,
		"auto-version-bump",
		"evidence kind must never be auto-version-bump",
	);
});

// ─── Test 5: Prompt contract ──────────────────────────────────────────────────

test("loadPrompt(triage-backlog, vars) parses without throwing", () => {
	const root = makeForgeProject();
	roots.push(root);

	// Should not throw even with empty feedback and missing files
	assert.doesNotThrow(() => {
		buildTriageBacklogPrompt(root);
	}, "buildTriageBacklogPrompt must not throw for a valid base path");
});

test("triage-backlog prompt: all declared {{vars}} are satisfied by loadTriageBacklogVars", () => {
	const root = makeForgeProject();
	roots.push(root);

	// Read the prompt template to check what vars it declares
	const promptPath = join(promptsDir, "triage-backlog.md");
	const promptContent = readFileSync(promptPath, "utf-8");
	const declared = promptContent.match(/\{\{[a-zA-Z][a-zA-Z0-9_]*\}\}/g) ?? [];
	const declaredKeys = [...new Set(declared.map((m) => m.slice(2, -2)))];

	// skillActivation is injected by loadPrompt itself, not by loadTriageBacklogVars
	const autoInjected = new Set(["skillActivation"]);
	const vars = loadTriageBacklogVars(root);

	for (const key of declaredKeys) {
		if (autoInjected.has(key)) continue;
		assert.ok(
			key in vars,
			`Template var {{${key}}} must be present in loadTriageBacklogVars output`,
		);
	}
});

// ─── Test 6: parseTriageReport ────────────────────────────────────────────────

test("parseTriageReport: parses raw JSON", () => {
	const raw: TriageReport = {
		clusterDecisions: [],
		promotedRequirements: [],
		resolutions: [],
	};
	const parsed = parseTriageReport(JSON.stringify(raw));
	assert.deepEqual(parsed.clusterDecisions, []);
	assert.deepEqual(parsed.promotedRequirements, []);
	assert.deepEqual(parsed.resolutions, []);
});

test("parseTriageReport: parses fenced ```json block surrounded by prose", () => {
	const report: TriageReport = {
		clusterDecisions: [
			{
				clusterKey: "key",
				entryIds: [],
				count: 0,
				summary: "test",
				recommendation: "defer",
				rationale: "Not now",
			},
		],
		promotedRequirements: [],
		resolutions: [],
	};
	const agentOutput = `Here is the triage report:\n\n\`\`\`json\n${JSON.stringify(report, null, 2)}\n\`\`\`\n\nTriage complete.`;
	const parsed = parseTriageReport(agentOutput);
	assert.equal(parsed.clusterDecisions.length, 1);
	assert.equal(parsed.clusterDecisions[0]?.clusterKey, "key");
});

test("parseTriageReport: throws on malformed JSON", () => {
	assert.throws(
		() => parseTriageReport("not json at all"),
		/could not parse JSON/,
	);
});

test("parseTriageReport: throws when top-level keys are missing", () => {
	assert.throws(
		() => parseTriageReport(JSON.stringify({ clusterDecisions: [] })),
		/missing or non-array/,
	);
});
