/**
 * swarm-dispatch.js — SwarmDispatchLayer routing UOK DispatchEnvelopes through the agent swarm.
 *
 * Purpose: bridge the UOK dispatch model (envelopes with unitType, workMode, unitId) to the
 * swarm agent topology, routing each envelope to the appropriate role agent and returning
 * a structured result. Provides cached swarm access so the same AgentSwarm instance is
 * reused across dispatch calls within a process.
 *
 * Consumer: UOK kernel dispatch path, parallel orchestrators, and /sf autonomous controller.
 *
 * ## Current state — enqueue only, no runner
 * `_busDispatch` routes an envelope to a role agent's inbox via the MessageBus. It does NOT
 * wait for a response — the `DispatchResult` contains only `messageId` and `targetAgent`,
 * not LLM output. Nothing currently drains agent inboxes and runs LLM calls.
 *
 * This layer is ready to use once `PersistentAgent` gains a runner (see persistent-agent.js
 * module comment for the runner design). At that point `dispatch()` can be extended to
 * optionally block until the runner posts a reply to the bus.
 *
 * Callers outside uok/: none currently. The autonomous loop uses AgentSwarm directly for
 * the sleeptime memory path. Wire this in when building the autonomous orchestrator.
 */

import { debugLog } from "../debug-logger.js";
import {
	formatMemoriesForPrompt,
	getActiveMemoriesRanked,
} from "../memory-store.js";
import { AgentSwarm } from "./agent-swarm.js";
import { MessageBus } from "./message-bus.js";
import { createDefaultSwarm } from "./swarm-roles.js";

/**
 * Delivery verification timeout for bus-backed dispatch acknowledgement.
 *
 * Purpose: bound the time `_busDispatch()` spends proving a target agent inbox
 * can read a just-sent SQLite message before reporting delivery failure.
 *
 * Consumer: SwarmDispatchLayer._busDispatch().
 */
export const BUS_DELIVERY_VERIFY_TIMEOUT_MS = 2000;

const BUS_DELIVERY_VERIFY_BACKOFF_MS = [50, 100, 200, 400, 800];

// Lazily imported to avoid circular deps — agent-runner depends on runSubagent
// which should not be pulled in for pure message-routing code paths.
let _runAgentTurnFn = null;
async function getRunAgentTurn() {
	if (!_runAgentTurnFn) {
		const runner = await import("./agent-runner.js");
		_runAgentTurnFn = runner.runAgentTurn;
	}
	return _runAgentTurnFn;
}

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

function sleep(ms) {
	return new Promise((resolve) => setTimeout(resolve, ms));
}

function refreshAndFindInboxMessage(agent, messageId) {
	agent._inbox.refresh();
	return agent.receive(false).find((message) => message.id === messageId);
}

async function verifyBusDelivery(agent, messageId) {
	const deadline = Date.now() + BUS_DELIVERY_VERIFY_TIMEOUT_MS;
	let attempt = 0;

	while (Date.now() <= deadline) {
		const visible = refreshAndFindInboxMessage(agent, messageId);
		if (visible) {
			return {
				ok: true,
				attempts: attempt + 1,
			};
		}

		const remainingMs = deadline - Date.now();
		if (remainingMs <= 0) break;

		const backoffMs =
			BUS_DELIVERY_VERIFY_BACKOFF_MS[
				Math.min(attempt, BUS_DELIVERY_VERIFY_BACKOFF_MS.length - 1)
			];
		await sleep(Math.min(backoffMs, remainingMs));
		attempt++;
	}

	return {
		ok: false,
		attempts: attempt + 1,
		reason: `message ${messageId} not visible in target inbox after force refresh within ${BUS_DELIVERY_VERIFY_TIMEOUT_MS}ms`,
	};
}

