/**
 * agent-runner.js — LLM execution runner for PersistentAgent swarm agents.
 *
 * Purpose: bridge the gap between PersistentAgent (passive inbox/memory store)
 * and actual LLM execution. Reads pending messages, assembles prompts, dispatches
 * to LLM via in-process `runSubagent`, writes replies back to the MessageBus, and
 * manages context window.
 *
 * Integration: imported lazily by `AgentSwarm.run()` when `opts.enableLLM` is true,
 * and directly by `auto/loop.js` for sleeptime consolidation. Keeps PersistentAgent
 * itself free of session-management logic.
 *
 * Consumer: AgentSwarm.run() orchestrator and autonomous dispatch paths.
 */

import { runSubagent } from "@singularity-forge/coding-agent";
import { debugLog } from "../debug-logger.js";

const DEFAULT_MAX_CONTEXT_TURNS = 10;
const DEFAULT_MAX_TURNS_PER_RUN = 5;
const DEFAULT_RUNNER_TIMEOUT_MS = 120_000;
const DEFAULT_NO_OUTPUT_TIMEOUT_MS = 180_000;
const DEFAULT_POLL_INTERVAL_MS = 1_000;

/**
 * Assemble a prompt from core memory blocks + recent inbox messages.
 */
function buildAgentPrompt(agent, messages) {
	const blocks = agent.getContextBlocks();
	const lines = [];

	if (blocks) {
		lines.push(blocks, "");
	}

	lines.push("## Instructions", "");
	lines.push(
		"You are a persistent agent in a multi-agent swarm. Process the incoming messages and produce a response.",
	);
	lines.push("Be concise. Use tools when needed. Return structured output.");
	lines.push("");

	if (messages.length > 0) {
		lines.push("## Incoming Messages", "");
		for (const msg of messages) {
			const from = msg.from?.replace(/^agent:/, "") ?? "unknown";
			lines.push(
				`**${from}**: ${typeof msg.body === "string" ? msg.body : JSON.stringify(msg.body)}`,
			);
		}
		lines.push("");
	}

	lines.push("## Your Response");
	return lines.join("\n");
}

/**
 * Execute a prompt via in-process runSubagent.
 * Returns the LLM response text.
 *
 * @param {string} basePath
 * @param {string} prompt
 * @param {number} [timeoutMs]
 * @param {object} [opts]
 * @param {Function} [opts.onEvent]  Optional event callback forwarded to runSubagent.
 * @param {number} [opts.noOutputTimeoutMs]  Abort a silent model call after this long.
 * @param {string} [opts.systemPromptOverride]  Override the default swarm-agent system prompt.
 *   When set (e.g. from envelope.executorSystemPrompt), this replaces the generic
 *   "persistent agent in a swarm" prompt so the worker receives the full autonomous
 *   executor contract including the checkpoint requirement.
 * @param {string[]} [opts.toolsOverride]  Override the default tool filter passed to
 *   runSubagent. When set (e.g. from envelope.executorTools), the worker's session is
 *   filtered to this specific set of tool names before the prompt is sent.
 * @param {string} [opts.permissionLevel] Legacy permission level used to initialize
 *   SF's permission extension in the isolated headless session.
 */
async function runHeadlessPrompt(
	basePath,
	prompt,
	timeoutMs = DEFAULT_RUNNER_TIMEOUT_MS,
	opts = {},
) {
	const {
		onEvent,
		noOutputTimeoutMs = DEFAULT_NO_OUTPUT_TIMEOUT_MS,
		systemPromptOverride,
		toolsOverride,
		permissionLevel,
	} = opts;
	const result = await runSubagent(
		{
			systemPrompt:
				systemPromptOverride ??
				"You are a persistent agent in a multi-agent swarm. Process the incoming messages and produce a structured response.",
			cwd: basePath,
			name: "swarm-agent",
			...(permissionLevel ? { permissionLevel } : {}),
			...(toolsOverride && toolsOverride.length > 0
				? { tools: toolsOverride }
				: {}),
		},
		prompt,
		{ timeoutMs, noOutputTimeoutMs, ...(onEvent ? { onEvent } : {}) },
	);

	if (!result.ok) {
		if (result.exitCode === 124) {
			throw new Error(
				result.stderr ?? `Agent runner timed out after ${timeoutMs}ms`,
			);
		}
		throw new Error(
			`sf headless failed: ${result.stderr || result.output || "unknown error"}`,
		);
	}

	return result.output;
}

