/**
 * Diagnostic budget guard for unusually long autonomous mode units.
 *
 * This is intentionally not a blind tool-count kill switch. It gives the agent
 * explicit turns to explain whether the unit is legitimately large, stuck, or
 * churning, then pauses only if the unit keeps consuming budget afterward.
 */
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { formatTokenCount } from "@singularity-forge/coding-agent";
import {
	runDetectorSweep,
	SWEEP_CADENCE_MS,
} from "../detectors/periodic-runner.js";
import { evaluateZeroProgress } from "../detectors/zero-progress.js";
import { _getAdapter } from "../sf-db/sf-db-core.js";
import {
	detectorSweepSignal,
	zeroProgressSignal,
} from "../supervision/loop-signals.js";
export const DEFAULT_RUNAWAY_TOOL_CALL_WARNING = 60;
export const DEFAULT_RUNAWAY_TOKEN_WARNING = 1_000_000;
export const DEFAULT_RUNAWAY_ELAPSED_MINUTES = 20;
export const DEFAULT_RUNAWAY_CHANGED_FILES_WARNING = 75;
export const DEFAULT_RUNAWAY_DIAGNOSTIC_TURNS = 2;
export const DEFAULT_RUNAWAY_MIN_INTERVAL_MS = 120_000;
const EXECUTE_NO_PROGRESS_TOOL_WARNING = 25;
const EXECUTE_NO_PROGRESS_TOKEN_WARNING = 500_000;
const DURABLE_SF_ARTIFACT_PATHS = [".sf/milestones", ".sf/approvals"];
let state = null;
// ── Wiggums detector sweep state (R053-R056) ─────────────────────────────
// runDetectorSweep is async; evaluateRunawayGuard is sync. We fire the sweep
// fire-and-forget on each tick and consume the previous tick's result on the
// next call — one-tick delayed effect, documented choice.
let sweepState = {
	lastFiredAt: 0,
	throttleState: new Map(),
	pendingFired: /** @type {Array<{name: string, signature: unknown}>} */ ([]),
};
/**
 * Read the last N self-feedback rows from the DB within a time window.
 * Returns [] when the DB is unavailable — the detector handles missing data.
 * @param {number} windowMs
 * @returns {Array<{kind: string, timestamp: number, occurredIn?: unknown}>}
 */