async function runAgentTurnWithOuterWatchdogs(runAgentTurn, agent, opts = {}) {
	const { timeoutMs = 480_000, noOutputTimeoutMs, signal, onEvent } = opts;
	const controller = new AbortController();
	let hardTimer = null;
	let noOutputTimer = null;
	let settled = false;
	let resolveWatchdog = null;
	const watchdogResult = new Promise((resolve) => {
		resolveWatchdog = resolve;
	});

	function abort(reason) {
		if (settled) return;
		try {
			controller.abort(reason);
		} catch {
			controller.abort();
		}
		resolveWatchdog?.({
			turnsProcessed: 0,
			response: null,
			error: reason,
		});
	}

	function cleanup() {
		settled = true;
		if (hardTimer) clearTimeout(hardTimer);
		if (noOutputTimer) clearTimeout(noOutputTimer);
	}

	function armNoOutputTimer() {
		if (!noOutputTimeoutMs || noOutputTimeoutMs <= 0) return;
		if (noOutputTimer) clearTimeout(noOutputTimer);
		noOutputTimer = setTimeout(() => {
			abort(`agent turn produced no output for ${noOutputTimeoutMs}ms`);
		}, noOutputTimeoutMs);
	}

	if (signal) {
		if (signal.aborted) abort("agent turn cancelled");
		else
			signal.addEventListener("abort", () => abort("agent turn cancelled"), {
				once: true,
			});
	}
	if (timeoutMs > 0) {
		hardTimer = setTimeout(() => {
			abort(`agent turn timed out after ${timeoutMs}ms`);
		}, timeoutMs);
	}
	armNoOutputTimer();

	debugLog("swarm-dispatch", {
		phase: "watchdog-about-to-call-runAgentTurn",
		runAgentTurnIsFunction: typeof runAgentTurn === "function",
		agentName: agent?.identity?.name,
		hasOnlyMessageId: !!opts.onlyMessageId,
	});
	const turnResult = runAgentTurn(agent, {
		...opts,
		signal: controller.signal,
		onEvent: (event) => {
			if (isMeaningfulAgentTurnEvent(event)) armNoOutputTimer();
			onEvent?.(event);
		},
	}).catch((err) => {
		if (controller.signal.aborted) {
			return {
				turnsProcessed: 0,
				response: null,
				error:
					typeof controller.signal.reason === "string"
						? controller.signal.reason
						: "agent turn aborted",
			};
		}
		throw err;
	});

	try {
		const result = await Promise.race([turnResult, watchdogResult]);
		cleanup();
		return result;
	} catch (err) {
		cleanup();
		throw err;
	}
}

// Module-level cache keyed by `${basePath}:${swarmName}`
const _cache = new Map();

/**
 * @typedef {object} DispatchEnvelope
 * @property {string} unitId
 * @property {string} unitType    - 'task' | 'slice' | 'milestone' | 'eval' | 'research'
 * @property {string} workMode    - 'build' | 'repair' | 'review' | 'research' | 'coordinate'
 * @property {object} payload     - arbitrary task data
 * @property {number} priority    - 0-10
 * @property {string} scope       - project scope string
 * @property {string} [executorSystemPrompt]  Optional: override the worker agent's
 *   default system prompt. When set, the worker receives this prompt instead of the
 *   generic "swarm agent" prompt. Used by runUnitViaSwarm to inject the autonomous
 *   executor contract (including checkpoint requirement) into the swarm worker session.
 * @property {string[]} [executorTools]  Optional: override the worker agent's tool
 *   filter. When set, runSubagent is configured with this tool name list so the worker
 *   session only exposes the specified tools. Allows callers to grant/restrict tools
 *   per unit type (e.g. ensure "checkpoint" is always available for execute-task units).
 * @property {string} [executorPermissionLevel] Optional: legacy SF permission
 *   extension level used by in-process headless worker sessions.
 * @property {string} [targetAgent] Optional explicit target agent name. When
 *   set, dispatch uses this agent instead of role-derived routing.
 */

/**
 * @typedef {object} DispatchResult
 * @property {string} messageId
 * @property {string} targetAgent
 * @property {string} swarmName
 * @property {DispatchEnvelope} envelope
 */

/**
 * SwarmDispatchLayer — routes DispatchEnvelopes through the agent swarm topology.
 *
 * Purpose: bridge the UOK dispatch model to the swarm agent topology, routing each
 * envelope to the appropriate role agent and returning a structured result.
 *
 * Consumer: UOK kernel dispatch path, parallel orchestrators, and /sf autonomous
 * controller.
 */
export class SwarmDispatchLayer {
	/**
	 * @param {string} basePath - project root used to locate `.sf/sf.db`
	 * @param {object} [options={}]
	 * @param {string} [options.swarmName='default']
	 * @param {boolean} [options.autoCreate=true]
	 */
	constructor(basePath, options = {}) {
		this._basePath = basePath;
		this._swarmName = options.swarmName ?? "default";
		this._autoCreate = options.autoCreate !== false;
		/** @type {AgentSwarm | null} */
		this._swarm = null;
		this._bus = new MessageBus(basePath);
	}

