/**
 * auto/run-unit.ts — Single unit execution: session create → prompt → await agent_end.
 *
 * Imports from: auto/types, auto/resolve
 */
import {
	collectSessionTokenUsage,
	collectWorktreeFingerprint,
	countChangedFiles,
	resetRunawayGuardState,
} from "../auto-runaway-guard.js";
import { scopeActiveToolsForUnitType } from "../constants.js";
import { debugLog } from "../debug-logger.js";
import {
	resolveAutoSupervisorConfig,
	resolvePersistModelChanges,
} from "../preferences.js";
import { logWarning } from "../workflow-logger.js";
import {
	_clearCurrentResolve,
	_setCurrentResolve,
	_setSessionSwitchInFlight,
} from "./resolve.js";
import { NEW_SESSION_TIMEOUT_MS } from "./session.js";
import {
	getCurrentTurnGeneration,
	runWithTurnGeneration,
} from "./turn-epoch.js";

// Tracks the latest session-switch attempt so a late timeout settlement from an
// older runUnit() call cannot clear the guard for a newer one.
let sessionSwitchGeneration = 0;
/**
 * Execute a single unit: create a new session, send the prompt, and await
 * the agent_end promise. Returns a UnitResult describing what happened.
 *
 * The promise is one-shot: resolveAgentEnd() is the only way to resolve it.
 * On session creation failure or timeout, returns { status: 'cancelled' }
 * without awaiting the promise.
 */