/**
 * Run a single turn for a PersistentAgent: read inbox, build prompt, call LLM, reply.
 *
 * @param {PersistentAgent} agent
 * @param {object} [opts]
 * @param {number} [opts.maxContextTurns=10]
 * @param {number} [opts.timeoutMs=120000]
 * @param {string} [opts.onlyMessageId]
 *   When set, the agent forces an inbox refresh from SQLite and processes ONLY
 *   the message with this id, leaving any other unread messages untouched.
 *   This makes each dispatchAndWait call surgical — the reply will target the
 *   specified messageId exactly, and legitimately queued messages from other
 *   senders remain unread and available for the next turn.
 * @param {Function} [opts.onEvent]  Optional event callback forwarded to runHeadlessPrompt.
 * @param {number} [opts.noOutputTimeoutMs]  Abort a silent model call after this long.
 * @param {string} [opts.systemPromptOverride]  Override the worker's system prompt.
 *   Forwarded to runHeadlessPrompt so executor-specific contracts (e.g. the autonomous
 *   checkpoint requirement) reach the LLM session unchanged.
 * @param {string[]} [opts.toolsOverride]  Override the worker's tool filter.
 *   Forwarded to runHeadlessPrompt so executor-specific tool sets (e.g. including
 *   "checkpoint") are applied in the runSubagent config.
 * @param {string} [opts.permissionLevel] Legacy permission level forwarded to
 *   runHeadlessPrompt so permission hooks match the parent SF profile.
 * @returns {Promise<{turnsProcessed: number, response: string|null}>}
 */
export async function runAgentTurn(agent, opts = {}) {
	const {
		maxContextTurns = DEFAULT_MAX_CONTEXT_TURNS,
		timeoutMs = DEFAULT_RUNNER_TIMEOUT_MS,
		noOutputTimeoutMs = DEFAULT_NO_OUTPUT_TIMEOUT_MS,
		onlyMessageId,
		onEvent,
		systemPromptOverride,
		toolsOverride,
		permissionLevel,
	} = opts;

	debugLog("agent-runner", {
		event: "runAgentTurn-enter",
		agentName: agent.identity?.name,
		onlyMessageId: onlyMessageId ?? null,
	});
	// When onlyMessageId is set, force-refresh the inbox from SQLite so that
	// messages delivered via a different MessageBus instance (i.e. the
	// SwarmDispatchLayer's bus) are visible even within the 30s cache window.
	// This is the root cause of Bug 1: the agent's in-memory inbox is stale on
	// a second dispatch because INBOX_REFRESH_INTERVAL_MS has not elapsed.
	if (onlyMessageId) {
		debugLog("agent-runner", { event: "before-inbox-refresh", onlyMessageId });
		agent._inbox.refresh();
		debugLog("agent-runner", { event: "after-inbox-refresh", onlyMessageId });
	}

	// When onlyMessageId is provided, isolate this message for surgical processing.
	// Other unread messages are left untouched so they can be processed in a
	// subsequent turn. Without this isolation a stale message from an earlier
	// dispatch could be included and its replyTo would mismatch the caller's
	// expected dispatchResult.messageId.
	let messages;
	if (onlyMessageId) {
		// #sf-mp8g4rcd-w01tkh fix: bounded retry on missing-message.
		//
		// Symptom: MessageBus dispatch returns success and the SF kernel marks
		// the unit as dispatched, but the consumer agent's in-memory inbox
		// doesn't yet see the new message (likely because the swarm-dispatch
		// bus instance and this agent's inbox bus instance are different
		// objects with their own SQLite read caches). Previously this triggered
		// a silent early-return with {turnsProcessed:0,response:null} → caller
		// swallowed it as "no work" → LLM never ran → autonomous-loop froze →
		// 59 idle-halt feedback entries piled up.
		//
		// Fix: retry the inbox refresh with exponential backoff (50, 100, 200,
		// 400, 800 ms; ~1.55s total). If found mid-retry, log a recovery event
		// and proceed normally. If still missing after the last retry, throw a
		// loud error — the caller (swarm-dispatch.js:462-484) wraps this in
		// try/catch and surfaces it as turnResult.error, so the autonomous
		// loop sees a real failure instead of phantom progress.
		const MAX_INBOX_REFRESH_RETRIES = 5;
		const INBOX_REFRESH_BACKOFFS_MS = [50, 100, 200, 400, 800];
		let allMessages = agent.receive(false);
		let target = allMessages.find((m) => m.id === onlyMessageId && !m.read);
		let retryAttempt = 0;
		let totalBackoffMs = 0;
		while (!target && retryAttempt < MAX_INBOX_REFRESH_RETRIES) {
			const backoffMs = INBOX_REFRESH_BACKOFFS_MS[retryAttempt];
			retryAttempt++;
			await new Promise((resolve) => setTimeout(resolve, backoffMs));
			totalBackoffMs += backoffMs;
			agent._inbox.refresh();
			allMessages = agent.receive(false);
			target = allMessages.find((m) => m.id === onlyMessageId && !m.read);
			if (target) {
				debugLog("agent-runner", {
					event: "missing-message-recovered",
					agentName: agent.identity?.name,
					onlyMessageId,
					retryAttempt,
					totalBackoffMs,
				});
			}
		}
		if (!target) {
			debugLog("agent-runner", {
				event: "missing-message-loud-failure",
				phase: "target-not-found-after-retries",
				agentName: agent.identity?.name,
				onlyMessageId,
				retryAttempt,
				totalBackoffMs,
				inboxSize: allMessages.length,
				inboxIds: allMessages.map((m) => ({ id: m.id, read: m.read })),
			});
			throw new Error(
				`agent-runner: message ${onlyMessageId} not found in inbox of "${agent.identity?.name ?? "?"}" ` +
					`after ${MAX_INBOX_REFRESH_RETRIES} refresh retries (~${totalBackoffMs}ms total). ` +
					`MessageBus dispatch did not deliver to consumer inbox. ` +
					`See sf-mp8g4rcd-w01tkh in self-feedback for root-cause analysis ` +
					`(likely separate bus instances / SQLite read-cache lag).`,
			);
		}
		messages = [target];
	} else {
		messages = agent.receive(true);
	}

	if (messages.length === 0) {
		debugLog("agent-runner", {
			event: "silent-empty-inbox",
			phase: "no-messages",
			agentName: agent.identity?.name,
			onlyMessageId: onlyMessageId ?? null,
		});
		return { turnsProcessed: 0, response: null };
	}

	// Mark messages as read before processing so a crash doesn't re-process them
	for (const msg of messages) {
		agent.markRead(msg.id);
	}

	// Limit context window
	const contextMessages = messages.slice(-maxContextTurns);
	const prompt = buildAgentPrompt(agent, contextMessages);

	let response;
	try {
		response = await runHeadlessPrompt(agent._basePath, prompt, timeoutMs, {
			onEvent,
			noOutputTimeoutMs,
			...(systemPromptOverride ? { systemPromptOverride } : {}),
			...(toolsOverride ? { toolsOverride } : {}),
			...(permissionLevel ? { permissionLevel } : {}),
		});
	} catch (err) {
		// On failure, write error back to bus so sender knows
		agent._bus.send(
			`agent:${agent.identity.name}`,
			messages[0]?.from ?? "agent:coordinator",
			{ error: err.message, original: messages[0]?.body },
			{ replyTo: messages[0]?.id, type: "error" },
		);
		return { turnsProcessed: 0, response: null, error: err.message };
	}

	// Reply to the most recent message (or broadcast if no clear target)
	const target = messages[messages.length - 1]?.from ?? `agent:coordinator`;
	const replyId = agent._bus.send(
		`agent:${agent.identity.name}`,
		target,
		response,
		{ replyTo: messages[messages.length - 1]?.id, type: "response" },
	);

	return { turnsProcessed: messages.length, response, replyId };
}

