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 {
	buildInlineFixPrompt,
	filterTriageCandidatesByProviderPolicy,
	rankTriageModelsViaRouter,
	runTriage,
	selectInlineFixCandidates,
	writeTriageDecisionReport,
} from "../self-feedback-drain.js";

let tempDirs = [];

function makeForgeProject() {
	const dir = mkdtempSync(join(tmpdir(), "sf-drain-test-"));
	tempDirs.push(dir);
	writeFileSync(
		join(dir, "package.json"),
		JSON.stringify({ name: "singularity-forge" }),
		"utf-8",
	);
	mkdirSync(join(dir, ".sf"), { recursive: true });
	return dir;
}

function writeEntries(basePath, entries) {
	const path = join(basePath, ".sf", "self-feedback.jsonl");
	writeFileSync(
		path,
		`${entries.map((e) => JSON.stringify(e)).join("\n")}\n`,
		"utf-8",
	);
}

function entry(overrides = {}) {
	return {
		schemaVersion: 1,
		id: `sf-test-${Math.random().toString(36).slice(2, 8)}`,
		ts: new Date().toISOString(),
		kind: "inconsistency:something",
		severity: "medium",
		blocking: false,
		summary: "test entry",
		source: "runtime",
		...overrides,
	};
}

afterEach(() => {
	for (const dir of tempDirs) {
		rmSync(dir, { recursive: true, force: true });
	}
	tempDirs = [];
});

