/**
 * auto/run-unit.ts — Single unit execution: session create → prompt → await agent_end.
 *
 * Imports from: auto/types, auto/resolve
 */

import { scopeActiveToolsForUnitType } from "../constants.js";
import { debugLog } from "../debug-logger.js";
import { getErrorMessage } from "../error-utils.js";
import {
	resolveAutoSupervisorConfig,
	resolvePersistModelChanges,
} from "../preferences.js";
import {
	collectSessionTokenUsage,
	collectWorktreeFingerprint,
	countChangedFiles,
	resetRunawayGuardState,
} from "../uok/auto-runaway-guard.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;
/**
 * Build the custom-message content for a unit prompt.
 *
 * Purpose: preserve the exact prompt text while allowing the provider layer to
 * cache the stable prefix separately from the dynamic suffix.
 *
 * Consumer: runUnit before pi.sendMessage dispatches the autonomous unit turn.
 */
export function buildUnitPromptMessageContent(prompt, promptParts) {
	if (!promptParts) return prompt;
	return [
		{
			type: "text",
			text: `${promptParts.before}\n`,
			cache_control: { type: "ephemeral" },
		},
		{ type: "text", text: promptParts.after },
	];
}

function sameStringList(a, b) {
	if (a.length !== b.length) return false;
	return a.every((value, index) => value === b[index]);
}

/**
 * Resolve the active tool list for one autonomous unit dispatch.
 *
 * Purpose: keep phase-specific agents inside their authority boundary. Normal
 * units use the unit-type SF allowlists; protocol-only passes can supply an
 * explicit allowlist so they cannot edit files, run shell commands, or dispatch
 * subagents.
 *
 * Consumer: runUnit before pi.sendMessage.
 */
export function scopeActiveToolsForRunUnit(
	unitType,
	currentTools,
	options = {},
) {
	const explicitAllowlist = Array.isArray(options.activeToolsAllowlist)
		? options.activeToolsAllowlist
		: null;
	if (explicitAllowlist) {
		const allowed = new Set(explicitAllowlist);
		return currentTools.filter((toolName) => allowed.has(toolName));
	}
	return scopeActiveToolsForUnitType(unitType, currentTools);
}

/**
 * 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.
 *
 * Options:
 *   keepSession — when true, skip newSession() and continue in the current
 *     conversation. Used for repair retries within the same unit so the agent
 *     sees what it tried before and why it failed, rather than starting cold.
 *     Default: false (each new unit starts with a clean session).
 */
export async function runUnit(ctx, pi, s, unitType, unitId, prompt, options) {
	const keepSession = options?.keepSession === true;
	// promptParts: {before, after} — stable prefix (to cache) + dynamic suffix.
	// When present, passes the content as a two-block array so providers can mark
	// the stable prefix with cache_control:ephemeral.
	const promptParts = options?.promptParts ?? null;
	debugLog("runUnit", { phase: "start", unitType, unitId, keepSession });
	// 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 (skipped for same-unit repair retries) ──
	// keepSession=true: send the repair prompt into the existing conversation so
	// the agent has full context of what it already tried and why it failed.
	// keepSession=false (default): start a clean session for each new unit.
	if (!keepSession) {
		debugLog("runUnit", { phase: "session-create", unitType, unitId });
		// Guard: s.cmdCtx must have newSession() (ExtensionCommandContext). If it
		// doesn't, autonomous mode was started from a shortcut handler without a
		// valid command ctx — startAuto() should have caught this, but defend here
		// as a last resort so we get a clear error category instead of a TypeError.
		if (typeof s.cmdCtx?.newSession !== "function") {
			return {
				status: "cancelled",
				errorContext: {
					message:
						"cmdCtx.newSession is not available — autonomous mode was started from a non-command context. Run /autonomous once to establish a command context.",
					category: "session-failed",
					isTransient: false,
				},
			};
		}
		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 = getErrorMessage(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,
				},
			};
		}
	}
	// Tell FallbackResolver what unit is running so any mid-unit model failure
	// is attributed to the correct unit type in the learning store.
	if (typeof pi.setFallbackUnitContext === "function") {
		pi.setFallbackUnitContext({ unitType, unitId });
	}
	// ── Create the agent_end promise (per-unit one-shot) ──
	// When keepSession=false: clear the in-flight guard now that the new session
	// is fully ready, so handleAgentEnd processes events for this unit only.
	// When keepSession=true: no session switch happened, guard is already clear.
	if (!keepSession) {
		_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 = scopeActiveToolsForRunUnit(unitType, currentTools, {
			activeToolsAllowlist: options?.activeToolsAllowlist,
		});
		if (!sameStringList(scopedTools, currentTools)) {
			savedTools = currentTools;
			pi.setActiveTools(scopedTools);
			debugLog("unit-tool-scoping", {
				unitType,
				before: currentTools.length,
				after: scopedTools.length,
				removed: currentTools.length - scopedTools.length,
				explicitAllowlist: Array.isArray(options?.activeToolsAllowlist),
			});
		}
	}
	try {
		// When promptParts is available, send structured content so the provider can
		// apply cache_control:ephemeral to the stable prefix (before) while leaving
		// the dynamic suffix (after) uncached.
		const messageContent = buildUnitPromptMessageContent(prompt, promptParts);
		await pi.sendMessage(
			{ customType: "sf-auto", content: messageContent, 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);
	// Clear unit context — fallback after this point has no associated unit.
	if (typeof pi.setFallbackUnitContext === "function") {
		pi.setFallbackUnitContext(null);
	}
	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;
}