/**
 * Continuously poll and run an agent until no more unread messages or max turns reached.
 *
 * @param {PersistentAgent} agent
 * @param {object} [opts]
 * @param {number} [opts.maxTurns=5]
 * @param {number} [opts.pollIntervalMs=1000]
 * @param {number} [opts.timeoutMs=120000]
 * @returns {Promise<{totalTurns: number, stoppedReason: string}>}
 */
export async function runAgentLoop(agent, opts = {}) {
	const {
		maxTurns = DEFAULT_MAX_TURNS_PER_RUN,
		pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
		timeoutMs = DEFAULT_RUNNER_TIMEOUT_MS,
	} = opts;

	let totalTurns = 0;
	const startTime = Date.now();

	while (totalTurns < maxTurns) {
		if (Date.now() - startTime > timeoutMs) {
			return { totalTurns, stoppedReason: "timeout" };
		}

		const result = await runAgentTurn(agent, {
			timeoutMs: Math.min(timeoutMs, 60_000),
		});
		if (result.turnsProcessed === 0) {
			return { totalTurns, stoppedReason: "no_messages" };
		}

		totalTurns += result.turnsProcessed;

		// Brief pause between turns to avoid hammering the LLM
		if (totalTurns < maxTurns) {
			await new Promise((r) => setTimeout(r, pollIntervalMs));
		}
	}

	return { totalTurns, stoppedReason: "max_turns" };
}

/**
 * Run a swarm turn: each agent in the swarm processes its inbox once.
 *
 * @param {AgentSwarm} swarm
 * @param {object} [opts]
 * @returns {Promise<Array<{agentName: string, result: object}>>}
 */
export async function runSwarmTurn(swarm, opts = {}) {
	const agents = swarm.getAll();
	const results = [];

	for (const agent of agents) {
		const result = await runAgentTurn(agent, opts);
		results.push({ agentName: agent.identity.name, result });
	}

	return results;
}