	/**
	 * Return the cached AgentSwarm, loading or creating it as needed.
	 *
	 * Purpose: ensure a single AgentSwarm instance is reused across dispatch
	 * calls within a process to avoid redundant DB round-trips and agent
	 * re-registration on every envelope.
	 *
	 * Consumer: dispatch() and dispatchBatch() before routing envelopes.
	 *
	 * @returns {Promise<AgentSwarm>}
	 */
	async getOrCreateSwarm() {
		if (this._swarm) return this._swarm;

		let swarm = AgentSwarm.load(this._basePath, this._swarmName);

		// load() returns a new empty swarm when no saved registry exists.
		// If it has no agents and autoCreate is on, bootstrap a default topology.
		if (swarm.getAll().length === 0 && this._autoCreate) {
			const result = await createDefaultSwarm(this._basePath, {
				swarmName: this._swarmName,
			});
			swarm = result.swarm;
		}

		this._swarm = swarm;
		return this._swarm;
	}

	/**
	 * Route a single DispatchEnvelope to the appropriate swarm agent.
	 *
	 * Purpose: select the right role agent for the envelope's workMode/unitType,
	 * deliver the payload to that agent's durable inbox, and return a structured
	 * result so callers can track message delivery without knowing the swarm topology.
	 *
	 * Consumer: UOK kernel dispatch path and swarmDispatch() convenience function.
	 *
	 * @param {DispatchEnvelope} envelope
	 * @returns {Promise<DispatchResult>}
	 */
	async dispatch(envelope) {
		return this._busDispatch(envelope);
	}

	/**
	 * Deliver an envelope via the SQLite MessageBus (default in-process path).
	 *
	 * Purpose: preserve the existing bus-backed delivery path so all existing
	 * callers continue working unchanged.
	 *
	 * Consumer: dispatch().
	 *
	 * @param {DispatchEnvelope} envelope
	 * @returns {Promise<DispatchResult>}
	 */
	async _busDispatch(envelope) {
		const swarm = await this.getOrCreateSwarm();
		const target =
			envelope.targetAgent != null
				? swarm.get(envelope.targetAgent)
				: swarm.route(envelope);

		if (!target) {
			throw new Error(
				`SwarmDispatchLayer: no agent available to handle envelope unitType=${envelope.unitType} workMode=${envelope.workMode}`,
			);
		}

		// ── Memory inheritance: snapshot the parent's active memory view ──────
		// Fetch the top-30 ranked memories at dispatch time so the member agent
		// starts with the same knowledge context as the orchestrator. Snapshot
		// semantics: the member sees the world as it was when dispatched and does
		// not receive live updates mid-task. Fail-open: if getActiveMemoriesRanked
		// throws or returns nothing the dispatch still proceeds without context.
		let memoryContext = "";
		try {
			const memories = getActiveMemoriesRanked(30);
			if (memories.length > 0) {
				memoryContext = formatMemoriesForPrompt(memories, 2000, false);
			}
		} catch {
			// Memory context is additive — never block dispatch on a read failure.
		}

		const from = `dispatch:${envelope.scope}:${envelope.unitId}`;
		const to = `agent:${target.identity.name}`;
		const metadata = {
			unitId: envelope.unitId,
			unitType: envelope.unitType,
			workMode: envelope.workMode,
			...(memoryContext ? { memoryContext } : {}),
		};

		const messageId = this._bus.send(from, to, envelope.payload, metadata);
		const delivery = await verifyBusDelivery(target, messageId);

		if (!delivery.ok) {
			return {
				ok: false,
				messageId,
				targetAgent: target.identity.name,
				swarmName: this._swarmName,
				envelope,
				...(memoryContext ? { memoryContext } : {}),
				error: {
					code: "BUS_DELIVERY_VERIFICATION_FAILED",
					messageId,
					reason: delivery.reason,
				},
			};
		}

		return {
			ok: true,
			messageId,
			targetAgent: target.identity.name,
			swarmName: this._swarmName,
			envelope,
			...(memoryContext ? { memoryContext } : {}),
		};
	}

