/**
 * subagent-runner.ts — In-process subagent execution helper.
 *
 * Replaces the spawn-based `sf -p` dispatch in headless-triage.ts and
 * uok/agent-runner.js.  Each call gets a fresh, isolated AgentSession.
 * Cancellation via AbortController, not signals.
 */

import type {
	AssistantMessage,
	AssistantMessageEvent,
} from "@singularity-forge/ai";
import { getAgentDir } from "../config.js";
import { theme } from "../modes/interactive/theme/theme.js";
import type { AgentSessionEvent } from "./agent-session.js";
import type { ExtensionUIContext } from "./extensions/types.js";
import { DefaultResourceLoader } from "./resource-loader.js";
import { createAgentSession } from "./sdk.js";
import { SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";

const DEFAULT_SUBAGENT_TIMEOUT_MS = 8 * 60 * 1000;

export interface SubagentConfig {
	/** Pre-composed system prompt (caller has already merged any prompt-parts). */
	systemPrompt: string;
	/** Optional model id like "kimi-coding/kimi-k2.6". If omitted, uses the session default. */
	model?: string;
	/** Tool name filter (subset of built-in tools by name). */
	tools?: string[];
	/**
	 * Legacy SF permission level to initialize permission extension state for
	 * headless subagent sessions. When omitted, extension lifecycle stays off.
	 */
	permissionLevel?: "minimal" | "low" | "medium" | "high" | "bypassed";
	/** Working directory. Default: process.cwd(). */
	cwd?: string;
	/** Display name for log/error messages. */
	name?: string;
}

export interface SubagentResult {
	ok: boolean;
	/** Final assistant text output (joined from all text parts). */
	output: string;
	/** Error or timeout message. */
	stderr?: string;
	/** 0=success, 124=timeout, 1=error. */
	exitCode: number;
}

/**
 * No-op UI context for subagent sessions (headless, no interactive UI).
 * Shape mirrors createPrintModeUIContext() in print-mode.ts.
 */
function createSubagentUIContext(): ExtensionUIContext {
	return {
		select: async () => undefined,
		confirm: async () => false,
		input: async () => undefined,
		notify: () => {},
		onTerminalInput: () => () => {},
		setStatus: () => {},
		setWorkingMessage: () => {},
		setWorkingVisible: () => {},
		setWidget: () => {},
		setFooter: () => {},
		setHeader: () => {},
		setTitle: () => {},
		custom: async () => undefined as never,
		pasteToEditor: () => {},
		setEditorText: () => {},
		getEditorText: () => "",
		editor: async () => undefined,
		setEditorComponent: () => {},
		get theme() {
			return theme;
		},
		getAllThemes: () => [],
		getTheme: () => undefined,
		setTheme: () => ({ success: false, error: "UI not available" }),
		getToolsExpanded: () => false,
		setToolsExpanded: () => {},
	};
}

function isMeaningfulSubagentOutputEvent(event: AgentSessionEvent): boolean {
	if (String(event.type) === "runtime_heartbeat") return false;
	if (event.type === "message_update") {
		const streamEvent = event.assistantMessageEvent;
		if (streamEvent.type === "text_delta") return streamEvent.delta.length > 0;
		return true;
	}
	return true;
}

export interface RunSubagentOptions {
	signal?: AbortSignal;
	timeoutMs?: number;
	/**
	 * Abort when the subagent produces no session events for this long.
	 * This is separate from timeoutMs: a long-running worker may keep making
	 * useful progress, but a silent model call should fail fast so callers can
	 * retry or route to a different model.
	 */
	noOutputTimeoutMs?: number;
	/**
	 * Called for each agent session event (forwarded from session.subscribe).
	 * Use this to drive live UI updates without polling.
	 * Errors thrown from this callback are caught and logged but do not abort the subagent.
	 */
	onEvent?: (event: AgentSessionEvent) => void;
}

/**
 * Run a subagent in-process with an isolated AgentSession.
 *
 * @param config  - Subagent configuration (system prompt, model, tools, cwd).
 * @param task    - Task string to send as the first prompt.
 * @param options - Optional AbortSignal, timeout, and event callback.
 * @returns       SubagentResult with ok, output, stderr, exitCode.
 */
export async function runSubagent(
	config: SubagentConfig,
	task: string,
	options?: RunSubagentOptions,
): Promise<SubagentResult> {
	const name = config.name ?? "subagent";
	const cwd = config.cwd ?? process.cwd();
	const timeoutMs = options?.timeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_MS;
	const noOutputTimeoutMs = options?.noOutputTimeoutMs ?? 0;

	// Per-phase timing diagnostics for sf-mp8e02m1-zpk903: the prompt-never-sent
	// bug surfaces here as "session initialized but no LLM call". Each phase
	// logs start/end so when an iteration silently hangs, the log identifies
	// which step is stuck. Also wraps each await in a soft watchdog that emits
	// a "STUCK in phase X" warning after 10s — no abort, just visibility so
	// the auto-supervisor can correlate with idle-halt errors.
	const phaseStartedAt = (): number => Date.now();
	const logPhase = (phase: string, startedAt: number, extra?: string) => {
		const ms = Date.now() - startedAt;
		const tail = extra ? ` ${extra}` : "";
		process.stderr.write(`[subagent:${name}] phase=${phase} ${ms}ms${tail}\n`);
	};
	const phaseWatchdog = (phase: string, startedAt: number): NodeJS.Timeout =>
		setTimeout(() => {
			const ms = Date.now() - startedAt;
			process.stderr.write(
				`[subagent:${name}] STUCK phase=${phase} ${ms}ms (no completion signal — see sf-mp8e02m1-zpk903 for context)\n`,
			);
		}, 10_000);

	// Build an isolated resource loader with the caller's system prompt appended.
	const agentDir = getAgentDir();
	const settingsManager = SettingsManager.create(cwd, agentDir);
	const resourceLoader = new DefaultResourceLoader({
		cwd,
		agentDir,
		settingsManager,
		appendSystemPrompt: config.systemPrompt,
	});
	let phaseAt = phaseStartedAt();
	let watchdog = phaseWatchdog("resourceLoader.reload", phaseAt);
	await resourceLoader.reload();
	clearTimeout(watchdog);
	logPhase("resourceLoader.reload", phaseAt);

	// Create a fresh in-memory session so it never touches the user's session files.
	// Tool filtering is applied below via setActiveToolsByName after session creation.
	phaseAt = phaseStartedAt();
	watchdog = phaseWatchdog("createAgentSession", phaseAt);
	const { session, modelFallbackMessage } = await createAgentSession({
		cwd,
		agentDir,
		resourceLoader,
		sessionManager: SessionManager.inMemory(),
		settingsManager,
		persistModelChanges: false,
	});
	clearTimeout(watchdog);
	logPhase("createAgentSession", phaseAt);

	if (modelFallbackMessage) {
		// Non-fatal: log but continue with whatever model was resolved.
		process.stderr.write(
			`[subagent:${name}] model fallback: ${modelFallbackMessage}\n`,
		);
	}

	// Apply optional model override.
	if (config.model) {
		const parts = config.model.split("/");
		const [provider, modelId] =
			parts.length >= 2
				? [parts[0], parts.slice(1).join("/")]
				: ["", config.model];
		const match = provider
			? session.modelRegistry.find(provider, modelId)
			: session.modelRegistry
					.getAll()
					.find(
						(m) =>
							m.id === config.model || `${m.provider}/${m.id}` === config.model,
					);
		if (match) {
			try {
				await session.setModel(match, { persist: false });
			} catch (err) {
				process.stderr.write(
					`[subagent:${name}] could not set model "${config.model}": ${err instanceof Error ? err.message : String(err)}\n`,
				);
			}
		} else {
			process.stderr.write(
				`[subagent:${name}] model "${config.model}" not found in registry, using default\n`,
			);
		}
	}

	// Apply tool name filter. setActiveToolsByName silently drops unknown names
	// and rebuilds the system prompt to reflect the active tool set.
	if (config.tools && config.tools.length > 0) {
		session.setActiveToolsByName(config.tools);
	}

	// Bind extensions in headless mode. When the caller supplies a permission
	// level, run lifecycle once so the permission extension reads the env-backed
	// level into its closure before tool hooks execute.
	const previousPermissionLevel = process.env.SF_PERMISSION_LEVEL;
	if (config.permissionLevel) {
		process.env.SF_PERMISSION_LEVEL = config.permissionLevel;
	}
	phaseAt = phaseStartedAt();
	watchdog = phaseWatchdog("bindExtensions", phaseAt);
	try {
		await session.bindExtensions({
			uiContext: createSubagentUIContext(),
			onError: (err) => {
				process.stderr.write(
					`[subagent:${name}] extension error (${err.extensionPath}): ${err.error}\n`,
				);
			},
			runLifecycle: Boolean(config.permissionLevel),
		});
		clearTimeout(watchdog);
		logPhase(
			"bindExtensions",
			phaseAt,
			`runLifecycle=${Boolean(config.permissionLevel)}`,
		);
	} finally {
		if (config.permissionLevel) {
			if (previousPermissionLevel === undefined) {
				delete process.env.SF_PERMISSION_LEVEL;
			} else {
				process.env.SF_PERMISSION_LEVEL = previousPermissionLevel;
			}
		}
	}

	// Debug: confirm tool count after bindExtensions so operators can verify
	// extension tools (e.g. checkpoint) are present before the model is called.
	process.stderr.write(
		`[subagent:${name}] tool count after bindExtensions: ${session.getActiveToolNames().length} (${session.getActiveToolNames().join(", ")})\n`,
	);

	// Collect incremental text output from events so the timeout case
	// can still return partial output.
	let partialOutput = "";
	const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
		if (event.type === "message_update") {
			const streamEvent: AssistantMessageEvent = event.assistantMessageEvent;
			if (streamEvent.type === "text_delta") {
				partialOutput += streamEvent.delta;
			}
		}
		// Forward every event to the caller's callback (if provided).
		// Errors from the callback are caught so a buggy caller cannot crash the agent.
		if (options?.onEvent) {
			try {
				options.onEvent(event);
			} catch (cbErr) {
				process.stderr.write(
					`[subagent:${name}] onEvent callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}\n`,
				);
			}
		}
	});

	// Helper to extract final output from session state messages.
	const extractFinalOutput = (): string => {
		const messages = session.state.messages;
		const last = messages[messages.length - 1];
		if (!last || last.role !== "assistant") return partialOutput;
		const assistant = last as AssistantMessage;
		const parts = assistant.content
			.filter((c): c is { type: "text"; text: string } => c.type === "text")
			.map((c) => c.text);
		return parts.join("\n") || partialOutput;
	};

	let timer: ReturnType<typeof setTimeout> | undefined;
	let noOutputTimer: ReturnType<typeof setTimeout> | undefined;
	let noOutputTimedOut = false;

	const cleanup = (): void => {
		if (timer) {
			clearTimeout(timer);
			timer = undefined;
		}
		if (noOutputTimer) {
			clearTimeout(noOutputTimer);
			noOutputTimer = undefined;
		}
		unsubscribe();
	};

	const promptTask = `Task: ${task}`;

	// Final diagnostic point: about to call session.prompt. If this never logs
	// "phase=session.prompt-returned" and the subagent hangs, the LLM dispatch
	// path itself is the silent abort seam (likely a runExtensionHooks=false
	// path A/D bypass via slash/sf-route prefix expansion, or a model-dispatch
	// hang). Combined with the no-output watchdog below, operator gets a clear
	// "which phase" diagnostic for any future hang report.
	process.stderr.write(
		`[subagent:${name}] phase=session.prompt-entered taskLen=${promptTask.length} timeoutMs=${timeoutMs} noOutputMs=${noOutputTimeoutMs}\n`,
	);
	const promptEnteredAt = phaseStartedAt();
	// Note: no phaseWatchdog for session.prompt. The 10s STUCK warning fired
	// spuriously on healthy 30–60s LLM responses (verified by strace: bytes
	// were flowing on the TLS socket the whole time). The noOutputTimeoutMs
	// watchdog below is the event-aware replacement — it resets on every
	// meaningful subagent event (see armNoOutputTimer / isMeaningfulSubagent-
	// OutputEvent) and aborts only after a true silence window. Keep the
	// STUCK watchdog for setup phases (resourceLoader.reload, createAgent-
	// Session, bindExtensions) where 10s of silence really is a hang signal.

	try {
		if (timeoutMs === 0 && noOutputTimeoutMs === 0 && !options?.signal) {
			// Fast path: no watchdog, no cancellation.
			const promptPromise = session.prompt(promptTask, {
				runExtensionHooks: false,
			});
			await promptPromise;
			logPhase("session.prompt-returned", promptEnteredAt);
			cleanup();
			return { ok: true, output: extractFinalOutput(), exitCode: 0 };
		}

		// Build race competitors.
		type RaceResult = {
			timedOut?: true;
			noOutputTimedOut?: true;
			cancelled?: true;
			error?: unknown;
		};
		const competitors: Promise<RaceResult>[] = [];

		if (timeoutMs > 0) {
			competitors.push(
				new Promise<RaceResult>((resolve) => {
					timer = setTimeout(() => {
						void session.abort().catch(() => {});
						resolve({ timedOut: true });
					}, timeoutMs);
				}),
			);
		}

		if (noOutputTimeoutMs > 0) {
			competitors.push(
				new Promise<RaceResult>((resolve) => {
					const armNoOutputTimer = () => {
						if (noOutputTimer) clearTimeout(noOutputTimer);
						noOutputTimer = setTimeout(() => {
							noOutputTimedOut = true;
							void session.abort().catch(() => {});
							resolve({ noOutputTimedOut: true });
						}, noOutputTimeoutMs);
					};
					armNoOutputTimer();
					const previousOnEvent = options?.onEvent;
					options = {
						...options,
						onEvent: (event) => {
							if (!noOutputTimedOut && isMeaningfulSubagentOutputEvent(event)) {
								armNoOutputTimer();
							}
							previousOnEvent?.(event);
						},
					};
				}),
			);
		}

		if (options?.signal) {
			const sig = options.signal;
			if (sig.aborted) {
				void session.abort().catch(() => {});
				cleanup();
				return {
					ok: false,
					output: partialOutput,
					stderr: `${name} cancelled`,
					exitCode: 1,
				};
			}
			competitors.push(
				new Promise<RaceResult>((resolve) => {
					const onAbort = () => {
						void session.abort().catch(() => {});
						resolve({ cancelled: true });
					};
					sig.addEventListener("abort", onAbort, { once: true });
				}),
			);
		}

		const promptPromise = session.prompt(promptTask, {
			runExtensionHooks: false,
		});
		competitors.unshift(
			promptPromise.then(() => {
				logPhase("session.prompt-returned", promptEnteredAt);
				return {} as RaceResult;
			}),
		);

		const result = await Promise.race(competitors);

		cleanup();

		if (result.timedOut) {
			return {
				ok: false,
				output: extractFinalOutput(),
				stderr: `${name} timed out after ${timeoutMs}ms (configure SF_TRIAGE_AGENT_TIMEOUT_MS to extend)`,
				exitCode: 124,
			};
		}

		if (result.noOutputTimedOut) {
			return {
				ok: false,
				output: extractFinalOutput(),
				stderr: `${name} produced no output for ${noOutputTimeoutMs}ms`,
				exitCode: 124,
			};
		}

		if (result.cancelled) {
			return {
				ok: false,
				output: extractFinalOutput(),
				stderr: `${name} cancelled`,
				exitCode: 1,
			};
		}

		// Normal completion.
		return { ok: true, output: extractFinalOutput(), exitCode: 0 };
	} catch (err) {
		cleanup();
		return {
			ok: false,
			output: extractFinalOutput(),
			stderr: err instanceof Error ? err.message : String(err),
			exitCode: 1,
		};
	}
}
