/**
 * headless-triage-run-routing.test.ts
 *
 * Verifies that `sf headless triage --run` routes through `runTriageApply`
 * with `dryRun: true` instead of the legacy `completeSimple` path
 * (sf-mp5khix3-9beona architecture-defect fix).
 *
 * Key contracts tested:
 * 1. --run dispatches via runTriageApply with dryRun=true (not completeSimple)
 * 2. resolve_issue / applyTriagePlan is NOT called during --run
 * 3. Model routing uses SF's router, not a hardcoded string
 * 4. The legacy runTriage (completeSimple) path is no longer invoked by --run
 * 5. dryRun=true relaxes the trusted-source guard (no allowUntrustedRunner needed)
 */

import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, test } from "vitest";

import {
	chooseRunnableSubagentModel,
	runTriageApply,
} from "../headless-triage.js";

const tempDirs: string[] = [];
let originalAgentDir: string | undefined;

const deciderPlan = [
	"```yaml",
	"decisions:",
	"  - id: sf-dryrun-1",
	"    outcome: close",
	"    evidence_kind: human-clear",
	"    reason: stale observation",
	"```",
	"Self-feedback triage complete.",
].join("\n");

function fakeRegistry(
	models: Array<{ provider: string; id: string; ready?: boolean }>,
) {
	return {
		find(provider: string, modelId: string) {
			return models.find((m) => m.provider === provider && m.id === modelId);
		},
		getAll() {
			return models;
		},
		isProviderRequestReady(provider: string) {
			return models.some((m) => m.provider === provider && m.ready !== false);
		},
	};
}

function makeProject(): string {
	const dir = mkdtempSync(join(tmpdir(), "sf-headless-triage-run-"));
	tempDirs.push(dir);
	mkdirSync(join(dir, ".sf"), { recursive: true });
	return dir;
}

afterEach(() => {
	if (originalAgentDir === undefined) delete process.env.SF_CODING_AGENT_DIR;
	else process.env.SF_CODING_AGENT_DIR = originalAgentDir;
	while (tempDirs.length > 0) {
		rmSync(tempDirs.pop()!, { recursive: true, force: true });
	}
});

beforeEach(() => {
	originalAgentDir = process.env.SF_CODING_AGENT_DIR;
	const dir = mkdtempSync(join(tmpdir(), "sf-headless-agent-dir-"));
	tempDirs.push(dir);
	process.env.SF_CODING_AGENT_DIR = dir;
});

test("chooseRunnableSubagentModel_skips_router_routes_missing_from_subagent_registry", () => {
	const selected = chooseRunnableSubagentModel(
		["openrouter/openai/gpt-5.1", "kimi-coding/kimi-k2.6"],
		fakeRegistry([{ provider: "kimi-coding", id: "kimi-k2.6" }]),
	);

	assert.equal(
		selected,
		"kimi-coding/kimi-k2.6",
		"triage must not pass a router-ranked model that runSubagent cannot find",
	);
});

test("chooseRunnableSubagentModel_returns_undefined_when_no_candidate_is_runnable", () => {
	const selected = chooseRunnableSubagentModel(
		["openrouter/openai/gpt-5.1"],
		fakeRegistry([{ provider: "kimi-coding", id: "kimi-k2.6" }]),
	);

	assert.equal(
		selected,
		undefined,
		"callers should omit the override and use the session default/fallback",
	);
});

// ── Test 1: dryRun=true runs both agents but skips Phase 3 ────────────────────

test("runTriageApply_dryRun_runs_decider_and_review_then_skips_apply", async () => {
	const project = makeProject();
	const calls: string[] = [];

	const result = await runTriageApply(project, "triage prompt", {
		dryRun: true,
		// dryRun relaxes the trusted-source guard — no allowUntrustedRunner needed
		agentRunner: async (agent) => {
			calls.push(agent.name);
			if (agent.name === "triage-decider") {
				return { ok: true, output: deciderPlan, exitCode: 0 };
			}
			return { ok: true, output: "review-code: agree", exitCode: 0 };
		},
	});

	// Both agents must have run (Phase 1 + Phase 2 complete)
	assert.deepEqual(
		calls,
		["triage-decider", "review-code"],
		"decider and review-code must both run in dry-run mode",
	);

	// ok=true: the decision was produced and the reviewer agreed
	assert.equal(
		result.ok,
		true,
		"dry-run should succeed when review-code agrees",
	);
	assert.equal(
		result.agreed,
		true,
		"agreed must be true when review-code agrees",
	);

	// No ledger mutations — resolvedIds must be empty
	assert.deepEqual(
		result.resolvedIds,
		[],
		"dry-run must not resolve any entries",
	);

	// deciderOutput must be captured for report writing
	assert.ok(
		typeof result.deciderOutput === "string" && result.deciderOutput.length > 0,
		"deciderOutput must be populated for report writing",
	);

	// flowId must use the triage-run- prefix (not triage-apply-)
	assert.ok(
		result.flowId?.startsWith("triage-run-"),
		`flowId should start with triage-run-, got: ${result.flowId}`,
	);
});

// ── Test 2: resolve_issue / applyTriagePlan not called in dryRun ──────────────