	/**
	 * Dispatch an envelope and block until the target agent has produced a reply.
	 *
	 * Purpose: provide a request/response API for callers that need the agent's
	 * output, not just delivery confirmation. Routes the envelope to the target
	 * agent's inbox (same as dispatch()), then drives runAgentTurn so the agent
	 * processes the inbox in-process, then reads the agent's reply from the bus.
	 *
	 * @param {DispatchEnvelope} envelope
	 * @param {object} [options={}]
	 * @param {number} [options.timeoutMs=480000]  Hard cap for the agent's turn.
	 * @param {number} [options.noOutputTimeoutMs]  Cap for silent model response time.
	 * @param {AbortSignal} [options.signal]
	 * @param {Function} [options.onEvent]  Optional event callback forwarded to runAgentTurn.
	 * @returns {Promise<DispatchResult & { reply: string | null; replyMessageId: string | null }>}
	 */
	async dispatchAndWait(envelope, options = {}) {
		const { timeoutMs = 480_000, noOutputTimeoutMs, signal, onEvent } = options;

		// Step 1: Enqueue via existing _busDispatch
		debugLog("swarm-dispatch", {
			phase: "before-busDispatch",
			unitType: envelope.unitType,
			unitId: envelope.unitId,
		});
		const dispatchResult = await this._busDispatch(envelope);
		debugLog("swarm-dispatch", {
			phase: "after-busDispatch",
			unitType: envelope.unitType,
			unitId: envelope.unitId,
			targetAgent: dispatchResult.targetAgent,
			messageId: dispatchResult.messageId,
		});

		// Step 2: Look up the target agent in the swarm
		const swarm = await this.getOrCreateSwarm();
		const agent = swarm.get(dispatchResult.targetAgent);

		if (!agent) {
			debugLog("swarm-dispatch", {
				phase: "agent-not-found",
				targetAgent: dispatchResult.targetAgent,
			});
			return {
				...dispatchResult,
				reply: null,
				replyMessageId: null,
				error: `dispatchAndWait: target agent "${dispatchResult.targetAgent}" not found in swarm`,
			};
		}
		debugLog("swarm-dispatch", {
			phase: "before-runAgentTurn",
			targetAgent: dispatchResult.targetAgent,
			messageId: dispatchResult.messageId,
		});

		// Step 3: Drive one inbox-processing turn via runAgentTurn (in-process).
		// Pass onlyMessageId so runAgentTurn forces an inbox refresh (catching
		// messages delivered via a different bus instance) and processes ONLY this
		// specific dispatch. Any legitimately queued messages from other senders
		// are left unread — they will be picked up on the next runAgentTurn call.
		//
		// executorSystemPrompt / executorTools: if the envelope carries them (set by
		// runUnitViaSwarm to inject the autonomous executor contract + checkpoint
		// requirement), forward them to runAgentTurn → runHeadlessPrompt → runSubagent
		// so the worker session receives the correct prompt and tool set.
		const executorSystemPrompt = envelope.executorSystemPrompt;
		const executorTools = envelope.executorTools;
		const executorPermissionLevel = envelope.executorPermissionLevel;
		const runAgentTurn = await getRunAgentTurn();
		let turnResult;
		try {
			turnResult = await runAgentTurnWithOuterWatchdogs(runAgentTurn, agent, {
				timeoutMs,
				...(noOutputTimeoutMs ? { noOutputTimeoutMs } : {}),
				signal,
				onlyMessageId: dispatchResult.messageId,
				...(onEvent ? { onEvent } : {}),
				...(executorSystemPrompt
					? { systemPromptOverride: executorSystemPrompt }
					: {}),
				...(executorTools ? { toolsOverride: executorTools } : {}),
				...(executorPermissionLevel
					? { permissionLevel: executorPermissionLevel }
					: {}),
			});
		} catch (err) {
			return {
				...dispatchResult,
				reply: null,
				replyMessageId: null,
				error: `runAgentTurn threw: ${err?.message ?? String(err)}`,
			};
		}

		if (turnResult?.error) {
			return {
				...dispatchResult,
				reply: null,
				replyMessageId: null,
				error: turnResult.error,
			};
		}

		// Step 4: Read the agent's reply back from the bus.
		// agent-runner writes the reply with metadata.replyTo set to the last processed
		// message id; we look for the most recent message from "agent:<targetAgent>"
		// whose replyTo matches our dispatched messageId.
		const fromAgent = `agent:${dispatchResult.targetAgent}`;
		const replyMessage = this._bus.getReplyTo(
			dispatchResult.messageId,
			fromAgent,
		);

		if (!replyMessage) {
			return {
				...dispatchResult,
				reply: null,
				replyMessageId: null,
				error: "no reply",
			};
		}

		// Step 5: Return structured result
		const replyBody =
			typeof replyMessage.body === "string"
				? replyMessage.body
				: JSON.stringify(replyMessage.body);

		return {
			...dispatchResult,
			reply: replyBody,
			replyMessageId: replyMessage.id,
		};
	}