function readRecentFeedbackFromDb(windowMs = 30 * 60 * 1000) {
	try {
		const db = _getAdapter();
		if (!db) return [];
		const cutoff = Date.now() - windowMs;
		const rows = db
			.prepare(
				"SELECT kind, ts, occurred_in_milestone, occurred_in_slice, occurred_in_task FROM self_feedback WHERE ts >= :cutoff ORDER BY ts DESC LIMIT 100",
			)
			.all({ ":cutoff": cutoff });
		return rows.map((row) => ({
			kind: row.kind,
			timestamp: row.ts,
			occurredIn: row.occurred_in_milestone
				? {
						milestone: row.occurred_in_milestone,
						slice: row.occurred_in_slice ?? undefined,
						task: row.occurred_in_task ?? undefined,
					}
				: undefined,
		}));
	} catch {
		return [];
	}
}
export function resetRunawayGuardState(unitType, unitId, baseline) {
	state = {
		unitKey: `${unitType}/${unitId}`,
		baselineSessionTokens: baseline?.sessionTokens ?? 0,
		baselineChangedFiles: baseline?.changedFiles ?? 0,
		baselineWorktreeFingerprint: baseline?.worktreeFingerprint ?? null,
		warningsSent: 0,
		lastWarningAt: 0,
		lastToolCalls: 0,
		lastSessionTokens: 0,
		lastElapsedMs: 0,
		zeroProgressToolCalls: 0,
		zeroProgressElapsedMs: 0,
		zeroProgressFingerprint: baseline?.worktreeFingerprint ?? null,
		zeroProgressIterationsSinceProgress: 0,
		finalWarningSent: false,
	};
}
export function clearRunawayGuardState() {
	state = null;
	sweepState = { lastFiredAt: 0, throttleState: new Map(), pendingFired: [] };
}
export function resolveRunawayGuardConfig(supervisor) {
	return {
		enabled: supervisor?.runaway_guard_enabled !== false,
		toolCallWarning:
			supervisor?.runaway_tool_call_warning ??
			DEFAULT_RUNAWAY_TOOL_CALL_WARNING,
		tokenWarning:
			supervisor?.runaway_token_warning ?? DEFAULT_RUNAWAY_TOKEN_WARNING,
		elapsedMs:
			(supervisor?.runaway_elapsed_minutes ?? DEFAULT_RUNAWAY_ELAPSED_MINUTES) *
			60 *
			1000,
		changedFilesWarning:
			supervisor?.runaway_changed_files_warning ??
			DEFAULT_RUNAWAY_CHANGED_FILES_WARNING,
		diagnosticTurns:
			supervisor?.runaway_diagnostic_turns ?? DEFAULT_RUNAWAY_DIAGNOSTIC_TURNS,
		hardPause: supervisor?.runaway_hard_pause !== false,
		minIntervalMs: DEFAULT_RUNAWAY_MIN_INTERVAL_MS,
	};
}
export function collectSessionTokenUsage(ctx) {
	try {
		const entries = ctx.sessionManager?.getEntries?.() ?? [];
		let total = 0;
		for (const entry of entries) {
			const message = entry.message;
			if (message?.role !== "assistant" || !message.usage) continue;
			const usage = message.usage;
			const totalTokens = numeric(usage.totalTokens ?? usage.total);
			if (totalTokens > 0) {
				total += totalTokens;
				continue;
			}
			total +=
				numeric(usage.input) +
				numeric(usage.output) +
				numeric(usage.cacheRead) +
				numeric(usage.cacheWrite);
		}
		return total;
	} catch {
		return 0;
	}
}
export function countChangedFiles(cwd) {
	try {
		const out = execFileSync("git", ["status", "--short"], {
			cwd,
			encoding: "utf8",
			stdio: ["ignore", "pipe", "ignore"],
			timeout: 2000,
		});
		return out
			.split("\n")
			.map((line) => line.trim())
			.filter(Boolean).length;
	} catch {
		return 0;
	}
}
export function collectWorktreeFingerprint(cwd) {
	try {
		const status = execFileSync(
			"git",
			["status", "--porcelain=v1", "--untracked-files=all"],
			{
				cwd,
				encoding: "utf8",
				stdio: ["ignore", "pipe", "ignore"],
				timeout: 2000,
			},
		);
		const lines = status
			.split("\n")
			.map((line) => line.trimEnd())
			.filter(Boolean);
		const hash = createHash("sha256");
		if (lines.length === 0) {
			hash.update("git-clean");
			hash.update("\0");
		}
		for (const line of lines) {
			hash.update(line);
			hash.update("\0");
			const filePath = parsePorcelainPath(line);
			if (!filePath) continue;
			appendFileFingerprint(hash, cwd, filePath);
		}
		appendDurableSfArtifactFingerprint(hash, cwd);
		return hash.digest("hex");
	} catch {
		return null;
	}
}
function appendDurableSfArtifactFingerprint(hash, cwd) {
	hash.update("sf-artifacts");
	hash.update("\0");
	for (const artifactPath of DURABLE_SF_ARTIFACT_PATHS) {
		appendPathFingerprint(hash, cwd, artifactPath);
	}
}
function appendPathFingerprint(hash, cwd, relativePath) {
	const fullPath = `${cwd}/${relativePath}`;
	if (!existsSync(fullPath)) {
		hash.update(`missing:${relativePath}`);
		hash.update("\0");
		return;
	}
	let stat;
	try {
		stat = lstatSync(fullPath);
	} catch {
		hash.update(`unreadable:${relativePath}`);
		hash.update("\0");
		return;
	}
	if (stat.isDirectory()) {
		hash.update(`dir:${relativePath}`);
		hash.update("\0");
		let entries;
		try {
			entries = readdirSync(fullPath).sort();
		} catch {
			hash.update(`unreadable-dir:${relativePath}`);
			hash.update("\0");
			return;
		}
		for (const entry of entries) {
			appendPathFingerprint(hash, cwd, `${relativePath}/${entry}`);
		}
		return;
	}
	appendFileFingerprint(hash, cwd, relativePath);
}
function appendFileFingerprint(hash, cwd, relativePath) {
	try {
		const stat = lstatSync(`${cwd}/${relativePath}`);
		if (!stat.isFile()) {
			hash.update(
				`type:${relativePath}:${stat.isDirectory() ? "dir" : "other"}`,
			);
			hash.update("\0");
			return;
		}
		hash.update(`file:${relativePath}`);
		hash.update("\0");
		hash.update(readFileSync(`${cwd}/${relativePath}`));
		hash.update("\0");
	} catch {
		hash.update(`unreadable-or-deleted:${relativePath}`);
		hash.update("\0");
	}
}
export function evaluateRunawayGuard(
	unitType,
	unitId,
	metrics,
	config,
	now = Date.now(),
) {
	if (!config.enabled) return { action: "none" };
	if (config.diagnosticTurns <= 0) return { action: "none" };
	const unitKey = `${unitType}/${unitId}`;
	if (!state || state.unitKey !== unitKey)
		resetRunawayGuardState(unitType, unitId);
	const s = state;
	const unitMetrics = normalizeMetricsToUnit(metrics, s);
	const zeroProgress = evaluateZeroProgressState(s, unitMetrics);
	if (zeroProgress.stuck) {
		const evidence = {
			toolCallsTotal: unitMetrics.toolCalls,
			toolCallsSinceLastChange: zeroProgress.signature.toolCallsSinceLastChange,
			elapsedMs: zeroProgress.signature.elapsedMs,
			fingerprint: zeroProgress.signature.fingerprint,
		};
		const signal = zeroProgressSignal({
			unitType,
			unitId,
			evidence,
		});
		return {
			action: "fail",
			reason: "zero-progress",
			metadata: {
				reason: "zero-progress",
				failedAt: now,
				unitType,
				unitId,
				metrics: unitMetrics,
				zeroProgress: true,
				evidence,
				loopSignal: signal,
				selfFeedback: {
					kind: "zero-progress",
					severity: "high",
					evidence,
				},
			},
		};
	}
	// ── Wiggums detector sweep (R053-R056) ──────────────────────────────────
	// Consume the result buffered from the previous tick's async sweep. If any
	// detector fired, treat it as a hard fail (same severity as zero-progress).
	// runDetectorSweep is async; we fire fire-and-forget here so the sync
	// evaluateRunawayGuard contract is preserved, with one-tick delay before
	// a result can act.
	const pendingFired = sweepState.pendingFired;
	sweepState.pendingFired = [];
	if (pendingFired.length > 0) {
		const fired = pendingFired[0];
		const signal = detectorSweepSignal(fired, unitType, unitId, now);
		return {
			action: "fail",
			reason: `detector-sweep:${fired.name}`,
			metadata: {
				reason: `detector-sweep:${fired.name}`,
				failedAt: now,
				unitType,
				unitId,
				metrics: unitMetrics,
				detectorsFired: pendingFired,
				loopSignal: signal,
				selfFeedback: {
					kind: signal.kind,
					severity: "high",
					evidence: JSON.stringify(signal.evidence),
				},
			},
		};
	}
	// Fire a new sweep for the next tick — cadence-throttled, fire-and-forget.
	if (now - sweepState.lastFiredAt >= SWEEP_CADENCE_MS) {
		sweepState.lastFiredAt = now;
		const basePath = metrics.basePath;
		const sweepCtx = {
			unitId,
			recentDispatches: undefined,
			unitMetrics,
			sessionFingerprint: unitMetrics.worktreeFingerprint ?? null,
			recentFeedback: readRecentFeedbackFromDb(),
			artifactHistory: undefined,
			lockPaths: basePath
				? [
						join(basePath, ".sf/sf.lock"),
						join(basePath, ".sf/interactive.lock"),
					]
				: [".sf/sf.lock", ".sf/interactive.lock"],
		};
		runDetectorSweep(sweepCtx, { throttleState: sweepState.throttleState })
			.then((result) => {
				if (result.detectorsFired.length > 0) {
					sweepState.pendingFired = result.detectorsFired;
				}
			})
			.catch(() => {
				// Sweep errors must not interrupt the guard tick.
			});
	}
	const reasons = thresholdReasons(unitType, unitMetrics, config);
	if (reasons.length === 0) return { action: "none" };
	if (
		s.lastWarningAt > 0 &&
		now - s.lastWarningAt < config.minIntervalMs &&
		!hasMeaningfulGrowth(unitMetrics, s, config)
	) {
		return { action: "none" };
	}
	// Skip hard-pause if the unit is making file-change progress — growth with
	// changes is legitimate diagnostic/planning work, not a stuck loop.
	// Without this check, discuss/plan phases that legitimately consume tokens
	// while writing summaries/plans would be hard-paused despite making progress.
	if (
		(unitMetrics.changedFiles ?? 0) > 0 ||
		unitMetrics.worktreeChangedSinceStart === true
	) {
		return { action: "none" };
	}
	// Silent-worker-failure detection (#sf-mp8c0arc-vgw8io):
	// When the final warning has already been sent and the unit has produced
	// zero tool calls past the elapsed threshold, the worker is not stuck in a
	// busy loop — its LLM session never produced an assistant message, so the
	// session JSONL was never written (session-manager.ts:1086-1096 skips
	// _persist when !hasAssistant). hasMeaningfulGrowth is false because no
	// tokens are flowing, so the existing pause branch never fires. Escalate
	// to fail so the dispatcher can retry or transition to blocked instead of
	// staying in runaway-final-warning-sent indefinitely.
	if (
		s.finalWarningSent &&
		(unitMetrics.toolCalls ?? 0) === 0 &&
		unitMetrics.elapsedMs > config.elapsedMs
	) {
		const reason =
			`Runaway guard fail ${unitType} ${unitId}: zero tool calls in ` +
			`${Math.round(unitMetrics.elapsedMs / 1000)}s after final warning — ` +
			`silent worker failure suspected (LLM session never produced an assistant message).`;
		return {
			action: "fail",
			reason,
			metadata: {
				reason,
				failedAt: now,
				unitType,
				unitId,
				diagnosticTurns: config.diagnosticTurns,
				warningsSent: s.warningsSent,
				thresholdReasons: reasons,
				metrics: unitMetrics,
				silentFailure: true,
				thresholds: {
					toolCallWarning: config.toolCallWarning,
					tokenWarning: config.tokenWarning,
					elapsedMs: config.elapsedMs,
					changedFilesWarning: config.changedFilesWarning,
					minIntervalMs: config.minIntervalMs,
				},
			},
		};
	}
	if (
		config.hardPause &&
		s.finalWarningSent &&
		hasMeaningfulGrowth(unitMetrics, s, config)
	) {
		const reason =
			`Runaway guard paused ${unitType} ${unitId}: budget kept growing after ` +
			`${config.diagnosticTurns} diagnostic turn(s). ` +
			formatMetricSummary(unitMetrics);
		return {
			action: "pause",
			reason,
			metadata: {
				reason,
				pausedAt: now,
				unitType,
				unitId,
				diagnosticTurns: config.diagnosticTurns,
				warningsSent: s.warningsSent,
				thresholdReasons: reasons,
				metrics: unitMetrics,
				lastWarningMetrics: {
					toolCalls: s.lastToolCalls,
					sessionTokens: s.lastSessionTokens,
					elapsedMs: s.lastElapsedMs,
				},
				thresholds: {
					toolCallWarning: config.toolCallWarning,
					tokenWarning: config.tokenWarning,
					elapsedMs: config.elapsedMs,
					changedFilesWarning: config.changedFilesWarning,
					minIntervalMs: config.minIntervalMs,
				},
			},
		};
	}
	const final = s.warningsSent + 1 >= config.diagnosticTurns;
	s.warningsSent++;
	s.lastWarningAt = now;
	s.lastToolCalls = unitMetrics.toolCalls;
	s.lastSessionTokens = unitMetrics.sessionTokens;
	s.lastElapsedMs = unitMetrics.elapsedMs;
	if (final) s.finalWarningSent = true;
	return {
		action: "warn",
		final,
		message: buildRunawayGuardMessage(
			unitType,
			unitId,
			unitMetrics,
			reasons,
			final,
		),
	};
}
function evaluateZeroProgressState(state, unitMetrics) {
	const currentFingerprint = unitMetrics.worktreeFingerprint ?? null;
	if (
		currentFingerprint !== null &&
		state.zeroProgressFingerprint !== null &&
		currentFingerprint !== state.zeroProgressFingerprint
	) {
		state.zeroProgressToolCalls = unitMetrics.toolCalls;
		state.zeroProgressElapsedMs = unitMetrics.elapsedMs;
		state.zeroProgressFingerprint = currentFingerprint;
		state.zeroProgressIterationsSinceProgress = 0;
		return { stuck: false, reason: "", signature: {} };
	}
	if (currentFingerprint !== null && state.zeroProgressFingerprint === null) {
		state.zeroProgressToolCalls = unitMetrics.toolCalls;
		state.zeroProgressElapsedMs = unitMetrics.elapsedMs;
		state.zeroProgressFingerprint = currentFingerprint;
		state.zeroProgressIterationsSinceProgress = 0;
		return { stuck: false, reason: "", signature: {} };
	}

	const explicitIterations =
		unitMetrics.iterationsSinceProgress ??
		unitMetrics.iterations_since_progress;
	const iterationsSinceProgress =
		typeof explicitIterations === "number" &&
		Number.isFinite(explicitIterations)
			? explicitIterations
			: state.zeroProgressIterationsSinceProgress + 1;
	state.zeroProgressIterationsSinceProgress = iterationsSinceProgress;

	return evaluateZeroProgress(
		{ ...unitMetrics, iterationsSinceProgress },
		{
			toolCalls: state.zeroProgressToolCalls,
			elapsedMs: state.zeroProgressElapsedMs,
			fingerprint: state.zeroProgressFingerprint,
		},
	);
}
function normalizeMetricsToUnit(metrics, state) {
	const worktreeChangedSinceStart =
		metrics.worktreeFingerprint !== undefined &&
		metrics.worktreeFingerprint !== null &&
		state.baselineWorktreeFingerprint !== null
			? metrics.worktreeFingerprint !== state.baselineWorktreeFingerprint
			: metrics.worktreeChangedSinceStart;
	return {
		...metrics,
		sessionTokens: Math.max(
			0,
			metrics.sessionTokens - state.baselineSessionTokens,
		),
		changedFiles:
			metrics.changedFiles === undefined
				? undefined
				: Math.max(0, metrics.changedFiles - state.baselineChangedFiles),
		worktreeChangedSinceStart,
	};
}
function thresholdReasons(unitType, metrics, config) {
	const reasons = [];
	// Primary signal: high tool call count — strong indicator of runaway/churn
	if (
		config.toolCallWarning > 0 &&
		metrics.toolCalls >= config.toolCallWarning
	) {
		reasons.push(
			`${metrics.toolCalls} tool calls (warning ${config.toolCallWarning})`,
		);
	}
	// Primary signal: long elapsed time — unit may be stuck
	if (config.elapsedMs > 0 && metrics.elapsedMs >= config.elapsedMs) {
		reasons.push(
			`${Math.round(metrics.elapsedMs / 60000)}min elapsed (warning ${Math.round(config.elapsedMs / 60000)}min)`,
		);
	}
	// Primary signal: many changed files — possible churn/duplication
	if (
		config.changedFilesWarning > 0 &&
		(metrics.changedFiles ?? 0) >= config.changedFilesWarning
	) {
		reasons.push(
			`${metrics.changedFiles} new changed files (warning ${config.changedFilesWarning})`,
		);
	}
	// Token count is a secondary signal: only fire when at least one primary
	// signal is also present, OR when the no-progress heuristic fires.
	// This prevents false positives on units that do real work with large
	// context models (a 25-tool-call unit can legitimately burn 1M+ tokens).
	const hasPrimarySignal = reasons.length > 0;
	if (config.tokenWarning > 0 && metrics.sessionTokens >= config.tokenWarning) {
		if (hasPrimarySignal) {
			reasons.push(
				`${formatTokenCount(metrics.sessionTokens)} unit tokens (warning ${formatTokenCount(config.tokenWarning)})`,
			);
		}
	}
	// No-progress heuristic for execute-task: no file changes despite many
	// tool calls and tokens — strong runaway indicator regardless of primary
	// signals. This is the exception where tokens alone can trigger.
	if (
		unitType === "execute-task" &&
		(metrics.changedFiles ?? 0) === 0 &&
		metrics.worktreeChangedSinceStart !== true &&
		metrics.toolCalls >= EXECUTE_NO_PROGRESS_TOOL_WARNING &&
		metrics.sessionTokens >= EXECUTE_NO_PROGRESS_TOKEN_WARNING
	) {
		reasons.push(
			`no new file changes after ${metrics.toolCalls} tool calls and ${formatTokenCount(metrics.sessionTokens)} tokens`,
		);
	}
	return reasons;
}
function hasMeaningfulGrowth(metrics, state, config) {
	const toolGrowth = Math.max(5, Math.floor(config.toolCallWarning / 4));
	const tokenGrowth = Math.max(50_000, Math.floor(config.tokenWarning / 4));
	return (
		metrics.toolCalls - state.lastToolCalls >= toolGrowth ||
		metrics.sessionTokens - state.lastSessionTokens >= tokenGrowth ||
		metrics.elapsedMs - state.lastElapsedMs >= config.minIntervalMs
	);
}
function buildRunawayGuardMessage(unitType, unitId, metrics, reasons, final) {
	const topTools = metrics.topTools
		? Object.entries(metrics.topTools)
				.sort(([, a], [, b]) => b - a)
				.slice(0, 5)
				.map(([name, count]) => `${name}x${count}`)
				.join(", ")
		: "";
	const title = final
		? "**RUNAWAY UNIT FINAL WARNING - write diagnosis and handoff now.**"
		: "**RUNAWAY UNIT BUDGET WARNING - diagnose before continuing.**";
	return [
		title,
		`Unit: ${unitType} ${unitId}`,
		`Budget signals: ${reasons.join("; ")}.`,
		topTools ? `Tool mix: ${topTools}.` : "",
		formatChangedFilesLine(unitType, metrics),
		"",
		final
			? "You have already received a budget warning. Do not start new exploration. Write or update the durable artifact/handoff now, explicitly stating whether the unit was legitimately large, blocked, or stuck in a loop."
			: "Before more exploration or broad edits, state why this unit is still running: legitimately large, blocked, or stuck/churning. Then either finish the required artifact or write a precise handoff.",
	]
		.filter(Boolean)
		.join("\n");
}
function formatChangedFilesLine(unitType, metrics) {
	if ((metrics.changedFiles ?? 0) > 0) {
		return `Working tree has ${metrics.changedFiles} new changed file(s) since this unit started. Active edits are not automatically healthy progress; check for repeated or broad churn.`;
	}
	if (unitType === "execute-task" && metrics.worktreeChangedSinceStart) {
		return "Working tree has 0 new changed file paths, but dirty file content changed since this execute-task started.";
	}
	if (unitType === "execute-task") {
		return "Working tree has 0 new changed files since this execute-task started. For implementation work, that is no durable progress yet.";
	}
	return "";
}
function formatMetricSummary(metrics) {
	return [
		`${metrics.toolCalls} tool calls`,
		`${formatTokenCount(metrics.sessionTokens)} tokens`,
		`${Math.round(metrics.elapsedMs / 60000)}min elapsed`,
		metrics.changedFiles !== undefined
			? `${metrics.changedFiles} new changed files`
			: "",
		metrics.worktreeChangedSinceStart ? "dirty file content changed" : "",
	]
		.filter(Boolean)
		.join(", ");
}
function parsePorcelainPath(line) {
	if (line.length < 4) return null;
	let filePath = line.slice(3);
	const renameSeparator = " -> ";
	if (filePath.includes(renameSeparator)) {
		filePath = filePath.slice(
			filePath.lastIndexOf(renameSeparator) + renameSeparator.length,
		);
	}
	if (filePath.startsWith('"') && filePath.endsWith('"')) {
		filePath = filePath.slice(1, -1);
	}
	return filePath || null;
}
function numeric(value) {
	return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