describe("selectInlineFixCandidates", () => {
	test("returns empty when basePath is not a forge repo", () => {
		const dir = mkdtempSync(join(tmpdir(), "sf-drain-test-nonforge-"));
		tempDirs.push(dir);
		writeFileSync(
			join(dir, "package.json"),
			JSON.stringify({ name: "some-other-app" }),
			"utf-8",
		);
		expect(selectInlineFixCandidates(dir)).toEqual([]);
	});

	test("selects every open forge-local entry regardless of severity, blocking, or kind (sorted by prioritization)", () => {
		const dir = makeForgeProject();
		writeEntries(dir, [
			entry({
				id: "hi-block",
				severity: "high",
				blocking: true,
				ts: "2026-05-01T00:00:00Z",
			}),
			entry({
				id: "crit-block",
				severity: "critical",
				blocking: true,
				kind: "runaway-loop:foo",
				ts: "2026-05-02T00:00:00Z",
			}),
			entry({
				id: "med-block",
				severity: "medium",
				blocking: true,
				kind: "inconsistency:bar",
				ts: "2026-05-03T00:00:00Z",
			}),
			entry({
				id: "med-nonblock",
				severity: "medium",
				blocking: false,
				kind: "janitor-gap:baz",
				ts: "2026-05-04T00:00:00Z",
			}),
			entry({
				id: "low-nonblock",
				severity: "low",
				blocking: false,
				kind: "gap:tiebreak",
				ts: "2026-05-05T00:00:00Z",
			}),
			entry({
				id: "arch",
				severity: "medium",
				blocking: false,
				kind: "architecture-defect:foo",
				ts: "2026-05-06T00:00:00Z",
			}),
		]);
		const ids = selectInlineFixCandidates(dir).map((e) => e.id);
		// Order: critical (95) > high (80) > medium (50) > low (20).
		// Within same impact, ts asc breaks the tie.
		expect(ids).toEqual([
			"crit-block", // critical / 95
			"hi-block", // high / 80
			"med-block", // medium / 50, oldest
			"med-nonblock", // medium / 50
			"arch", // medium / 50, newest
			"low-nonblock", // low / 20
		]);
	});

	test("excludes resolved entries with credible evidence kind", () => {
		const dir = makeForgeProject();
		writeEntries(dir, [
			entry({
				id: "agent-fixed",
				kind: "gap:foo",
				resolvedAt: "2026-05-10T00:00:00Z",
				resolvedEvidence: { kind: "agent-fix", commitSha: "abc1234" },
				resolvedReason: "Fixed by abc1234",
			}),
			entry({
				id: "human-cleared",
				kind: "gap:bar",
				resolvedAt: "2026-05-10T00:00:00Z",
				resolvedEvidence: { kind: "human-clear" },
				resolvedReason: "stale",
			}),
			entry({
				id: "promoted",
				kind: "gap:baz",
				resolvedAt: "2026-05-10T00:00:00Z",
				resolvedEvidence: {
					kind: "promoted-to-requirement",
					requirementId: "R1",
				},
			}),
		]);
		expect(selectInlineFixCandidates(dir)).toEqual([]);
	});

	test("re-includes entries resolved with auto-version-bump", () => {
		const dir = makeForgeProject();
		writeEntries(dir, [
			entry({
				id: "version-bump-resolved",
				severity: "high",
				blocking: true,
				resolvedAt: "2026-05-10T00:00:00Z",
				resolvedEvidence: { kind: "auto-version-bump" },
				resolvedReason: "auto-version-bump",
			}),
		]);
		const ids = selectInlineFixCandidates(dir).map((e) => e.id);
		expect(ids).toEqual(["version-bump-resolved"]);
	});

	test("re-includes entries with prose reason but no evidence kind (no narrative-trust carve-out)", () => {
		const dir = makeForgeProject();
		writeEntries(dir, [
			entry({
				id: "prose-only-resolution",
				kind: "gap:foo",
				resolvedAt: "2026-05-10T00:00:00Z",
				resolvedReason:
					"Fixed by splitting solver and executor roles per ADR-0079. Solver pinned to kimi-k2.6.",
			}),
		]);
		const ids = selectInlineFixCandidates(dir).map((e) => e.id);
		expect(ids).toEqual(["prose-only-resolution"]);
	});

	test("re-includes entries with non-canonical evidence shape (e.g. {file, line})", () => {
		const dir = makeForgeProject();
		writeEntries(dir, [
			entry({
				id: "non-canonical-evidence",
				kind: "gap:foo",
				resolvedAt: "2026-05-10T00:00:00Z",
				resolvedReason: "claimed implemented",
				resolvedEvidence: { file: "src/foo.js", line: 42 },
			}),
		]);
		const ids = selectInlineFixCandidates(dir).map((e) => e.id);
		expect(ids).toEqual(["non-canonical-evidence"]);
	});

	test("re-includes entries with no resolution narrative and no evidence kind", () => {
		const dir = makeForgeProject();
		writeEntries(dir, [
			entry({
				id: "empty-resolution",
				kind: "gap:foo",
				severity: "medium",
				resolvedAt: "2026-05-10T00:00:00Z",
				resolvedReason: "",
			}),
		]);
		const ids = selectInlineFixCandidates(dir).map((e) => e.id);
		expect(ids).toEqual(["empty-resolution"]);
	});

	test("orders by impact desc, then effort asc, then ts asc (prioritization)", () => {
		const dir = makeForgeProject();
		// Mixed entries: high-severity-newest should outrank low-severity-oldest;
		// at equal impact, low effort should outrank high effort; at equal both,
		// older ts breaks the tie.
		writeEntries(dir, [
			entry({
				id: "low-severity-oldest",
				severity: "low",
				kind: "gap:a",
				ts: "2026-05-01T00:00:00Z",
			}),
			entry({
				id: "high-severity-newer",
				severity: "high",
				kind: "gap:b",
				ts: "2026-05-05T00:00:00Z",
			}),
			entry({
				id: "high-severity-quick",
				severity: "high",
				kind: "gap:c",
				ts: "2026-05-04T00:00:00Z",
				effortEstimate: 1,
			}),
			entry({
				id: "high-severity-slow",
				severity: "high",
				kind: "gap:d",
				ts: "2026-05-03T00:00:00Z",
				effortEstimate: 5,
			}),
			entry({
				id: "medium-newest",
				severity: "medium",
				kind: "gap:e",
				ts: "2026-05-06T00:00:00Z",
			}),
		]);
		const ids = selectInlineFixCandidates(dir).map((e) => e.id);
		expect(ids).toEqual([
			"high-severity-quick", // high impact + effort=1 wins
			"high-severity-newer", // high impact + effort=undefined→3
			"high-severity-slow", // high impact + effort=5
			"medium-newest", // medium impact
			"low-severity-oldest", // low impact loses despite older ts
		]);
	});

	test("explicit impactScore overrides severity-derived default", () => {
		const dir = makeForgeProject();
		writeEntries(dir, [
			entry({
				id: "low-but-bumped",
				severity: "low",
				kind: "gap:bumped",
				ts: "2026-05-02T00:00:00Z",
				impactScore: 90,
			}),
			entry({
				id: "high-default",
				severity: "high",
				kind: "gap:default",
				ts: "2026-05-01T00:00:00Z",
			}),
		]);
		const ids = selectInlineFixCandidates(dir).map((e) => e.id);
		// 90 explicit beats 80 (severity=high default)
		expect(ids).toEqual(["low-but-bumped", "high-default"]);
	});
});