	/**
	 * Route multiple DispatchEnvelopes through the swarm in parallel.
	 *
	 * Purpose: fan-out a batch of work units to the swarm concurrently so
	 * throughput scales with the number of envelopes rather than serializing them.
	 *
	 * Consumer: parallel orchestrators and autonomous dispatch batching loops.
	 *
	 * @param {DispatchEnvelope[]} envelopes
	 * @returns {Promise<DispatchResult[]>}
	 */
	async dispatchBatch(envelopes) {
		return Promise.all(envelopes.map((envelope) => this.dispatch(envelope)));
	}

	/**
	 * Return a structured view of the swarm topology by role.
	 *
	 * Purpose: expose the swarm role snapshot so callers can inspect routing
	 * capacity without reaching into the AgentSwarm directly.
	 *
	 * Consumer: diagnostic tooling, health checks, and /sf swarm status.
	 *
	 * @returns {Promise<{ coordinator: import('./persistent-agent.js').PersistentAgent[], workers: import('./persistent-agent.js').PersistentAgent[], scouts: import('./persistent-agent.js').PersistentAgent[], reviewers: import('./persistent-agent.js').PersistentAgent[], all: import('./persistent-agent.js').PersistentAgent[] }>}
	 */
	async getTopology() {
		const swarm = await this.getOrCreateSwarm();
		return swarm.getTopology();
	}

	/**
	 * Return or create a SwarmDispatchLayer for the given basePath and swarmName.
	 *
	 * Purpose: provide a process-level singleton factory so callers that don't
	 * manage a SwarmDispatchLayer instance directly share the same cached swarm
	 * and avoid redundant initialization on repeated dispatch calls.
	 *
	 * Consumer: swarmDispatch() convenience function and any caller that wants
	 * a default dispatch layer without explicit lifecycle management.
	 *
	 * @param {string} basePath
	 * @param {object} [options={}]
	 * @param {string} [options.swarmName='default']
	 * @param {boolean} [options.autoCreate=true]
	 * @returns {SwarmDispatchLayer}
	 */
	static getOrCreate(basePath, options = {}) {
		const key = `${basePath}:${options.swarmName ?? "default"}`;
		if (!_cache.has(key)) {
			_cache.set(key, new SwarmDispatchLayer(basePath, options));
		}
		return _cache.get(key);
	}
}

/**
 * Route a single DispatchEnvelope through the default swarm for basePath.
 *
 * Purpose: one-liner entry point for dispatch callers that don't need to manage
 * a SwarmDispatchLayer instance directly.
 *
 * Consumer: UOK kernel dispatch integration, test helpers.
 *
 * @param {string} basePath
 * @param {DispatchEnvelope} envelope
 * @returns {Promise<DispatchResult>}
 */
export async function swarmDispatch(basePath, envelope) {
	return SwarmDispatchLayer.getOrCreate(basePath).dispatch(envelope);
}

/**
 * Dispatch an envelope and block until the target agent has produced a reply.
 *
 * Purpose: one-liner entry point for request/response dispatch callers that
 * don't need to manage a SwarmDispatchLayer instance directly.
 *
 * Consumer: orchestrator flows that need the agent's output in-line.
 *
 * @param {string} basePath
 * @param {DispatchEnvelope} envelope
 * @param {object} [options]
 * @param {number} [options.timeoutMs=480000]
 * @param {AbortSignal} [options.signal]
 * @param {Function} [options.onEvent]  Optional event callback forwarded to the agent turn.
 * @returns {Promise<DispatchResult & { reply: string | null; replyMessageId: string | null }>}
 */
export async function swarmDispatchAndWait(basePath, envelope, options) {
	return SwarmDispatchLayer.getOrCreate(basePath).dispatchAndWait(
		envelope,
		options,
	);
}