export async function runUnit(ctx, pi, s, unitType, unitId, prompt) {
	debugLog("runUnit", { phase: "start", unitType, unitId });
	// GAP-10: Ensure cwd matches basePath BEFORE newSession() captures it. The
	// new session reads process.cwd() during construction to anchor its tool
	// runtime and system prompt; if cwd has drifted (async_bash, background
	// jobs, prior unit cleanup), the session would otherwise be rooted to the
	// wrong directory. Must be synchronous — no awaits between chdir and
	// newSession (#1389, #4762 follow-up).
	try {
		if (s.basePath && process.cwd() !== s.basePath) {
			process.chdir(s.basePath);
		}
	} catch (e) {
		const msg = `Failed to chdir to basePath before newSession (basePath: ${s.basePath}): ${String(e)}`;
		logWarning("engine", msg, { basePath: s.basePath, error: String(e) });
		return {
			status: "cancelled",
			errorContext: {
				message: msg,
				category: "session-failed",
				isTransient: true,
			},
		};
	}
	// ── Session creation with timeout ──
	debugLog("runUnit", { phase: "session-create", unitType, unitId });
	let sessionResult;
	let sessionTimeoutHandle;
	const mySessionSwitchGeneration = ++sessionSwitchGeneration;
	// GAP-07: Cancellation controller for newSession(). When the session-creation
	// timeout fires, we abort this controller so that any still-in-flight
	// newSession() work (which may clobber process.cwd()) is signalled to stop.
	// Note: SF's newSession() does not currently accept abortSignal in its
	// options type, so we cannot pass it directly — but aborting the controller
	// documents the intent clearly and is a no-op call site when the API adds it.
	const sessionAbortController = new AbortController();
	_setSessionSwitchInFlight(true);
	try {
		const sessionPromise = s.cmdCtx.newSession().finally(() => {
			if (sessionSwitchGeneration === mySessionSwitchGeneration) {
				_setSessionSwitchInFlight(false);
			}
		});
		const timeoutPromise = new Promise((resolve) => {
			sessionTimeoutHandle = setTimeout(() => {
				sessionAbortController.abort();
				resolve({ cancelled: true });
			}, NEW_SESSION_TIMEOUT_MS);
		});
		sessionResult = await Promise.race([sessionPromise, timeoutPromise]);
	} catch (sessionErr) {
		if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle);
		const msg =
			sessionErr instanceof Error ? sessionErr.message : String(sessionErr);
		debugLog("runUnit", {
			phase: "session-error",
			unitType,
			unitId,
			error: msg,
		});
		return {
			status: "cancelled",
			errorContext: {
				message: `Session creation failed: ${msg}`,
				category: "session-failed",
				isTransient: true,
			},
		};
	}
	if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle);
	if (sessionResult.cancelled) {
		debugLog("runUnit-session-timeout", { unitType, unitId });
		// On timeout, do NOT clear the in-flight guard here. The dangling
		// sessionPromise's .finally() has a generation check — it will clear the
		// guard when the underlying newSession promise eventually settles, but only
		// if no newer runUnit call has already incremented the generation. This is
		// the correct design: the guard stays true until the next session is ready,
		// preventing stale agent_end events from the timed-out session from being
		// processed by handleAgentEnd. The next runUnit call sets inFlight=true
		// again and its own .finally() manages the clearing.
		return {
			status: "cancelled",
			errorContext: {
				message: "Session creation timed out",
				category: "timeout",
				isTransient: true,
			},
		};
	}
	if (!s.active) {
		return { status: "cancelled" };
	}
	// GAP-09: Hard-cancel if setModel fails rather than continuing with the
	// wrong model. Running with an unexpected model wastes the unit and can
	// cause quota / pricing surprises.
	if (s.currentUnitModel && typeof pi.setModel === "function") {
		const modelId = s.currentUnitModel;
		const restored = await pi.setModel(modelId, {
			persist: resolvePersistModelChanges(),
		});
		if (!restored) {
			return {
				status: "cancelled",
				errorContext: {
					message: `setModel failed for ${modelId.provider}/${modelId.id}`,
					category: "session-failed",
					isTransient: false,
				},
			};
		}
	}
	// ── Create the agent_end promise (per-unit one-shot) ──
	// This happens after newSession completes so session-switch agent_end events
	// from the previous session cannot resolve the new unit.
	_setSessionSwitchInFlight(false);
	const unitPromise = new Promise((resolve) => {
		_setCurrentResolve(resolve);
	});
	// GAP-08: Provider request-readiness pre-check (#4555).
	// Verify the provider can accept requests before dispatching. If the token
	// has expired since bootstrap, return cancelled immediately so the unit is
	// not wasted on a guaranteed 401.
	{
		const provider = s.currentUnitModel?.provider ?? ctx.model?.provider;
		if (
			provider != null &&
			typeof ctx.modelRegistry?.isProviderRequestReady === "function"
		) {
			let ready = false;
			try {
				ready = ctx.modelRegistry.isProviderRequestReady(provider);
			} catch {
				ready = false;
			}
			if (!ready) {
				_clearCurrentResolve();
				return {
					status: "cancelled",
					errorContext: {
						message: `Provider ${provider} is not request-ready (login/token expired)`,
						category: "provider",
						isTransient: false,
					},
				};
			}
		}
	}
	// Refresh the runaway baseline after newSession(). Resumed sessions recover
	// old context during session creation; taking the baseline before that makes
	// historical tokens look like budget spent by this unit.
	resetRunawayGuardState(unitType, unitId, {
		sessionTokens: collectSessionTokenUsage(ctx),
		changedFiles: countChangedFiles(s.basePath),
		worktreeFingerprint: collectWorktreeFingerprint(s.basePath),
	});
	// ── Send the prompt ──
	debugLog("runUnit", { phase: "send-message", unitType, unitId });
	// Capture the turn generation BEFORE sendMessage so any stale-write
	// checks reached from within this turn see the same generation we start
	// with. bumpTurnGeneration() is called by timeout-recovery when this turn
	// is superseded; isStaleWrite() in journal.ts uses it to drop late writes.
	const capturedTurnGen = getCurrentTurnGeneration();
	const requestDispatchedAt = Date.now();
	let savedTools = null;
	if (
		typeof pi.getActiveTools === "function" &&
		typeof pi.setActiveTools === "function"
	) {
		const currentTools = pi.getActiveTools();
		const scopedTools = scopeActiveToolsForUnitType(unitType, currentTools);
		if (scopedTools.length !== currentTools.length) {
			savedTools = currentTools;
			pi.setActiveTools(scopedTools);
			debugLog("unit-tool-scoping", {
				unitType,
				before: currentTools.length,
				after: scopedTools.length,
				removed: currentTools.length - scopedTools.length,
			});
		}
	}
	try {
		await pi.sendMessage(
			{ customType: "sf-auto", content: prompt, display: s.verbose },
			{ triggerTurn: true },
		);
	} finally {
		if (savedTools) {
			pi.setActiveTools(savedTools);
		}
	}
	// ── Await agent_end with absolute timeout (H4 fix) ──
	// If supervision fails to resolve unitPromise within 30s, treat as cancelled.
	// Without this, a crashed agent that never emits agent_end hangs the loop (#3161).
	debugLog("runUnit", { phase: "awaiting-agent-end", unitType, unitId });
	const supervisor = resolveAutoSupervisorConfig();
	const UNIT_HARD_TIMEOUT_MS = Math.max(
		30_000,
		(supervisor.hard_timeout_minutes ?? 30) * 60 * 1000 + 30_000,
	);
	let unitTimeoutHandle;
	const timeoutResult = new Promise((resolve) => {
		unitTimeoutHandle = setTimeout(() => {
			resolve({
				status: "cancelled",
				errorContext: {
					message: "Unit hard timeout — supervision may have failed",
					category: "timeout",
					isTransient: true,
				},
			});
		}, UNIT_HARD_TIMEOUT_MS);
	});
	const result = await runWithTurnGeneration(capturedTurnGen, () =>
		Promise.race([unitPromise, timeoutResult]),
	);
	if (unitTimeoutHandle) clearTimeout(unitTimeoutHandle);
	debugLog("runUnit", {
		phase: "agent-end-received",
		unitType,
		unitId,
		status: result.status,
	});
	const finalResult = { ...result, requestDispatchedAt };
	// Discard trailing follow-up messages (e.g. async_job_result notifications)
	// from the completed unit. Without this, queued follow-ups trigger wasteful
	// LLM turns before the next session can start (#1642).
	// clearQueue() lives on AgentSession but isn't part of the typed
	// ExtensionCommandContext interface — call it via runtime check.
	try {
		const cmdCtxAny = s.cmdCtx;
		if (typeof cmdCtxAny?.clearQueue === "function") {
			cmdCtxAny.clearQueue();
		}
	} catch (e) {
		logWarning("engine", "clearQueue failed after unit completion", {
			error: String(e),
		});
	}
	return finalResult;
}