describe("triage model candidate policy", () => {
	test("filterTriageCandidatesByProviderPolicy_removes_paid_opencode_models_but_keeps_opencode_go", () => {
		expect(
			filterTriageCandidatesByProviderPolicy([
				"opencode/gpt-5.1",
				"opencode/minimax-m2.5-free",
				"opencode/qwen3-coder:free",
				"opencode-go/glm-5",
				"opencode-go/nemotron-3-super-free",
				"kimi-coding/kimi-k2.6",
			]),
		).toEqual([
			"opencode/minimax-m2.5-free",
			"opencode/qwen3-coder:free",
			"opencode-go/glm-5",
			"opencode-go/nemotron-3-super-free",
			"kimi-coding/kimi-k2.6",
		]);
	});

	test("rankTriageModelsViaRouter_when_candidates_include_paid_opencode_never_returns_them", async () => {
		const ranked = await rankTriageModelsViaRouter([
			"opencode/gpt-5.1",
			"opencode/gpt-5.2",
			"opencode/minimax-m2.5-free",
			"kimi-coding/kimi-for-coding",
			"minimax/MiniMax-M2.7",
		]);

		expect(ranked).not.toContain("opencode/gpt-5.1");
		expect(ranked).not.toContain("opencode/gpt-5.2");
		expect(ranked).toContain("opencode/minimax-m2.5-free");
	});
});

describe("buildInlineFixPrompt", () => {
	// Exported (sf-mp4rxkwb-l4baga partial) so headless-triage.ts can render
	// the canonical triage prompt without going through the followUp dispatch
	// path that gets dropped when the autonomous loop bails before any turn
	// runs.
	test("renders one section per entry with the triage decision protocol", () => {
		const prompt = buildInlineFixPrompt([
			entry({ id: "sf-a", kind: "gap:foo", summary: "first thing" }),
			entry({ id: "sf-b", kind: "gap:bar", summary: "second thing" }),
		]);
		expect(prompt).toContain("## sf-a — gap:foo");
		expect(prompt).toContain("## sf-b — gap:bar");
		expect(prompt).toContain("first thing");
		expect(prompt).toContain("second thing");
		// The decision protocol must be present so external models can
		// follow it without additional priming.
		expect(prompt).toContain("A. Fix it.");
		expect(prompt).toContain("B. Promote it.");
		expect(prompt).toContain("C. Close it.");
		expect(prompt).toContain("Self-feedback triage complete");
	});

	test("returns a usable prompt for an empty list", () => {
		// Operators occasionally pass no entries (e.g. piping a filtered
		// candidate set). The prompt should still render the protocol so
		// the model knows what to do — and so the function never throws.
		const prompt = buildInlineFixPrompt([]);
		expect(prompt).toContain("Self-feedback triage complete");
	});
});

