/**
 * 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 { formatTokenCount } from "../shared/format-utils.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",
	".sf/DECISIONS.md",
	".sf/KNOWLEDGE.md",
	".sf/STATE.md",
];
let state = null;
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,
		finalWarningSent: false,
	};
}
export function clearRunawayGuardState() {
	state = null;
}
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 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" };
	}
	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 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;
}