test("runTriageApply_dryRun_does_not_mutate_ledger_even_when_review_agrees", async () => {
	const project = makeProject();
	// Track if applyTriagePlan would have been attempted by checking whether
	// any markResolved-shaped side-effect occurred. In practice, the easiest
	// observable signal is that resolvedIds is empty and rejectedIds is absent
	// (--apply path sets rejectedIds when markResolved fails on missing entries).
	const result = await runTriageApply(project, "triage prompt", {
		dryRun: true,
		agentRunner: async (agent) => {
			if (agent.name === "triage-decider") {
				return { ok: true, output: deciderPlan, exitCode: 0 };
			}
			return { ok: true, output: "review-code: agree", exitCode: 0 };
		},
	});

	assert.equal(result.ok, true);
	assert.deepEqual(result.resolvedIds, []);
	// rejectedIds only appears when applyTriagePlan ran and failed; in dry-run
	// it should be absent (or empty), not a list of failures.
	assert.ok(
		result.rejectedIds === undefined || result.rejectedIds.length === 0,
		"rejectedIds must be absent or empty in dry-run (no apply attempted)",
	);
});

// ── Test 3: dryRun relaxes the trusted-source guard ──────────────────────────

test("runTriageApply_dryRun_allows_custom_runner_without_allowUntrustedRunner", async () => {
	// In --apply mode, passing a custom agentRunner without allowUntrustedRunner
	// is refused. In dryRun mode (no mutations), this guard is relaxed.
	const project = makeProject();
	const result = await runTriageApply(project, "triage prompt", {
		dryRun: true,
		// NOTE: no allowUntrustedRunner — dryRun should permit this
		agentRunner: async (agent) => {
			if (agent.name === "triage-decider") {
				return { ok: true, output: deciderPlan, exitCode: 0 };
			}
			return { ok: true, output: "review-code: agree", exitCode: 0 };
		},
	});

	assert.equal(
		result.ok,
		true,
		"dryRun should allow custom runner without allowUntrustedRunner",
	);
	assert.ok(
		!(result.error ?? "").match(/allowUntrustedRunner|custom agentRunner/i),
		`unexpected trust error in dry-run: ${result.error}`,
	);
});

// ── Test 4: legacy runTriage guard — dryRun does NOT call completeSimple ──────

test("runTriageApply_dryRun_does_not_invoke_completeSimple", async () => {
	// The agentRunner injection proves the call goes through the agent path,
	// not the completeSimple path. If completeSimple were invoked, it would
	// try to import @singularity-forge/ai and fail or produce different output.
	// We verify by confirming the agentRunner was actually called (agent path),
	// not bypassed (completeSimple path).
	const project = makeProject();
	let agentRunnerCalled = false;

	const result = await runTriageApply(project, "triage prompt", {
		dryRun: true,
		agentRunner: async (agent) => {
			agentRunnerCalled = true;
			if (agent.name === "triage-decider") {
				return { ok: true, output: deciderPlan, exitCode: 0 };
			}
			return { ok: true, output: "review-code: agree", exitCode: 0 };
		},
	});

	assert.equal(
		agentRunnerCalled,
		true,
		"agentRunner (agent path) must be called, not the legacy completeSimple path",
	);
	assert.equal(result.ok, true);
});

// ── Test 5: dryRun model field is not a hardcoded legacy string ───────────────

test("runTriageApply_dryRun_passes_model_from_options_not_hardcoded_gemini", async () => {
	const project = makeProject();
	const LEGACY_HARDCODED = "google-gemini-cli/gemini-3-pro-preview";
	let modelUsed: string | undefined;

	await runTriageApply(project, "triage prompt", {
		dryRun: true,
		model: "anthropic/claude-sonnet-4-5",
		agentRunner: async (_agent, _task, opts) => {
			modelUsed = opts?.model;
			if (_agent.name === "triage-decider") {
				return { ok: true, output: deciderPlan, exitCode: 0 };
			}
			return { ok: true, output: "review-code: agree", exitCode: 0 };
		},
	});

	assert.notEqual(
		modelUsed,
		LEGACY_HARDCODED,
		`--run must not use hardcoded "${LEGACY_HARDCODED}"`,
	);
	assert.equal(
		modelUsed,
		"anthropic/claude-sonnet-4-5",
		"model from options must be forwarded to agentRunner",
	);
});

// ── Test 6: dryRun when review-code disagrees still surfaces the plan ─────────

test("runTriageApply_dryRun_when_review_disagrees_returns_ok_false_with_decider_output", async () => {
	const project = makeProject();

	const result = await runTriageApply(project, "triage prompt", {
		dryRun: true,
		agentRunner: async (agent) => {
			if (agent.name === "triage-decider") {
				return { ok: true, output: deciderPlan, exitCode: 0 };
			}
			return {
				ok: true,
				output:
					"The evidence_kind for sf-dryrun-1 is unsupported. I cannot agree.",
				exitCode: 0,
			};
		},
	});

	assert.equal(result.ok, false);
	assert.equal(result.agreed, false);
	// deciderOutput must still be present so operator can inspect the plan
	assert.ok(
		typeof result.deciderOutput === "string" && result.deciderOutput.length > 0,
		"deciderOutput must be available even when review disagrees",
	);
	assert.deepEqual(result.resolvedIds, []);
});
