/**
 * auto/detect-stuck.ts — Sliding-window stuck detection for the auto-loop.
 *
 * Leaf node in the import DAG.
 */
import { summarizeLogs } from "../workflow-logger.js";

/**
 * Pattern matching ENOENT errors with a file path.
 * Matches: "ENOENT: no such file or directory, access '/path/to/file'"
 * and similar Node.js filesystem error messages.
 */
const ENOENT_PATH_RE = /ENOENT[^']*'([^']+)'/;
const TRANSIENT_TASK_COMPLETE_RE =
	/\b(?:complete_task failed|Error completing task:).*SUMMARY\.md write failed/i;
const MAX_STUCK_REASON_CHARS = 260;
function isTransientTaskCompleteError(entry) {
	return (
		typeof entry.error === "string" &&
		TRANSIENT_TASK_COMPLETE_RE.test(entry.error)
	);
}
function truncateReason(reason) {
	return reason.length > MAX_STUCK_REASON_CHARS
		? `${reason.slice(0, MAX_STUCK_REASON_CHARS - 1)}…`
		: reason;
}
/**
 * Analyze a sliding window of recent unit dispatches for stuck patterns.
 * Returns a signal with reason if stuck, null otherwise.
 *
 * Rule 1: Same error string twice in a row → stuck immediately.
 * Rule 2: Same unit key 3+ consecutive times → stuck (preserves prior behavior).
 * Rule 3: Oscillation A→B→A→B in last 4 entries → stuck.
 * Rule 4: Same ENOENT path in any 2 entries within the window → stuck (#3575).
 *         Missing files don't self-heal between retries — retrying wastes budget.
 */
export function detectStuck(window) {
	const effectiveWindow = window.filter(
		(entry) => !isTransientTaskCompleteError(entry),
	);
	if (effectiveWindow.length < 2) return null;
	// Peek (not drain) the workflow-logger buffer so stuck reasons can surface
	// the underlying diagnostic context (projection failures, DB degradations,
	// reconcile warnings) that usually explains *why* the loop is stuck. The
	// auto-loop's finalize step owns the buffer lifecycle — this is read-only.
	const loggerSummary = summarizeLogs();
	const suffix = loggerSummary ? ` — ${loggerSummary}` : "";
	const last = effectiveWindow[effectiveWindow.length - 1];
	const prev = effectiveWindow[effectiveWindow.length - 2];
	// Rule 1: Same error repeated consecutively
	if (last.error && prev.error && last.error === prev.error) {
		return {
			stuck: true,
			reason: truncateReason(
				`Same error repeated: ${last.error.slice(0, 200)}${suffix}`,
			),
		};
	}
	// Rule 2: Same unit 3+ consecutive times
	if (effectiveWindow.length >= 3) {
		const lastThree = effectiveWindow.slice(-3);
		if (lastThree.every((u) => u.key === last.key)) {
			return {
				stuck: true,
				reason: truncateReason(
					`${last.key} derived 3 consecutive times without progress${suffix}`,
				),
			};
		}
	}
	// Rule 3: Oscillation (A→B→A→B in last 4)
	if (effectiveWindow.length >= 4) {
		const w = effectiveWindow.slice(-4);
		if (
			w[0].key === w[2].key &&
			w[1].key === w[3].key &&
			w[0].key !== w[1].key
		) {
			return {
				stuck: true,
				reason: truncateReason(
					`Oscillation detected: ${w[0].key} ↔ ${w[1].key}${suffix}`,
				),
			};
		}
	}
	// Rule 4: Same ENOENT path seen twice in window (#3575)
	// Missing files don't appear between retries — stop immediately.
	const enoentPaths = new Map();
	for (const entry of effectiveWindow) {
		if (!entry.error) continue;
		const match = ENOENT_PATH_RE.exec(entry.error);
		if (!match) continue;
		const filePath = match[1];
		const count = (enoentPaths.get(filePath) ?? 0) + 1;
		if (count >= 2) {
			return {
				stuck: true,
				reason: truncateReason(
					`Missing file referenced twice: ${filePath} (ENOENT)${suffix}`,
				),
			};
		}
		enoentPaths.set(filePath, count);
	}
	return null;
}
