/**
 * self-feedback-drain.ts - dispatch high-priority sf self-feedback as repair work.
 *
 * Purpose: high/critical self-feedback should not remain a passive startup
 * warning; it should become an executable repair turn when sf is dogfooding
 * itself.
 *
 * Consumer: session_start hook in bootstrap/register-hooks.ts.
 */
import { execFileSync, spawn } from "node:child_process";
import {
	existsSync,
	mkdirSync,
	readFileSync,
	unlinkSync,
	writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { getErrorMessage } from "./error-utils.js";
import { sfRoot, sfRuntimeRoot } from "./paths.js";
import { readAllSelfFeedback } from "./self-feedback.js";

/**
 * Resolution-evidence kinds that count as authoritative for the inline-fix
 * dispatcher. Any other kind — including null/missing — is suspect, and the
 * entry is re-included as a candidate so the unit gets another chance to
 * close it with proper evidence.
 *
 * Why an allowlist instead of a denylist: an earlier version of this check
 * trusted any resolution that had a prose `resolvedReason` (to preserve
 * legacy operator clears), but in dogfood the triage agent produced fresh
 * resolutions with prose reasons and null evidence — bypassing the
 * `resolve_issue` tool which would have stamped a credible kind. Those
 * non-canonical resolutions slipped through the trust-narrative path.
 *
 * Legacy operator clears that genuinely lack an evidence kind must be
 * backfilled with `kind: "human-clear"` to remain trusted; that backfill
 * is a one-shot data migration, not an ongoing carve-out in the policy.
 */
const CREDIBLE_RESOLUTION_KINDS = new Set([
	"agent-fix",
	"human-clear",
	"promoted-to-requirement",
]);

/**
 * Decide whether an entry's recorded resolution is suspect — i.e. the entry
 * is marked resolved but the evidence kind is not in
 * CREDIBLE_RESOLUTION_KINDS.
 *
 * Returns `false` only for unresolved entries and for entries whose
 * `resolvedEvidence.kind` is one of the credible kinds. Any other state —
 * `auto-version-bump`, a non-standard kind, or missing kind entirely
 * (regardless of how rich the prose `resolvedReason` is) — returns `true`
 * so the entry is re-included as a candidate.
 *
 * Legacy entries that lack an evidence kind must be backfilled with
 * `kind: "human-clear"` to remain trusted under this check.
 */
function isSuspectlyResolved(entry) {
	if (!entry.resolvedAt) return false;
	const evidenceKind = entry.resolvedEvidence?.kind;
	return !CREDIBLE_RESOLUTION_KINDS.has(evidenceKind);
}

const CLAIM_TTL_MS = 30 * 60 * 1000;
function claimPath(basePath) {
	return join(
		sfRuntimeRoot(basePath),
		"runtime",
		"self-feedback-inline-fix.json",
	);
}
function readClaim(basePath) {
	try {
		const path = claimPath(basePath);
		if (!existsSync(path)) return null;
		return JSON.parse(readFileSync(path, "utf-8"));
	} catch {
		return null;
	}
}
function writeClaim(basePath, ids) {
	const path = claimPath(basePath);
	mkdirSync(dirname(path), { recursive: true });
	writeFileSync(
		path,
		JSON.stringify({ ids, dispatchedAt: new Date().toISOString() }, null, 2),
		"utf-8",
	);
}
function writeFailedClaim(basePath, ids, error) {
	const path = claimPath(basePath);
	mkdirSync(dirname(path), { recursive: true });
	writeFileSync(
		path,
		JSON.stringify(
			{
				ids,
				dispatchedAt: new Date(Date.now() - CLAIM_TTL_MS - 1).toISOString(),
				lastDispatchError: error,
			},
			null,
			2,
		),
		"utf-8",
	);
}
function clearClaim(basePath) {
	try {
		unlinkSync(claimPath(basePath));
	} catch {
		/* non-fatal */
	}
}
function sameIds(a, b) {
	return a.length === b.length && a.every((id, idx) => id === b[idx]);
}
function claimStillFresh(claim, ids) {
	if (!Array.isArray(claim?.ids)) return false;
	if (!sameIds(claim.ids, ids)) return false;
	const age = Date.now() - new Date(claim.dispatchedAt).getTime();
	return Number.isFinite(age) && age >= 0 && age < CLAIM_TTL_MS;
}
function isForgeRepo(basePath) {
	try {
		const pkg = JSON.parse(
			readFileSync(join(basePath, "package.json"), "utf-8"),
		);
		return pkg?.name === "singularity-forge";
	} catch {
		return false;
	}
}
function hasUncommittedChanges(basePath) {
	try {
		const out = execFileSync("git", ["status", "--porcelain"], {
			cwd: basePath,
			encoding: "utf-8",
			stdio: ["ignore", "pipe", "ignore"],
		});
		return out.trim().length > 0;
	} catch {
		return true;
	}
}
/**
 * Return forge-local self-feedback entries that need triage decisions.
 *
 * The inline-fix unit is a triage worker, not just a repair worker: for each
 * candidate it decides — fix, promote-to-requirement, or close-as-not-of-value
 * with reason. Sitting open forever is the failure mode we are designing
 * against, so the selector returns every open forge-local entry rather than
 * narrowing by severity or kind.
 *
 * Filters:
 *
 *   1. Forge-local only. Upstream entries are cross-project flow observations
 *      gathered while sf runs in other repos; they're operator-visibility
 *      signal for forge maintainers but the inline-fix unit edits forge
 *      source and cannot repair issues in those other repos.
 *
 *   2. Unresolved, OR resolved with suspect evidence. Resolutions are honored
 *      when the recorded evidence is credible (either a structured kind in
 *      CREDIBLE_RESOLUTION_KINDS or a legacy `resolvedReason` narrative).
 *      Resolutions of suspect kinds (notably `auto-version-bump`) or with
 *      neither structured evidence nor a narrative are re-included so the
 *      unit gets a chance to actually close them with a real decision.
 *
 * Consumer: dispatchSelfFeedbackInlineFixIfNeeded during session_start.
 */
// Severity-derived default impact_score, mirroring sf-db-self-feedback's
// defaultImpactForSeverity. Used by the selector when an entry's
// impact_score column is null (legacy rows pre-v65 + entries that didn't
// override the default at insert time which means the column is null too —
// the v65 column DEFAULT NULL is intentional so legacy rows participate
// in prioritization without a data backfill).
function effectiveImpact(entry) {
	if (typeof entry.impactScore === "number") return entry.impactScore;
	switch (entry.severity) {
		case "critical":
			return 95;
		case "high":
			return 80;
		case "medium":
			return 50;
		case "low":
			return 20;
		default:
			return 50;
	}
}

// Default effort to 3 (mid of 1-5 scale) when unset so missing-effort
// entries don't all sort identically; a 5-point scale gives operators
// room to bump truly-tiny work above truly-huge work.
function effectiveEffort(entry) {
	if (typeof entry.effortEstimate === "number") return entry.effortEstimate;
	return 3;
}

export function selectInlineFixCandidates(basePath) {
	// Forge-only: in consumer projects, `readAllSelfFeedback` returns the
	// global upstream-feedback log (~/.sf/agent/upstream-feedback.jsonl),
	// which lists SF-developer work items. We must not auto-dispatch a
	// triage agent against those from inside a consumer project — that
	// would let any downstream project mark SF's own work as resolved.
	if (!isForgeRepo(basePath)) return [];
	return readAllSelfFeedback(basePath)
		.filter((entry) => {
			if (isSuspectlyResolved(entry)) return true;
			return !entry.resolvedAt;
		})
		.sort((a, b) => {
			// Prioritization (sf-mp4rxkx0-fkt3e2):
			//   1. impact desc — high-impact work first
			//   2. effort asc  — quick wins ahead of multi-day work at same impact
			//   3. ts asc      — older entries break ties (FIFO within priority)
			const impactDelta = effectiveImpact(b) - effectiveImpact(a);
			if (impactDelta !== 0) return impactDelta;
			const effortDelta = effectiveEffort(a) - effectiveEffort(b);
			if (effortDelta !== 0) return effortDelta;
			return a.ts.localeCompare(b.ts);
		});
}
export function buildInlineFixPrompt(entries) {
	const rendered = entries
		.map((entry) =>
			[
				`## ${entry.id} — ${entry.kind}`,
				`- Severity: ${entry.severity}`,
				`- Summary: ${entry.summary}`,
				entry.acceptanceCriteria
					? `- Acceptance criteria: ${entry.acceptanceCriteria}`
					: "- Acceptance criteria: verify the reported failure is gone and add/adjust a regression test where practical.",
				entry.evidence
					? `\nEvidence:\n\n\`\`\`\n${entry.evidence}\n\`\`\``
					: "",
				entry.suggestedFix ? `\nSuggested fix: ${entry.suggestedFix}` : "",
			]
				.filter(Boolean)
				.join("\n"),
		)
		.join("\n\n");
	return [
		"You are executing SF self-feedback triage mode.",
		"",
		"Every open forge-local self-feedback entry is listed below. Your job is to give each one a decision — sitting open forever is the failure mode. For each entry, choose exactly one of three outcomes:",
		"",
		"  A. Fix it.        The defect is real, in scope, and worth fixing now.",
		"  B. Promote it.    The defect is real but the right place to track it is a requirement, not a self-feedback entry.",
		"  C. Close it.      The entry is no longer of value: stale, superseded, false positive, or not worth a fix at SF's current priorities.",
		"",
		"Pick the smallest coherent decision per entry. You do not have to implement every entry — but you DO have to decide every entry. Closing entries deliberately is a valid and expected outcome.",
		"",
		rendered,
		"",
		"Decision procedure:",
		"1. For each entry: verify the claim still applies against the current code. If it doesn't, that's outcome C.",
		'2. If outcome A (fix): make the smallest coherent code/docs/tests change. Run focused verification and typecheck for touched areas. Commit the fix with a conventional message. Then call `resolve_issue` with evidence `{kind: "agent-fix", commitSha: <sha>}` and a `reason` naming what landed.',
		'3. If outcome B (promote): call `resolve_issue` with evidence `{kind: "promoted-to-requirement", requirementId: <id>}` after ensuring the requirement row exists. Use this when the entry describes long-running policy work, not a code fix.',
		'4. If outcome C (close): call `resolve_issue` with evidence `{kind: "human-clear"}` and a `reason` that names WHY the entry is no longer of value (stale, superseded by <commit>, false positive, out-of-scope, etc.). Be specific — a future reader should be able to tell whether re-opening makes sense.',
		'5. Never use evidence `{kind: "auto-version-bump"}` for these decisions — that kind is reserved for the automatic version-bump resolver and would re-open under the credibility check.',
		"6. Do not hand-edit `.sf/self-feedback.jsonl` or `.sf/SELF-FEEDBACK.md`. Always go through `resolve_issue` so the DB, markdown projection, and reload detection stay consistent.",
		"",
		"When every entry has a decision, say: Self-feedback triage complete.",
	].join("\n");
}
/**
 * Dispatch a focused inline-fix turn for unresolved high/critical self-feedback.
 *
 * Purpose: convert startup self-feedback warnings into executable work while
 * preventing repeated dispatch of the same candidate set on every session.
 *
 * Consumer: bootstrap/register-hooks.ts session_start drain.
 */
export function dispatchSelfFeedbackInlineFixIfNeeded(basePath, ctx, pi) {
	const candidates = selectInlineFixCandidates(basePath);
	if (candidates.length === 0) return 0;
	const ids = candidates.map((entry) => entry.id);
	const claim = readClaim(basePath);
	if (claim && claimStillFresh(claim, ids)) {
		ctx.ui.notify(
			`Self-feedback inline fix already dispatched for ${ids.length} high/critical entr${ids.length === 1 ? "y" : "ies"}; waiting for completion and clean commit before reload.`,
			"info",
		);
		return 0;
	}
	// Auto-transition to repair work mode when high/critical self-feedback detected
	try {
		const { getAutoSession } = require("../auto/session.js");
		const session = getAutoSession();
		if (session && session.workMode !== "repair") {
			const transition = session.setMode({
				workMode: "repair",
				reason: "self-feedback-drain",
			});
			ctx.ui.notify(
				`Auto-transition: ${transition.from.workMode} → ${transition.to.workMode} (high/critical self-feedback detected)`,
				"warning",
			);
		}
	} catch {
		// Mode transition is best-effort
	}
	writeClaim(basePath, ids);
	// Headless / autonomous surfaces have no interactive chat agent listening
	// for `pi.sendMessage` — the followUp gets queued but never delivered, so
	// the blocker persists across iterations. Route through `sf headless triage
	// --apply` instead, which dispatches the same triage-decider → review-code
	// pipeline through SF's own subprocess machinery (router-resolved model,
	// watchdog, trust gate). Fire-and-forget: the autonomous loop will see the
	// resolved entries via DB on the next iteration's gate check.
	if (process.env.SF_HEADLESS === "1") {
		ctx.ui.notify(
			`Dispatching self-feedback inline fix via 'sf headless triage --apply' for ${ids.length} high/critical entr${ids.length === 1 ? "y" : "ies"} (headless surface).`,
			"warning",
		);
		const sfBin = process.env.SF_BIN_PATH || process.argv[1];
		if (!sfBin) {
			writeFailedClaim(basePath, ids, "SF_BIN_PATH unavailable");
			return 0;
		}
		try {
			// Spawn the child with SF_SELF_FEEDBACK_WORKER=1 so it can use a
			// non-conflicting lock file instead of contending for auto.lock.
			const env = { ...process.env, SF_SELF_FEEDBACK_WORKER: "1" };
			const child = spawn(
				process.execPath,
				[sfBin, "headless", "triage", "--apply", "--json"],
				{
					cwd: basePath,
					stdio: ["ignore", "ignore", "ignore"],
					detached: true,
					env,
				},
			);
			child.on("error", (err) => {
				writeFailedClaim(basePath, ids, getErrorMessage(err));
			});
			child.on("exit", (code) => {
				if (code !== 0) {
					writeFailedClaim(
						basePath,
						ids,
						`Child exited with code ${code ?? "null"}`,
					);
				}
			});
			child.unref();
		} catch (err) {
			writeFailedClaim(basePath, ids, getErrorMessage(err));
		}
		return candidates.length;
	}
	const prompt = buildInlineFixPrompt(candidates);
	ctx.ui.notify(
		`Queueing self-feedback inline fix for ${ids.length} high/critical entr${ids.length === 1 ? "y" : "ies"}.`,
		"warning",
	);
	const dispatch = pi.sendMessage(
		{
			customType: "sf-self-feedback-inline-fix",
			content: prompt,
			display: false,
		},
		{ triggerTurn: true, deliverAs: "followUp" },
	);
	void Promise.resolve(dispatch).catch((error) => {
		const message = getErrorMessage(error);
		writeFailedClaim(basePath, ids, message);
		ctx.ui.notify(
			`Self-feedback inline fix dispatch failed; will retry at the next idle point: ${message}`,
			"warning",
		);
	});
	return candidates.length;
}
/**
 * Consume a completed inline-fix claim when all claimed entries are resolved.
 *
 * Purpose: self-patches must become active without the operator manually
 * noticing that files changed; this detects the end of the repair turn and lets
 * the lifecycle hook request a deferred reload.
 *
 * Consumer: register-hooks.ts turn_end handler.
 */
const TRIAGE_TERMINATOR = "Self-feedback triage complete";

/**
 * Last-resort candidates if reading the operator's settings.json fails. The
 * Chinese-provider set matches typical SF operator config (kimi + minimax +
 * zai). Gemini is intentionally NOT in this list — when the operator has
 * gemini as default they'll have it in settings.json and we'll pick it up
 * from there; hardcoding it here would silently re-default to gemini even
 * for operators who explicitly removed it. The router scores these via
 * BASE_REQUIREMENTS["self-feedback-triage"] before dispatch.
 *
 * Operator --model override always wins; operator settings.json enabledModels
 * always wins over this fallback list.
 */
const TRIAGE_FALLBACK_CANDIDATES = [
	"kimi-coding/kimi-for-coding",
	"minimax/MiniMax-M2.7",
	"zai/glm-5",
];

function parseTriageModelString(input) {
	if (typeof input !== "string") return null;
	const slash = input.indexOf("/");
	if (slash <= 0 || slash === input.length - 1) return null;
	return [input.slice(0, slash), input.slice(slash + 1)];
}

function isOpenCodeProvider(provider) {
	return provider === "opencode";
}

function isFreeOpenCodeModelId(modelId) {
	const id = String(modelId ?? "").toLowerCase();
	return id.includes(":free") || /(?:^|-)free(?:-|$)/.test(id);
}

/**
 * Filter triage model candidates through provider-specific operator policy.
 *
 * Purpose: enforce the operator's cost boundary before the router scores
 * candidates. `opencode/*` in settings means "all free OpenCode models", not
 * "all OpenCode billing models"; otherwise triage can pick paid GPT routes like
 * `opencode/gpt-5.1` just because they score high on agentic capability.
 * `opencode-go/*` is intentionally not filtered here because that provider's
 * catalog is treated as free by policy.
 *
 * Consumer: readOperatorTriageCandidates and rankTriageModelsViaRouter.
 *
 * @param {string[]} candidates
 * @returns {string[]}
 */
export function filterTriageCandidatesByProviderPolicy(candidates) {
	if (!Array.isArray(candidates)) return [];
	return candidates.filter((candidate) => {
		const parsed = parseTriageModelString(candidate);
		if (!parsed) return false;
		const [provider, modelId] = parsed;
		if (!isOpenCodeProvider(provider)) return true;
		return isFreeOpenCodeModelId(modelId);
	});
}

async function resolveTriageModel(providerModelString) {
	const parsed = parseTriageModelString(providerModelString);
	if (!parsed) return null;
	const [provider, modelId] = parsed;
	const ai = await import("@singularity-forge/ai");
	if (typeof ai.getModel !== "function") return null;
	try {
		return ai.getModel(provider, modelId) ?? null;
	} catch {
		return null;
	}
}

/**
 * Read the operator's enabledModels allowlist from ~/.sf/agent/settings.json
 * and expand "provider/*" wildcards against @singularity-forge/ai's MODELS
 * catalog.
 *
 * Intentionally does NOT include defaultProvider/defaultModel. The operator's
 * `defaultModel` is for general dispatch (typically a flash/cheap pick like
 * gemini-3-flash-preview); triage is an agentic-heavy task and should pick
 * from the explicit `enabledModels` allowlist (typically kimi/minimax/zai).
 * Mixing the cheap default into the triage candidate pool gives it a chance
 * to win on cost tie-break against an agentic-better but pricier option.
 *
 * Returns null on any failure — callers fall back to TRIAGE_FALLBACK_CANDIDATES.
 */
async function readOperatorTriageCandidates() {
	try {
		const { getSfAgentSettingsPath } = await import("./preferences.js");
		const path = getSfAgentSettingsPath();
		if (!existsSync(path)) return null;
		const settings = JSON.parse(readFileSync(path, "utf-8"));
		const enabled = Array.isArray(settings?.enabledModels)
			? settings.enabledModels
			: [];

		const result = new Set();

		// Expand wildcards by walking the pi-ai MODELS catalog for matching
		// provider entries. Exact "provider/modelId" entries pass through.
		const ai = await import("@singularity-forge/ai");
		for (const entry of enabled) {
			if (typeof entry !== "string" || !entry.includes("/")) continue;
			const slash = entry.indexOf("/");
			const provider = entry.slice(0, slash);
			const modelGlob = entry.slice(slash + 1);
			if (modelGlob !== "*") {
				for (const candidate of filterTriageCandidatesByProviderPolicy([
					entry,
				])) {
					result.add(candidate);
				}
				continue;
			}
			// Wildcard: enumerate models registered under this provider via
			// pi-ai's getModels(provider). If the function isn't available
			// in the loaded build we just skip (caller falls back to
			// TRIAGE_FALLBACK_CANDIDATES).
			if (typeof ai.getModels === "function") {
				const models = ai.getModels(provider) ?? [];
				for (const m of models) {
					if (!m?.id) continue;
					for (const candidate of filterTriageCandidatesByProviderPolicy([
						`${provider}/${m.id}`,
					])) {
						result.add(candidate);
					}
				}
			}
		}

		const list = Array.from(result);
		return list.length > 0 ? list : null;
	} catch {
		return null;
	}
}

/**
 * Score the operator-derived candidate list via SF's model-router using the
 * self-feedback-triage requirements profile (agentic-heavy). Returns the
 * "provider/modelId" strings in best-first order. Used by runTriage for
 * dispatch-with-fallback when the top pick lacks credentials.
 *
 * Priority order (sf-mp5khix3-9beona AC1 + user correction):
 *   1. explicit candidates passed by caller (test injection)
 *   2. operator's settings.json enabledModels (expanded) + defaultModel
 *   3. TRIAGE_FALLBACK_CANDIDATES — only when settings.json is missing or
 *      yields no usable models. This is the safe Chinese+Gemini set that
 *      matches the operator's typical config, NOT a universal model dump.
 *
 * Consumer: headless-triage handleTriage (--run and --apply paths, when
 *   operator doesn't pass --model). Also used by the deprecated runTriage.
 */
export async function rankTriageModelsViaRouter(candidates) {
	const rawCandidateList =
		(Array.isArray(candidates) && candidates.length > 0
			? candidates
			: await readOperatorTriageCandidates()) ?? TRIAGE_FALLBACK_CANDIDATES;
	const candidateList =
		filterTriageCandidatesByProviderPolicy(rawCandidateList);
	const effectiveCandidateList =
		candidateList.length > 0 ? candidateList : TRIAGE_FALLBACK_CANDIDATES;
	try {
		const { BASE_REQUIREMENTS, scoreEligibleModels } = await import(
			"./model-router.js"
		);
		const ranked = scoreEligibleModels(
			effectiveCandidateList,
			BASE_REQUIREMENTS["self-feedback-triage"],
		);
		const ids = ranked.map((r) => r.modelId).filter(Boolean);
		return ids.length > 0 ? ids : effectiveCandidateList;
	} catch {
		return effectiveCandidateList;
	}
}

/**
 * Heuristic: detect provider-credential failures that warrant trying the
 * next-best candidate instead of surfacing the error. We match on
 * substrings that pi-ai providers actually emit on missing-key paths.
 */
function isCredentialError(message) {
	if (typeof message !== "string") return false;
	return /no api key|missing api key|api[_ ]?key.*not set|unauthorized|authentication failed|not authenticated|credentials/i.test(
		message,
	);
}

function extractAssistantText(message) {
	const content = message?.content;
	if (!Array.isArray(content)) return "";
	const out = [];
	for (const part of content) {
		if (part && typeof part === "object" && part.type === "text") {
			if (typeof part.text === "string") out.push(part.text);
		}
	}
	return out.join("");
}

/**
 * Persist a triage decision report to .sf/triage/decisions/<timestamp>.md.
 * Returns the absolute path on success, null on failure (best-effort).
 *
 * Why a separate subdirectory under .sf/triage/: the captures-triage system
 * (commands-todo.js) already owns .sf/triage/{backlog,...}; using
 * .sf/triage/decisions/ keeps both subsystems clearly namespaced without
 * either subsystem accidentally consuming the other's files.
 *
 * Consumer: headless-triage handleTriage (--run flag, both legacy and
 *   the new runTriageApply dryRun path).
 */
export function writeTriageDecisionReport(basePath, content) {
	try {
		const dir = join(sfRoot(basePath), "triage", "decisions");
		mkdirSync(dir, { recursive: true });
		const ts = new Date().toISOString().replace(/[:.]/g, "-");
		const path = join(dir, `${ts}.md`);
		writeFileSync(path, content, "utf-8");
		return path;
	} catch {
		return null;
	}
}

/**
 * Run a triage pass against the canonical inline-fix prompt using
 * @singularity-forge/ai completeSimple (text-only, no tools).
 *
 * @deprecated sf-mp5khix3-9beona: the `sf headless triage --run` operator
 * path no longer calls this function. It now routes through
 * `runTriageApply` (headless-triage.ts) with `dryRun: true`, which uses
 * SF's proper agent runner (defaultAgentRunner / runSubagent), role-routed
 * model selection, and the full triage-decider + review-code tool surface.
 *
 * This function is retained only for test compatibility of the
 * self-feedback-drain unit tests that exercise the completeSimple dispatch
 * layer directly. Do NOT add new call sites here. Redirect any new callers
 * to `runTriageApply(..., { dryRun: true })` in headless-triage.ts.
 *
 * options.model — "provider/modelId" override. Defaults to router pick.
 * options.candidates — override the candidate list for router-based pick.
 * options.complete — dependency injection for tests.
 * options.timeoutMs — defaults to 8 minutes.
 *
 * Returns { ok, content?, cleanFinish?, error?, provider?, modelId? }.
 * Best-effort; never throws.
 */
export async function runTriage(prompt, options = {}) {
	const timeoutMs = options.timeoutMs ?? 8 * 60 * 1000;

	// Build the dispatch order. Operator --model wins as a single-shot
	// pick (no fallback). Without --model, walk the router-ranked list and
	// fall through to the next candidate on credential errors.
	const dispatchOrder = options.model
		? [options.model]
		: await rankTriageModelsViaRouter(options.candidates);

	const completeFn =
		options.complete ??
		(await (async () => {
			const ai = await import("@singularity-forge/ai");
			return ai.completeSimple;
		})());

	const context = {
		systemPrompt: undefined,
		messages: [
			{
				role: "user",
				content: [{ type: "text", text: prompt }],
				timestamp: Date.now(),
			},
		],
	};

	const attemptOne = async (modelString) => {
		let model;
		try {
			model = await resolveTriageModel(modelString);
		} catch (err) {
			return {
				ok: false,
				error: `failed to load model catalog: ${getErrorMessage(err)}`,
				modelString,
			};
		}
		if (!model) {
			return {
				ok: false,
				error: `unknown model "${modelString}" — expected "provider/modelId" with a model registered in @singularity-forge/ai MODELS`,
				modelString,
			};
		}

		const callPromise = (async () => {
			try {
				const message = await completeFn(model, context, {});
				const content = extractAssistantText(message);
				return {
					ok: true,
					content,
					cleanFinish: content.includes(TRIAGE_TERMINATOR),
					provider: model.provider,
					modelId: model.id,
				};
			} catch (err) {
				return {
					ok: false,
					error: `provider call failed: ${getErrorMessage(err)}`,
					provider: model.provider,
					modelId: model.id,
				};
			}
		})();

		const timeoutPromise = new Promise((resolve) => {
			setTimeout(() => {
				resolve({
					ok: false,
					error: `triage call timed out after ${timeoutMs}ms`,
					provider: model.provider,
					modelId: model.id,
				});
			}, timeoutMs);
		});

		return Promise.race([callPromise, timeoutPromise]);
	};

	// Walk the ranked list. Retry-on-credential-error keeps us from giving
	// up because the highest-scoring model happens to lack credentials in
	// the operator's environment (e.g. router picks openai-codex-responses
	// when the operator only has anthropic + gemini-cli auth).
	const attempts = [];
	for (const modelString of dispatchOrder) {
		const result = await attemptOne(modelString);
		attempts.push({ modelString, error: result.error });
		if (result.ok) return result;
		// Only fall through on credential errors. Unknown-model + timeout +
		// other errors surface as-is (no point retrying with a different
		// model if the prompt itself is broken or the network is down).
		if (!isCredentialError(result.error) || dispatchOrder.length <= 1) {
			return result;
		}
	}

	// All candidates exhausted on credential errors.
	const lastErr = attempts[attempts.length - 1];
	return {
		ok: false,
		error: `provider call failed: no candidate model had usable credentials (tried ${attempts.length}: ${attempts.map((a) => a.modelString).join(", ")}). Last error: ${lastErr?.error ?? "unknown"}`,
		modelId: lastErr?.modelString,
	};
}

export function consumeCompletedInlineFixClaim(basePath) {
	const claim = readClaim(basePath);
	if (!claim || claim.ids.length === 0) return [];
	const byId = new Map(
		[...readAllSelfFeedback(basePath), ...readUpstreamSelfFeedback()].map(
			(entry) => [entry.id, entry],
		),
	);
	const allResolved = claim.ids.every((id) => {
		const entry = byId.get(id);
		return Boolean(entry?.resolvedAt);
	});
	if (!allResolved) return [];
	if (hasUncommittedChanges(basePath)) return [];
	clearClaim(basePath);
	return claim.ids;
}