describe("runTriage (dependency-injected)", () => {
	test("returns ok+content on success and flags clean-finish from terminator", async () => {
		const fakeMessage = {
			content: [
				{
					type: "text",
					text: "decision: close.\nSelf-feedback triage complete",
				},
			],
		};
		const result = await runTriage("triage prompt", {
			model: "anthropic/claude-sonnet-4-6",
			complete: async () => fakeMessage,
		});
		expect(result.ok).toBe(true);
		expect(result.content).toContain("decision: close");
		expect(result.cleanFinish).toBe(true);
	});

	test("flags cleanFinish=false when terminator missing", async () => {
		const result = await runTriage("triage prompt", {
			model: "anthropic/claude-sonnet-4-6",
			complete: async () => ({
				content: [{ type: "text", text: "partial output, no terminator" }],
			}),
		});
		expect(result.ok).toBe(true);
		expect(result.cleanFinish).toBe(false);
	});

	test("returns ok=false on unknown model string", async () => {
		const result = await runTriage("triage prompt", {
			model: "no-such-provider/no-such-model",
			complete: async () => ({ content: [] }),
		});
		expect(result.ok).toBe(false);
		expect(result.error).toContain("unknown model");
	});

	test("returns ok=false when complete throws", async () => {
		const result = await runTriage("triage prompt", {
			model: "anthropic/claude-sonnet-4-6",
			complete: async () => {
				throw new Error("provider boom");
			},
		});
		expect(result.ok).toBe(false);
		expect(result.error).toContain("provider boom");
	});
});

describe("runTriage model routing (sf-mp5khix3-9beona AC1)", () => {
	test("router scores candidates by agentic profile and picks the best", async () => {
		// Test fixture uses the operator's actual provider allowlist
		// (minimax + kimi-coding) instead of cross-provider examples:
		//   - MiniMax-M2.1 has agentic 40 (older gen, penalized after
		//     the 60+ checkpoint loop incident on 2026-05-13)
		//   - kimi-for-coding aliases to kimi-k2.6 capability profile,
		//     agentic 90 (pinned autonomous-solver default per ADR-0079)
		// With agentic-heavy requirements the router must pick kimi.
		const seen = { modelId: null };
		await runTriage("prompt", {
			candidates: ["minimax/MiniMax-M2.1", "kimi-coding/kimi-for-coding"],
			complete: async (model) => {
				seen.modelId = model.id;
				return { content: [{ type: "text", text: "ok" }] };
			},
		});
		expect(seen.modelId).toBe("kimi-for-coding");
	});

	test("operator --model still wins over router pick", async () => {
		const seen = { modelId: null };
		await runTriage("prompt", {
			model: "anthropic/claude-sonnet-4-6",
			complete: async (model) => {
				seen.modelId = model.id;
				return { content: [{ type: "text", text: "ok" }] };
			},
		});
		expect(seen.modelId).toBe("claude-sonnet-4-6");
	});

	test("candidates override picks from a custom list", async () => {
		const seen = { modelId: null };
		await runTriage("prompt", {
			candidates: ["anthropic/claude-opus-4-6"],
			complete: async (model) => {
				seen.modelId = model.id;
				return { content: [{ type: "text", text: "ok" }] };
			},
		});
		expect(seen.modelId).toBe("claude-opus-4-6");
	});
});

describe("writeTriageDecisionReport", () => {
	test("writes to .sf/triage/decisions/<ts>.md and returns the path", () => {
		const dir = makeForgeProject();
		const path = writeTriageDecisionReport(dir, "## decisions\nfoo");
		expect(path).toBeTruthy();
		expect(path).toMatch(/\.sf\/triage\/decisions\/.*\.md$/);
	});
});
