/**
 * commands-todo.test.ts - TODO.md dump triage artifact contract.
 */

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 { test } from 'vitest';

import {
	extractTodoDump,
	handleTodo,
	parseTodoTriageResponse,
	triageTodoDump,
	validateJsonlFile,
} from "../commands-todo.ts";
import { handleTriage } from "../commands-handlers.ts";

const fixedDate = new Date("2026-04-30T12:34:56.000Z");
const fixedLocalTimestamp = "20260430-143456";

function makeMockCtx(opts: { model?: unknown; modelRegistry?: unknown } = {}) {
	return {
		ui: { notify: () => {} },
		model: opts.model !== undefined ? opts.model : { id: "test-model", provider: "test", name: "Test Model" },
		modelRegistry: opts.modelRegistry !== undefined ? opts.modelRegistry : {
			getAvailable: () => [],
			getApiKey: async () => undefined,
		},
	} as any;
}

function makeMockPi() {
	return {
		sendMessage: () => {},
		setModel: async () => true,
	} as any;
}

test("extractTodoDump strips the empty inbox wrapper", () => {
	assert.equal(
		extractTodoDump("# TODO\n\nDump anything here.\n\n- keep this\n"),
		"- keep this",
	);
});

test("parseTodoTriageResponse accepts fenced JSON", () => {
	const parsed = parseTodoTriageResponse(`Here:
\`\`\`json
{
  "summary": "Triaged.",
  "eval_candidates": [
    {
      "task_input": "agent sees a TODO dump",
      "expected_behavior": "agent writes eval artifacts"
    }
  ],
  "implementation_tasks": ["wire command"],
  "memory_requirements": ["remember failure pattern"],
  "harness_suggestions": ["add deterministic test"],
  "docs_or_tests": ["document flow"],
  "unclear_notes": ["owner unknown"]
}
\`\`\``);

	assert.equal(parsed.summary, "Triaged.");
	assert.equal(parsed.eval_candidates.length, 1);
	assert.equal(parsed.eval_candidates[0].id, "todo.eval.001");
	assert.deepEqual(parsed.implementation_tasks, ["wire command"]);
});

