/**
 * reflection.test.mjs — corpus assembler + brief renderer.
 *
 * Phase 1A scope: validate the data-assembly half of the reflection layer.
 * The LLM-call half lives in headless-reflect.ts and the future
 * autonomous-loop unit handler; those get their own coverage when they land.
 */
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import {
	assembleReflectionCorpus,
	renderReflectionCorpusBrief,
	writeReflectionReport,
} from "../reflection.js";
import { markResolved, recordSelfFeedback } from "../self-feedback.js";
import {
	closeDatabase,
	insertMilestone,
	insertSlice,
	insertTask,
	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-reflection-"));
	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 seedMilestone(_dir, milestoneId, slices) {
	insertMilestone({ id: milestoneId, title: milestoneId, status: "active" });
	for (const slice of slices) {
		insertSlice({
			milestoneId,
			id: slice.id,
			title: slice.id,
			status: slice.status,
			risk: "medium",
			sequence: 1,
		});
		for (const task of slice.tasks ?? []) {
			insertTask({
				milestoneId,
				sliceId: slice.id,
				id: task.id,
				title: task.id,
				status: task.status,
				description: "",
				estimate: "",
				files: [],
				sequence: 1,
			});
		}
	}
}

function writeValidation(dir, milestoneId, verdict, body) {
	const mdir = join(dir, ".sf", "milestones", milestoneId);
	mkdirSync(mdir, { recursive: true });
	writeFileSync(
		join(mdir, `${milestoneId}-VALIDATION.md`),
		`---\nverdict: ${verdict}\n---\n\n${body}\n`,
	);
}

describe("assembleReflectionCorpus", () => {
	test("returns null when DB is not open", () => {
		// no makeForgeProject → no openDatabase → DB not available
		const corpus = assembleReflectionCorpus("/tmp/does-not-exist");
		expect(corpus).toBe(null);
	});

	test("collects open and recent-resolved forge entries", () => {
		const dir = makeForgeProject();
		// open entry
		const openRes = recordSelfFeedback(
			{ kind: "gap:foo", severity: "medium", summary: "open one" },
			dir,
		);
		// resolved entry (today, well within lookback)
		const resolvedRes = recordSelfFeedback(
			{
				kind: "architecture-defect:bar",
				severity: "high",
				summary: "resolved one",
			},
			dir,
		);
		markResolved(
			resolvedRes.entry.id,
			{
				reason: "fixed",
				evidence: { kind: "agent-fix", commitSha: "abc1234" },
			},
			dir,
		);

		const corpus = assembleReflectionCorpus(dir);
		expect(corpus).not.toBe(null);
		expect(corpus.openEntries).toHaveLength(1);
		expect(corpus.openEntries[0].id).toBe(openRes.entry.id);
		expect(corpus.recentResolvedEntries).toHaveLength(1);
		expect(corpus.recentResolvedEntries[0].id).toBe(resolvedRes.entry.id);
		expect(corpus.recentResolvedEntries[0].resolvedEvidence?.kind).toBe(
			"agent-fix",
		);
	});

	test("excludes resolutions older than the lookback window", async () => {
		const dir = makeForgeProject();
		// Seed an entry, mark it resolved, then back-date its resolved_at
		// directly via DB so it falls outside the 30-day lookback.
		const filed = recordSelfFeedback(
			{ kind: "gap:old", severity: "low", summary: "old one" },
			dir,
		);
		markResolved(
			filed.entry.id,
			{ reason: "old fix", evidence: { kind: "human-clear" } },
			dir,
		);
		// Back-date 60 days. Use the same DB adapter via dynamic ESM import.
		const oldTs = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString();
		const { _getAdapter } = await import("../sf-db/sf-db-core.js");
		_getAdapter()
			.prepare("UPDATE self_feedback SET resolved_at = :ts WHERE id = :id")
			.run({ ":ts": oldTs, ":id": filed.entry.id });

		const corpus = assembleReflectionCorpus(dir);
		expect(corpus.openEntries).toHaveLength(0);
		expect(corpus.recentResolvedEntries).toHaveLength(0);
	});

	test("captures milestone state and per-slice task counts", () => {
		const dir = makeForgeProject();
		seedMilestone(dir, "M001", [
			{
				id: "S01",
				status: "complete",
				tasks: [
					{ id: "T01", status: "complete" },
					{ id: "T02", status: "complete" },
				],
			},
			{
				id: "S02",
				status: "pending",
				tasks: [{ id: "T01", status: "pending" }],
			},
		]);

		const corpus = assembleReflectionCorpus(dir);
		expect(corpus.milestones).toHaveLength(1);
		const m = corpus.milestones[0];
		expect(m.id).toBe("M001");
		expect(m.slices).toHaveLength(2);
		expect(m.slices[0].id).toBe("S01");
		expect(m.slices[0].tasks).toHaveLength(2);
		expect(m.slices[1].tasks).toHaveLength(1);
	});

	test("captures milestone validation verdicts", () => {
		const dir = makeForgeProject();
		writeValidation(
			dir,
			"M001",
			"needs-attention",
			"S02-S05 still pending per this stale file",
		);
		writeValidation(dir, "M002", "pass", "All criteria met");

		const corpus = assembleReflectionCorpus(dir);
		expect(corpus.validations).toHaveLength(2);
		const verdictByMilestone = Object.fromEntries(
			corpus.validations.map((v) => [v.milestoneId, v.verdict]),
		);
		expect(verdictByMilestone.M001).toBe("needs-attention");
		expect(verdictByMilestone.M002).toBe("pass");
	});

	test("returns commits as an array (best-effort, may be empty in tmpdir)", () => {
		const dir = makeForgeProject();
		const corpus = assembleReflectionCorpus(dir);
		// tmpdir is not a git repo so git log fails → []. Any value other than
		// an array would mean the helper threw out, which it must never.
		expect(Array.isArray(corpus.commits)).toBe(true);
	});
});

describe("renderReflectionCorpusBrief", () => {
	test("renders all major sections even with empty inputs", () => {
		const dir = makeForgeProject();
		const corpus = assembleReflectionCorpus(dir);
		const brief = renderReflectionCorpusBrief(corpus);
		expect(brief).toContain("Open self-feedback entries");
		expect(brief).toContain("Recently resolved self-feedback");
		expect(brief).toContain("Recent commits");
		expect(brief).toContain("Milestone state");
		expect(brief).toContain("Milestone validation verdicts");
		expect(brief).toContain("Previous reflection report");
	});

	test("includes entry id, severity, and kind in brief", () => {
		const dir = makeForgeProject();
		recordSelfFeedback(
			{
				kind: "gap:visible-in-brief",
				severity: "high",
				summary: "must appear",
			},
			dir,
		);
		const corpus = assembleReflectionCorpus(dir);
		const brief = renderReflectionCorpusBrief(corpus);
		expect(brief).toMatch(/sf-[\w-]+/);
		expect(brief).toContain("gap:visible-in-brief");
		expect(brief).toContain("[high]");
		expect(brief).toContain("must appear");
	});
});

describe("writeReflectionReport", () => {
	test("writes a timestamped report to .sf/reflection/", () => {
		const dir = makeForgeProject();
		const path = writeReflectionReport(
			dir,
			"# Test reflection\n\nSome content.\n",
		);
		expect(path).toBeTruthy();
		expect(path).toMatch(/-report\.md$/);

		// And next-pass should be able to read it as the previous report
		const corpus = assembleReflectionCorpus(dir);
		expect(corpus.previousReport).toBeTruthy();
		expect(corpus.previousReport.content).toContain("Test reflection");
	});
});

describe("runReflection", () => {
	function fakeAssistantMessage(text) {
		return {
			role: "assistant",
			content: [{ type: "text", text }],
			api: "google-gemini-cli",
			provider: "google-gemini-cli",
			model: "gemini-3-pro-preview",
			usage: {
				input: 0,
				output: 0,
				cacheRead: 0,
				cacheWrite: 0,
				totalTokens: 0,
				cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
			},
			stopReason: "stop",
			timestamp: Date.now(),
		};
	}

	test("returns ok with content + cleanFinish when terminator present", async () => {
		const { runReflection } = await import("../reflection.js");
		const result = await runReflection("a".repeat(100), {
			complete: async (_model, context) => {
				const userText = context.messages[0].content[0].text;
				return fakeAssistantMessage(
					`# Reflection\nReceived ${userText.length} chars\nREFLECTION_COMPLETE\n`,
				);
			},
			timeoutMs: 5000,
		});
		expect(result.ok).toBe(true);
		expect(result.content).toContain("Received 100 chars");
		expect(result.cleanFinish).toBe(true);
		expect(result.provider).toBeTruthy();
	});

	test("returns ok=false when the provider throws", async () => {
		const { runReflection } = await import("../reflection.js");
		const result = await runReflection("ignored", {
			complete: async () => {
				throw new Error("synthetic upstream failure");
			},
			timeoutMs: 5000,
		});
		expect(result.ok).toBe(false);
		expect(result.error).toMatch(/synthetic upstream failure/);
	});

	test("returns ok=false with timeout error when the call hangs", async () => {
		const { runReflection } = await import("../reflection.js");
		const result = await runReflection("ignored", {
			complete: () =>
				new Promise(() => {
					/* never resolves */
				}),
			timeoutMs: 200,
		});
		expect(result.ok).toBe(false);
		expect(result.error).toMatch(/timed out/);
	});

	test("flags cleanFinish=false when terminator absent", async () => {
		const { runReflection } = await import("../reflection.js");
		const result = await runReflection("ignored", {
			complete: async () =>
				fakeAssistantMessage("# Truncated\nNo terminator here.\n"),
			timeoutMs: 5000,
		});
		expect(result.ok).toBe(true);
		expect(result.cleanFinish).toBe(false);
	});

	test("rejects model strings that aren't provider/modelId shaped", async () => {
		const { runReflection } = await import("../reflection.js");
		const result = await runReflection("ignored", {
			model: "bare-model-name-no-slash",
			complete: async () => fakeAssistantMessage("never called"),
			timeoutMs: 5000,
		});
		expect(result.ok).toBe(false);
		expect(result.error).toMatch(/unknown model/);
	});

	test("concatenates multi-text-content responses", async () => {
		const { runReflection } = await import("../reflection.js");
		const result = await runReflection("ignored", {
			complete: async () => ({
				role: "assistant",
				content: [
					{ type: "text", text: "part1\n" },
					{ type: "thinking", text: "internal — should be ignored" },
					{ type: "text", text: "part2\nREFLECTION_COMPLETE\n" },
				],
				api: "google-gemini-cli",
				provider: "google-gemini-cli",
				model: "gemini-3-pro-preview",
				usage: {
					input: 0,
					output: 0,
					cacheRead: 0,
					cacheWrite: 0,
					totalTokens: 0,
					cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
				},
				stopReason: "stop",
				timestamp: Date.now(),
			}),
			timeoutMs: 5000,
		});
		expect(result.ok).toBe(true);
		expect(result.content).toBe("part1\npart2\nREFLECTION_COMPLETE\n");
		expect(result.cleanFinish).toBe(true);
	});
});