test("triageTodoDump writes report, eval JSONL, normalized inbox, skill proposals, and clears TODO.md", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-triage-"));
	try {
		writeFileSync(
			join(base, "TODO.md"),
			"# TODO\n\nDump anything here.\n\n- agent forgot to turn repeated failure into eval\n",
		);

		const output = await triageTodoDump(
			base,
			async () =>
				JSON.stringify({
					summary: "Repeated failure should become an eval.",
					eval_candidates: [
						{
							id: "todo.eval.memory-repeat",
							task_input: "repeated failure appears in TODO.md",
							expected_behavior: "triage emits eval candidate JSONL",
							failure_mode: "agent treats note as runtime instruction",
							evidence: "TODO.md dump",
							source: "TODO.md",
							suggested_location: ".sf/triage/evals",
						},
					],
					implementation_tasks: ["add /sf todo triage"],
					memory_requirements: ["store repeated failure evidence"],
					harness_suggestions: ["gate backlog promotion"],
					docs_or_tests: ["test TODO triage command"],
					unclear_notes: [],
				}),
			{ date: fixedDate, clear: false },
		);

		assert.equal(
			output.markdownPath,
			join(base, ".sf", "triage", "reports", `${fixedLocalTimestamp}.md`),
		);
		assert.equal(
			output.evalJsonlPath,
			join(
				base,
				".sf",
				"triage",
				"evals",
				`${fixedLocalTimestamp}.evals.jsonl`,
			),
		);
		assert.equal(
			output.normalizedJsonlPath,
			join(base, ".sf", "triage", "inbox", `${fixedLocalTimestamp}.jsonl`),
		);
		assert.equal(
			output.skillJsonlPath,
			join(base, ".sf", "triage", "skills", `${fixedLocalTimestamp}.skills.jsonl`),
		);

		const evals = readFileSync(output.evalJsonlPath, "utf-8")
			.trim()
			.split("\n")
			.map((line) => JSON.parse(line));
		assert.equal(evals.length, 1);
		assert.equal(evals[0].id, "todo.eval.memory-repeat");

		const skills = readFileSync(output.skillJsonlPath, "utf-8")
			.trim()
			.split("\n")
			.filter(Boolean)
			.map((line) => JSON.parse(line));
		assert.equal(skills.length, 0, "no recurring patterns = no skill proposals");

		const inbox = readFileSync(output.normalizedJsonlPath, "utf-8")
			.trim()
			.split("\n")
			.map((line) => JSON.parse(line));
		assert.equal(inbox.length, 5);
		assert.equal(inbox[0].kind, "eval_candidate");
		assert.equal(inbox[0].source, "todo.md");
		assert.equal(inbox[0].status, "pending");
		assert.ok(
			readFileSync(output.markdownPath, "utf-8").includes("Repeated failure"),
		);
		assert.equal(output.backlogItemsAdded, 0);
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("triageTodoDump removes TODO.md when clear is true", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-clear-"));
	try {
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nclear me\n");

		await triageTodoDump(
			base,
			async () =>
				JSON.stringify({
					summary: "Cleared.",
					eval_candidates: [],
					implementation_tasks: [],
					memory_requirements: [],
					harness_suggestions: [],
					docs_or_tests: [],
					unclear_notes: [],
				}),
			{ date: fixedDate, clear: true },
		);

		assert.equal(existsSync(join(base, "TODO.md")), false);
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("triageTodoDump generates skill proposals for recurring eval candidates", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-skills-"));
	try {
		writeFileSync(
			join(base, "TODO.md"),
			"# TODO\n\nDump anything here.\n\n- agent forgot to turn repeated failure into eval\n",
		);

		const output = await triageTodoDump(
			base,
			async () =>
				JSON.stringify({
					summary: "Repeated failure should become an eval.",
					eval_candidates: [
						{
							id: "todo.eval.memory-repeat",
							task_input: "repeated failure appears in TODO.md",
							expected_behavior: "triage emits eval candidate JSONL",
							failure_mode: "agent treats note as runtime instruction",
							evidence: "TODO.md dump",
							source: "TODO.md",
							suggested_location: ".sf/triage/evals",
						},
						{
							id: "todo.eval.memory-repeat-2",
							task_input: "repeated failure appears in TODO.md",
							expected_behavior: "triage also emits skill proposal",
							failure_mode: "agent misses pattern",
							evidence: "TODO.md dump",
							source: "TODO.md",
							suggested_location: ".sf/triage/skills",
						},
					],
					implementation_tasks: [],
					memory_requirements: [],
					harness_suggestions: [],
					docs_or_tests: [],
					unclear_notes: [],
				}),
			{ date: fixedDate, clear: false },
		);

		assert.equal(
			output.skillJsonlPath,
			join(base, ".sf", "triage", "skills", `${fixedLocalTimestamp}.skills.jsonl`),
		);
		assert.ok(existsSync(output.skillJsonlPath), "skill proposals file should exist");

		const skills = readFileSync(output.skillJsonlPath, "utf-8")
			.trim()
			.split("\n")
			.map((line) => JSON.parse(line));
		assert.equal(skills.length, 1, "one recurring pattern should produce one skill proposal");
		assert.ok(skills[0].id.startsWith("skill."), "skill proposal id should start with skill.");
		assert.equal(skills[0].confidence, "medium", "2 occurrences = medium confidence");
		assert.ok(skills[0].trigger_pattern.includes("repeated failure"), "trigger pattern should capture the recurring theme");
		assert.equal(skills[0].source_evidence.length, 2, "should include both source evidences");
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("triageTodoDump generates skill proposals for recurring harness suggestions", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-skills-harness-"));
	try {
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n\n- add gate for missing eval\n");

		const output = await triageTodoDump(
			base,
			async () =>
				JSON.stringify({
					summary: "Harness suggestions.",
					eval_candidates: [],
					implementation_tasks: [],
					memory_requirements: [],
					harness_suggestions: [
						"add gate for missing eval before behavior change",
						"add gate for missing eval before behavior change",
						"add gate for missing eval before behavior change",
					],
					docs_or_tests: [],
					unclear_notes: [],
				}),
			{ date: fixedDate, clear: false },
		);

		const skills = readFileSync(output.skillJsonlPath, "utf-8")
			.trim()
			.split("\n")
			.map((line) => JSON.parse(line));
		assert.equal(skills.length, 1, "one recurring harness pattern should produce one skill proposal");
		assert.equal(skills[0].confidence, "high", "3 occurrences = high confidence");
		assert.ok(skills[0].title.toLowerCase().includes("gate"), "title should reference gate/harness");
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("triageTodoDump generates skill proposals for recurring memory requirements", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-skills-memory-"));
	try {
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n\n- remember eval pattern\n");

		const output = await triageTodoDump(
			base,
			async () =>
				JSON.stringify({
					summary: "Memory requirements.",
					eval_candidates: [],
					implementation_tasks: [],
					memory_requirements: [
						"remember to extract eval pattern from repeated failures",
						"remember to extract eval pattern from repeated failures",
					],
					harness_suggestions: [],
					docs_or_tests: [],
					unclear_notes: [],
				}),
			{ date: fixedDate, clear: false },
		);

		const skills = readFileSync(output.skillJsonlPath, "utf-8")
			.trim()
			.split("\n")
			.map((line) => JSON.parse(line));
		assert.equal(skills.length, 1, "one recurring memory pattern should produce one skill proposal");
		assert.equal(skills[0].confidence, "medium", "2 occurrences = medium confidence");
		assert.ok(skills[0].title.toLowerCase().includes("remember"), "title should reference memory");
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("triageTodoDump appends implementation tasks to backlog only when requested", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-backlog-"));
	try {
		mkdirSync(join(base, ".sf"), { recursive: true });
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nimplement task\n");

		const output = await triageTodoDump(
			base,
			async () =>
				JSON.stringify({
					summary: "Implementation task.",
					eval_candidates: [],
					implementation_tasks: ["build explicit triage command"],
					memory_requirements: [],
					harness_suggestions: [],
					docs_or_tests: [],
					unclear_notes: [],
				}),
			{ date: fixedDate, backlog: true },
		);

		const backlogPath = join(base, ".sf", "WORK-QUEUE.md");
		assert.equal(output.backlogItemsAdded, 1);
		assert.equal(existsSync(backlogPath), true);
		assert.match(
			readFileSync(backlogPath, "utf-8"),
			/- \[ \] 999\.1 — build explicit triage command \(triaged 2026-04-30\)/,
		);
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("triageTodoDump writes backlog JSONL with valid BacklogEntry schema", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-backlog-jsonl-"));
	try {
		mkdirSync(join(base, ".sf"), { recursive: true });
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nimpl task 1\nimpl task 2\n");

		const output = await triageTodoDump(
			base,
			async () =>
				JSON.stringify({
					summary: "Multiple tasks.",
					eval_candidates: [],
					implementation_tasks: ["task alpha", "task beta"],
					memory_requirements: [],
					harness_suggestions: [],
					docs_or_tests: [],
					unclear_notes: [],
				}),
			{ date: fixedDate, backlog: true },
		);

		const backlogJsonlPath = join(base, ".sf", "triage", "backlog", `${fixedLocalTimestamp}.jsonl`);
		assert.equal(existsSync(backlogJsonlPath), true, "backlog JSONL file should exist");

		const lines = readFileSync(backlogJsonlPath, "utf-8")
			.trim()
			.split("\n")
			.filter(Boolean);
		assert.equal(lines.length, 2, "should have one JSONL entry per implementation task");

		const entries = lines.map((line) => JSON.parse(line));
		const backlogMd = readFileSync(join(base, ".sf", "WORK-QUEUE.md"), "utf-8");

		for (let i = 0; i < entries.length; i++) {
			const entry = entries[i];
			assert.match(entry.id, /^999\.\d+$/, "id should match 999.x pattern");
			assert.equal(entry.source, "todo-triage");
			assert.equal(entry.kind, "implementation_task");
			assert.equal(entry.status, "pending");
			assert.ok(entry.title, "title should be present");
			assert.ok(
				Date.parse(entry.triaged_at) > 0,
				"triaged_at should be a valid ISO date string",
			);
			assert.ok(
				backlogMd.includes(entry.id),
				`JSONL entry id ${entry.id} should match an id in WORK-QUEUE.md`,
			);
		}
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("triageTodoDump throws when TODO.md is missing", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-missing-"));
	try {
		await assert.rejects(
			async () =>
				triageTodoDump(
					base,
					async () => "{}",
					{ date: fixedDate },
				),
			/no root TODO\.md found/i,
		);
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("triageTodoDump throws when TODO.md has no dump content", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-empty-"));
	try {
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n");
		await assert.rejects(
			async () =>
				triageTodoDump(
					base,
					async () => "{}",
					{ date: fixedDate },
				),
			/TODO\.md has no dump content to triage/i,
		);
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("handleTriage with --source todo routes to todo triage path", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-triage-source-todo-"));
	const notifications: Array<{ message: string; type: string }> = [];
	try {
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n\n- test item\n");

		const ctx = makeMockCtx();
		ctx.ui.notify = (message: string, type: string) => {
			notifications.push({ message, type });
		};
		const pi = makeMockPi();

		await handleTriage("--source todo", ctx, pi, base);

		// The LLM call will fail because completeSimple is not mocked,
		// but the notification proves it routed to the todo triage path.
		assert.equal(notifications.length, 1);
		assert.equal(notifications[0].type, "warning");
		assert.ok(notifications[0].message.includes("TODO triage failed:"));
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("handleTriage with --source todo warns when no model is available", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-triage-no-model-"));
	const notifications: Array<{ message: string; type: string }> = [];
	try {
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n\n- test item\n");

		const ctx = makeMockCtx({ model: null, modelRegistry: { getAvailable: () => [] } });
		ctx.ui.notify = (message: string, type: string) => {
			notifications.push({ message, type });
		};
		const pi = makeMockPi();

		await handleTriage("--source todo", ctx, pi, base);

		assert.equal(notifications.length, 1);
		assert.equal(notifications[0].type, "warning");
		assert.ok(notifications[0].message.includes("No model available for TODO triage."));
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("handleTriage without --source notifies when no pending captures exist", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-triage-captures-"));
	const notifications: Array<{ message: string; type: string }> = [];
	try {
		const ctx = makeMockCtx();
		ctx.ui.notify = (message: string, type: string) => {
			notifications.push({ message, type });
		};
		const pi = makeMockPi();

		await handleTriage("", ctx, pi, base);

		assert.equal(notifications.length, 1);
		assert.equal(notifications[0].type, "info");
		assert.ok(notifications[0].message.includes("No pending captures to triage."));
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("handleTodo removes empty TODO.md and notifies info", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-handle-empty-"));
	const notifications: Array<{ message: string; type: string }> = [];
	const originalProjectRoot = process.env.SF_PROJECT_ROOT;
	try {
		process.env.SF_PROJECT_ROOT = base;
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n");

		const ctx = makeMockCtx();
		ctx.ui.notify = (message: string, type: string) => {
			notifications.push({ message, type });
		};
		const pi = makeMockPi();

		await handleTodo("triage", ctx, pi);

		assert.equal(notifications.length, 1);
		assert.equal(notifications[0].type, "info");
		assert.ok(notifications[0].message.includes("TODO.md was empty"));
		assert.equal(existsSync(join(base, "TODO.md")), false);
	} finally {
		if (originalProjectRoot === undefined) {
			delete process.env.SF_PROJECT_ROOT;
		} else {
			process.env.SF_PROJECT_ROOT = originalProjectRoot;
		}
		rmSync(base, { recursive: true, force: true });
	}
});

test("triageTodoDump in CI mode skips LLM call when hash unchanged", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-ci-skip-"));
	try {
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n\n- ci skip test\n");

		// First run: should process
		const output1 = await triageTodoDump(
			base,
			async () =>
				JSON.stringify({
					summary: "CI test.",
					eval_candidates: [],
					implementation_tasks: [],
					memory_requirements: [],
					harness_suggestions: [],
					docs_or_tests: [],
					unclear_notes: [],
				}),
			{ date: fixedDate, ci: true },
		);
		assert.equal(output1.skipped, false);
		assert.ok(existsSync(join(base, ".sf", "triage", ".last-hash")));

		// Second run with same content: should skip
		const output2 = await triageTodoDump(
			base,
			async () => "should-not-be-called",
			{ date: fixedDate, ci: true },
		);
		assert.equal(output2.skipped, true);
		assert.equal(output2.backlogItemsAdded, 0);
		assert.equal(output2.result.summary, "TODO.md unchanged since last triage — skipping.");
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("triageTodoDump in CI mode uses deterministic suffix from env", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-ci-suffix-"));
	const originalSha = process.env.GITHUB_SHA;
	try {
		process.env.GITHUB_SHA = "abc123def456789";
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n\n- ci suffix test\n");

		const output = await triageTodoDump(
			base,
			async () =>
				JSON.stringify({
					summary: "CI suffix test.",
					eval_candidates: [],
					implementation_tasks: [],
					memory_requirements: [],
					harness_suggestions: [],
					docs_or_tests: [],
					unclear_notes: [],
				}),
			{ date: fixedDate, ci: true },
		);

		assert.equal(output.skipped, false);
		assert.ok(output.markdownPath.includes("abc123def456789"), "should use GITHUB_SHA as filename suffix");
		assert.ok(existsSync(output.markdownPath));
	} finally {
		if (originalSha === undefined) {
			delete process.env.GITHUB_SHA;
		} else {
			process.env.GITHUB_SHA = originalSha;
		}
		rmSync(base, { recursive: true, force: true });
	}
});

test("triageTodoDump in CI mode forces no-clear and backlog", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-ci-forces-"));
	try {
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n\n- ci force test\n");

		const output = await triageTodoDump(
			base,
			async () =>
				JSON.stringify({
					summary: "CI force test.",
					eval_candidates: [],
					implementation_tasks: ["forced backlog item"],
					memory_requirements: [],
					harness_suggestions: [],
					docs_or_tests: [],
					unclear_notes: [],
				}),
			{ date: fixedDate, ci: true, clear: true, backlog: false },
		);

		assert.equal(output.skipped, false);
		assert.ok(existsSync(join(base, "TODO.md")), "TODO.md should NOT be cleared in CI mode");
		assert.equal(output.backlogItemsAdded, 1, "backlog should be forced true in CI mode");
		assert.ok(existsSync(join(base, ".sf", "WORK-QUEUE.md")));
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("triageTodoDump in CI mode validates JSONL schemas and passes for valid output", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-ci-validate-"));
	try {
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n\n- ci validate test\n");

		// Should not throw — all JSONL is generated via JSON.stringify
		const output = await triageTodoDump(
			base,
			async () =>
				JSON.stringify({
					summary: "CI validate test.",
					eval_candidates: [{ task_input: "x", expected_behavior: "y" }],
					implementation_tasks: [],
					memory_requirements: [],
					harness_suggestions: [],
					docs_or_tests: [],
					unclear_notes: [],
				}),
			{ date: fixedDate, ci: true },
		);

		assert.equal(output.skipped, false);
		assert.ok(existsSync(output.evalJsonlPath));
		assert.ok(existsSync(output.normalizedJsonlPath));
		assert.ok(existsSync(output.skillJsonlPath));

		// Verify all JSONL files are parseable
		for (const path of [output.evalJsonlPath, output.normalizedJsonlPath, output.skillJsonlPath]) {
			const lines = readFileSync(path, "utf-8")
				.split("\n")
				.filter((line) => line.trim());
			for (const line of lines) {
				assert.doesNotThrow(() => JSON.parse(line), `Invalid JSONL in ${path}`);
			}
		}
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("handleTodo with --ci flag passes ci option to triageTodoDump", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-handle-ci-"));
	const notifications: Array<{ message: string; type: string }> = [];
	const originalProjectRoot = process.env.SF_PROJECT_ROOT;
	try {
		process.env.SF_PROJECT_ROOT = base;
		// Use empty TODO.md to test the CI-mode empty-check path without hitting the LLM
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n");

		const ctx = makeMockCtx();
		ctx.ui.notify = (message: string, type: string) => {
			notifications.push({ message, type });
		};
		const pi = makeMockPi();

		await handleTodo("triage --ci", ctx, pi);

		assert.ok(
			notifications.some((n) => n.type === "info" && n.message.includes("TODO.md was empty — nothing to triage in CI mode.")),
			"should show CI-specific empty message",
		);
		// In CI mode, empty TODO.md should NOT be removed
		assert.ok(existsSync(join(base, "TODO.md")), "TODO.md should not be removed in CI mode");
	} finally {
		if (originalProjectRoot === undefined) {
			delete process.env.SF_PROJECT_ROOT;
		} else {
			process.env.SF_PROJECT_ROOT = originalProjectRoot;
		}
		rmSync(base, { recursive: true, force: true });
	}
});

test("handleTriage with --source todo --ci routes to todo triage path in CI mode", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-triage-source-todo-ci-"));
	const notifications: Array<{ message: string; type: string }> = [];
	try {
		// Use empty TODO.md — handleTriage routes directly to triageTodoDump which throws for empty content
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n");

		const ctx = makeMockCtx();
		ctx.ui.notify = (message: string, type: string) => {
			notifications.push({ message, type });
		};
		const pi = makeMockPi();

		await handleTriage("--source todo --ci", ctx, pi, base);

		// handleTriage routes to triageTodoDump directly; empty content produces a failure notification
		assert.ok(
			notifications.some((n) => n.type === "warning" && n.message.includes("TODO triage failed:")),
			"should route to todo triage path in CI mode",
		);
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("triageTodoDump in CI mode re-calls LLM when TODO.md content changes", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-ci-change-"));
	let callCount = 0;
	try {
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n\n- first version\n");

		// First run
		const output1 = await triageTodoDump(
			base,
			async () => {
				callCount++;
				return JSON.stringify({
					summary: "First.",
					eval_candidates: [],
					implementation_tasks: [],
					memory_requirements: [],
					harness_suggestions: [],
					docs_or_tests: [],
					unclear_notes: [],
				});
			},
			{ date: fixedDate, ci: true },
		);
		assert.equal(output1.skipped, false);
		assert.equal(callCount, 1);

		// Change content
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n\n- second version\n");

		// Second run should re-call LLM because hash changed
		const output2 = await triageTodoDump(
			base,
			async () => {
				callCount++;
				return JSON.stringify({
					summary: "Second.",
					eval_candidates: [],
					implementation_tasks: [],
					memory_requirements: [],
					harness_suggestions: [],
					docs_or_tests: [],
					unclear_notes: [],
				});
			},
			{ date: fixedDate, ci: true },
		);
		assert.equal(output2.skipped, false);
		assert.equal(callCount, 2);
		assert.equal(output2.result.summary, "Second.");
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("validateJsonlFile detects corrupted JSONL lines", () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-validate-jsonl-"));
	try {
		const validPath = join(base, "valid.jsonl");
		const invalidPath = join(base, "invalid.jsonl");

		writeFileSync(validPath, '{"a":1}\n{"b":2}\n');
		writeFileSync(invalidPath, '{"a":1}\nthis is not json\n{"b":2}\n');

		assert.deepEqual(validateJsonlFile(validPath, "valid"), { ok: true });

		const invalidResult = validateJsonlFile(invalidPath, "eval");
		assert.equal(invalidResult.ok, false);
		assert.ok(invalidResult.error?.includes("eval line 2:"), "error should mention the line number and schema name");
		assert.ok(invalidResult.error?.includes("Unexpected token"), "error should include the parse error");
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("triageTodoDump in CI mode throws when JSONL schema validation fails on corrupted output", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-ci-schema-fail-"));
	try {
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n\n- schema fail test\n");

		// Mock LLM returns valid data, but we'll corrupt the file after triageTodoDump writes it
		// by monkey-patching writeFileSync via a proxy on the module. Since ES modules are sealed,
		// we instead test the integration path: run triage, corrupt one file, then validate directly.
		const output = await triageTodoDump(
			base,
			async () =>
				JSON.stringify({
					summary: "Schema fail.",
					eval_candidates: [{ task_input: "x", expected_behavior: "y" }],
					implementation_tasks: [],
					memory_requirements: [],
					harness_suggestions: [],
					docs_or_tests: [],
					unclear_notes: [],
				}),
			{ date: fixedDate, ci: true },
		);
		assert.equal(output.skipped, false);

		// Corrupt the eval JSONL after it was written
		writeFileSync(output.evalJsonlPath, "this is not valid json\n");

		const validation = validateJsonlFile(output.evalJsonlPath, "eval");
		assert.equal(validation.ok, false);
		assert.ok(validation.error?.includes("eval line 1:"));
	} finally {
		rmSync(base, { recursive: true, force: true });
	}
});

test("handleTodo with --ci and empty TODO.md notifies skip without error", async () => {
	const base = mkdtempSync(join(tmpdir(), "sf-todo-handle-ci-empty-"));
	const notifications: Array<{ message: string; type: string }> = [];
	const originalProjectRoot = process.env.SF_PROJECT_ROOT;
	try {
		process.env.SF_PROJECT_ROOT = base;
		writeFileSync(join(base, "TODO.md"), "# TODO\n\nDump anything here.\n");

		const ctx = makeMockCtx();
		ctx.ui.notify = (message: string, type: string) => {
			notifications.push({ message, type });
		};
		const pi = makeMockPi();

		await handleTodo("triage --ci", ctx, pi);

		// Should be an info notification, not a warning/error
		assert.equal(notifications.length, 1);
		assert.equal(notifications[0].type, "info");
		assert.ok(notifications[0].message.includes("TODO.md was empty — nothing to triage in CI mode."));
		// In CI mode, empty TODO.md should NOT be removed
		assert.ok(existsSync(join(base, "TODO.md")), "TODO.md should not be removed in CI mode");
	} finally {
		if (originalProjectRoot === undefined) {
			delete process.env.SF_PROJECT_ROOT;
		} else {
			process.env.SF_PROJECT_ROOT = originalProjectRoot;
		}
		rmSync(base, { recursive: true, force: true });
	}
});
