/**
 * Tests for the SF_SUBAGENT_VIA_SWARM feature flag path in runSingleAgent /
 * runSingleAgentViaSwarm.
 *
 * Strategy: vi.mock the swarm-dispatch module so no real MessageBus / AgentSwarm
 * is created, then call runSingleAgent (exported for testing) directly.
 */
import assert from "node:assert/strict";
import { afterEach, beforeEach, test, vi } from "vitest";

// ─── Mock swarm-dispatch before the module under test is imported ─────────────
// vi.mock hoists this call to the top of the file so the mock is in place when
// `../subagent/index.js` is first resolved.
vi.mock("../uok/swarm-dispatch.js", () => {
	return {
		swarmDispatchAndWait: vi.fn(),
		swarmDispatch: vi.fn(),
		SwarmDispatchLayer: class {
			dispatch() {}
			dispatchAndWait() {}
		},
	};
});

// ─── Mock journal.js ──────────────────────────────────────────────────────────
vi.mock("../journal.js", () => ({
	emitJournalEvent: vi.fn(),
	queryJournal: vi.fn(async () => []),
}));

// Import the mock handles and the module under test AFTER vi.mock is declared.
const { swarmDispatchAndWait } = await import("../uok/swarm-dispatch.js");
const { emitJournalEvent } = await import("../journal.js");

// runSingleAgent is exported for testing only. (The swarm path is exercised
// indirectly via runSingleAgent when SF_SUBAGENT_VIA_SWARM is set.)
const { runSingleAgent } = await import("../subagent/index.js");

// ─── Minimal agent fixture ─────────────────────────────────────────────────────
const DEFAULT_CWD = "/tmp/sf-test";

function makeAgent(overrides = {}) {
	return {
		name: "worker",
		displayName: "Worker",
		source: "builtin",
		systemPrompt: "You are a helpful worker agent.",
		model: "claude-haiku-4.5",
		tools: ["read", "bash"],
		promptParts: [],
		conflictsWith: [],
		...overrides,
	};
}

function makeAgents(overrides = {}) {
	return [makeAgent(overrides)];
}

// ─── Helpers ───────────────────────────────────────────────────────────────────
const NOOP_MAKE_DETAILS = () => () => ({});

function makeDeterministicSwarmResult(overrides = {}) {
	return {
		messageId: "msg-001",
		targetAgent: "worker-1",
		swarmName: "default",
		reply: "Task completed successfully.",
		replyMessageId: "reply-001",
		error: undefined,
		...overrides,
	};
}

// ─── Setup / teardown ─────────────────────────────────────────────────────────
beforeEach(() => {
	vi.resetAllMocks();
	delete process.env.SF_SUBAGENT_VIA_SWARM;
});

afterEach(() => {
	delete process.env.SF_SUBAGENT_VIA_SWARM;
});

// ─── Tests ─────────────────────────────────────────────────────────────────────

test("SF_SUBAGENT_VIA_SWARM=0 → runSubagent path, swarmDispatchAndWait NOT called", async () => {
	// With the flag explicitly disabled, runSingleAgent should NOT touch
	// swarmDispatchAndWait. It will try to call runSubagent (from
	// @singularity-forge/coding-agent), which will fail inside the test
	// environment — we just need to confirm the swarm path was NOT taken.
	process.env.SF_SUBAGENT_VIA_SWARM = "0";
	const agents = makeAgents();

	try {
		await runSingleAgent(
			DEFAULT_CWD,
			agents,
			"worker",
			"do a thing",
			undefined,
			1,
			undefined,
			undefined,
			NOOP_MAKE_DETAILS,
			undefined,
			undefined,
		);
	} catch {
		// Expected in this environment — runSubagent has no real backend.
		// The assertion below is what matters: the swarm path must not run.
	}

	// swarmDispatchAndWait must NOT have been called regardless of whether
	// runSubagent threw.
	assert.equal(
		swarmDispatchAndWait.mock.calls.length,
		0,
		"swarmDispatchAndWait should not be called when flag is explicitly disabled",
	);
});

test("SF_SUBAGENT_VIA_SWARM=1 → swarmDispatchAndWait called with correct envelope shape", async () => {
	process.env.SF_SUBAGENT_VIA_SWARM = "1";
	swarmDispatchAndWait.mockResolvedValueOnce(makeDeterministicSwarmResult());

	const agents = makeAgents();
	await runSingleAgent(
		DEFAULT_CWD,
		agents,
		"worker",
		"implement the feature",
		undefined,
		1,
		undefined,
		undefined,
		NOOP_MAKE_DETAILS,
		undefined,
		undefined,
	);

	assert.equal(
		swarmDispatchAndWait.mock.calls.length,
		1,
		"swarmDispatchAndWait should be called exactly once",
	);

	const [calledBasePath, calledEnvelope, calledOptions] =
		swarmDispatchAndWait.mock.calls[0];

	// basePath should be defaultCwd when no cwd override is given
	assert.equal(calledBasePath, DEFAULT_CWD);

	// scope must be "subagent"
	assert.equal(calledEnvelope.scope, "subagent");

	// unitType must be "delegate"
	assert.equal(calledEnvelope.unitType, "delegate");

	// payload must match the task string
	assert.equal(calledEnvelope.payload, "implement the feature");

	// executorSystemPrompt must be non-empty (composed from agent.systemPrompt)
	assert.ok(
		typeof calledEnvelope.executorSystemPrompt === "string" &&
			calledEnvelope.executorSystemPrompt.length > 0,
		"executorSystemPrompt should be a non-empty string",
	);

	// executorTools must match the agent's tool list
	assert.deepEqual(calledEnvelope.executorTools, ["read", "bash"]);

	// unitId must start with agent name
	assert.ok(
		calledEnvelope.unitId.startsWith("worker-"),
		`unitId should start with "worker-", got: ${calledEnvelope.unitId}`,
	);

	// options must include a signal (chained AbortSignal)
	assert.ok(
		"signal" in calledOptions || calledOptions.signal === undefined,
		"options should have a signal property",
	);

	// onEvent callback must be provided
	assert.equal(typeof calledOptions.onEvent, "function");
});

test("SF_SUBAGENT_VIA_SWARM=true → same swarm path as =1", async () => {
	process.env.SF_SUBAGENT_VIA_SWARM = "true";
	swarmDispatchAndWait.mockResolvedValueOnce(makeDeterministicSwarmResult());

	const agents = makeAgents();
	await runSingleAgent(
		DEFAULT_CWD,
		agents,
		"worker",
		"task",
		undefined,
		1,
		undefined,
		undefined,
		NOOP_MAKE_DETAILS,
		undefined,
		undefined,
	);

	assert.equal(swarmDispatchAndWait.mock.calls.length, 1);
});

test("swarm success → return shape matches in-process path", async () => {
	process.env.SF_SUBAGENT_VIA_SWARM = "1";
	swarmDispatchAndWait.mockResolvedValueOnce(makeDeterministicSwarmResult());

	const agents = makeAgents();
	const result = await runSingleAgent(
		DEFAULT_CWD,
		agents,
		"worker",
		"build the thing",
		undefined,
		2,
		undefined,
		undefined,
		NOOP_MAKE_DETAILS,
		undefined,
		undefined,
	);

	// Must have all the same top-level keys as the in-process path
	assert.equal(result.agent, "worker");
	assert.equal(result.agentSource, "builtin");
	assert.equal(result.task, "build the thing");
	assert.equal(result.exitCode, 0);
	assert.equal(result.step, 2);
	assert.ok(Array.isArray(result.messages), "messages must be an array");
	assert.equal(typeof result.stderr, "string");
	assert.ok(result.usage, "usage must be present");
	assert.equal(typeof result.usage.input, "number");
	assert.equal(typeof result.usage.output, "number");
	assert.equal(typeof result.usage.turns, "number");

	// The swarm reply text should appear as a synthesized assistant message
	// because no onEvent calls were fired with message_end events.
	assert.equal(result.messages.length, 1);
	assert.equal(result.messages[0].role, "assistant");
	assert.ok(
		result.messages[0].content.some(
			(c) => c.type === "text" && c.text === "Task completed successfully.",
		),
	);
});

test("swarm returns error → exitCode=1 and errorMessage set", async () => {
	process.env.SF_SUBAGENT_VIA_SWARM = "1";
	swarmDispatchAndWait.mockResolvedValueOnce({
		...makeDeterministicSwarmResult(),
		reply: null,
		error: "agent runner failed",
	});

	const agents = makeAgents();
	const result = await runSingleAgent(
		DEFAULT_CWD,
		agents,
		"worker",
		"task",
		undefined,
		1,
		undefined,
		undefined,
		NOOP_MAKE_DETAILS,
		undefined,
		undefined,
	);

	assert.equal(result.exitCode, 1);
	assert.ok(result.errorMessage?.includes("agent runner failed"));
});

test("onEvent forwards events to processSubagentEventLine via onUpdate callback", async () => {
	process.env.SF_SUBAGENT_VIA_SWARM = "1";

	// Capture the onEvent callback from the dispatch call and fire a message_end
	// event through it; verify that the emitUpdate path fires with messages populated.
	let capturedOnEvent = null;
	swarmDispatchAndWait.mockImplementationOnce(
		async (_basePath, _envelope, options) => {
			capturedOnEvent = options.onEvent;
			// Fire a synthetic message_end event through the callback
			if (capturedOnEvent) {
				capturedOnEvent({
					type: "message_end",
					message: {
						role: "assistant",
						content: [{ type: "text", text: "hello from agent" }],
						usage: {
							input: 10,
							output: 20,
							cacheRead: 0,
							cacheWrite: 0,
							cost: { total: 0 },
							totalTokens: 30,
						},
						model: "claude-haiku-4.5",
						stopReason: "stop",
					},
				});
			}
			return makeDeterministicSwarmResult({ reply: "hello from agent" });
		},
	);

	let updateFired = false;
	const onUpdate = () => {
		updateFired = true;
	};

	const agents = makeAgents();
	const result = await runSingleAgent(
		DEFAULT_CWD,
		agents,
		"worker",
		"task",
		undefined,
		1,
		undefined,
		onUpdate,
		() => () => ({}),
		undefined,
		undefined,
	);

	// The message_end event should have been processed into currentResult.messages
	assert.ok(
		result.messages.some(
			(m) =>
				m.role === "assistant" &&
				m.content?.some(
					(c) => c.type === "text" && c.text === "hello from agent",
				),
		),
		"message from onEvent should appear in result.messages",
	);

	assert.ok(updateFired, "onUpdate/emitUpdate should have been called");
	// usage should reflect the event-based counters
	assert.equal(result.usage.input, 10);
	assert.equal(result.usage.output, 20);
	assert.equal(result.usage.turns, 1);
});

test("unknown agent → early return without touching swarm", async () => {
	process.env.SF_SUBAGENT_VIA_SWARM = "1";

	const agents = makeAgents();
	const result = await runSingleAgent(
		DEFAULT_CWD,
		agents,
		"nonexistent-agent",
		"task",
		undefined,
		1,
		undefined,
		undefined,
		NOOP_MAKE_DETAILS,
		undefined,
		undefined,
	);

	assert.equal(result.exitCode, 1);
	assert.ok(result.stderr.includes("Unknown agent"));
	assert.equal(swarmDispatchAndWait.mock.calls.length, 0);
});

test("cwd override is forwarded as basePath to swarmDispatchAndWait", async () => {
	process.env.SF_SUBAGENT_VIA_SWARM = "1";
	swarmDispatchAndWait.mockResolvedValueOnce(makeDeterministicSwarmResult());

	const agents = makeAgents();
	const customCwd = "/custom/project/path";
	await runSingleAgent(
		DEFAULT_CWD,
		agents,
		"worker",
		"task",
		customCwd, // cwd override
		1,
		undefined,
		undefined,
		NOOP_MAKE_DETAILS,
		undefined,
		undefined,
	);

	const [calledBasePath] = swarmDispatchAndWait.mock.calls[0];
	assert.equal(calledBasePath, customCwd);
});

test("AbortSignal is passed through to swarmDispatchAndWait options", async () => {
	process.env.SF_SUBAGENT_VIA_SWARM = "1";
	swarmDispatchAndWait.mockResolvedValueOnce(makeDeterministicSwarmResult());

	const controller = new AbortController();
	const agents = makeAgents();
	await runSingleAgent(
		DEFAULT_CWD,
		agents,
		"worker",
		"task",
		undefined,
		1,
		controller.signal,
		undefined,
		NOOP_MAKE_DETAILS,
		undefined,
		undefined,
	);

	const [, , calledOptions] = swarmDispatchAndWait.mock.calls[0];
	// The chained controller's signal is passed (not the raw caller signal,
	// but it must be an AbortSignal instance).
	assert.ok(
		calledOptions.signal instanceof AbortSignal,
		"signal must be an AbortSignal",
	);
});

// ─── swarm-dispatch journal events ────────────────────────────────────────────

test("swarm success → emits swarm-dispatch journal event with outcome=agent-reply", async () => {
	process.env.SF_SUBAGENT_VIA_SWARM = "1";
	swarmDispatchAndWait.mockResolvedValueOnce(makeDeterministicSwarmResult());

	const agents = makeAgents();
	await runSingleAgent(
		DEFAULT_CWD,
		agents,
		"worker",
		"implement the feature",
		undefined,
		1,
		undefined,
		undefined,
		NOOP_MAKE_DETAILS,
		undefined,
		undefined,
	);

	const calls = emitJournalEvent.mock.calls;
	assert.ok(calls.length > 0, "emitJournalEvent should have been called");
	const swarmCall = calls.find((c) => c[1]?.eventType === "swarm-dispatch");
	assert.ok(swarmCall, "a swarm-dispatch event must have been emitted");
	const [calledBasePath, entry] = swarmCall;
	assert.equal(calledBasePath, DEFAULT_CWD);
	assert.match(entry.ts, /^\d{4}-\d{2}-\d{2}T/);
	assert.equal(entry.data.unitType, "delegate");
	assert.ok(
		entry.data.unitId.startsWith("worker-"),
		`unitId should start with "worker-", got: ${entry.data.unitId}`,
	);
	assert.equal(entry.data.targetAgent, "worker-1");
	assert.equal(entry.data.workMode, "build");
	assert.equal(typeof entry.data.toolCallCount, "number");
	assert.equal(entry.data.outcome, "agent-reply");
	assert.equal(entry.data.via, "subagent-extension");
	assert.equal(entry.data.agentName, "worker");
});

test("swarm returns error → emits swarm-dispatch event with outcome=no-reply", async () => {
	process.env.SF_SUBAGENT_VIA_SWARM = "1";
	swarmDispatchAndWait.mockResolvedValueOnce({
		...makeDeterministicSwarmResult(),
		reply: null,
		error: "agent runner failed",
	});

	const agents = makeAgents();
	await runSingleAgent(
		DEFAULT_CWD,
		agents,
		"worker",
		"task",
		undefined,
		1,
		undefined,
		undefined,
		NOOP_MAKE_DETAILS,
		undefined,
		undefined,
	);

	const swarmCall = emitJournalEvent.mock.calls.find(
		(c) => c[1]?.eventType === "swarm-dispatch",
	);
	assert.ok(swarmCall, "a swarm-dispatch event must have been emitted");
	const [, entry] = swarmCall;
	assert.equal(entry.data.outcome, "no-reply");
	assert.ok(entry.data.error?.includes("agent runner failed"));
	assert.equal(entry.data.via, "subagent-extension");
});

test("swarmDispatchAndWait throws → emits swarm-dispatch event with outcome=error", async () => {
	process.env.SF_SUBAGENT_VIA_SWARM = "1";
	swarmDispatchAndWait.mockRejectedValueOnce(new Error("network timeout"));

	const agents = makeAgents();
	await runSingleAgent(
		DEFAULT_CWD,
		agents,
		"worker",
		"task",
		undefined,
		1,
		undefined,
		undefined,
		NOOP_MAKE_DETAILS,
		undefined,
		undefined,
	);

	const swarmCall = emitJournalEvent.mock.calls.find(
		(c) => c[1]?.eventType === "swarm-dispatch",
	);
	assert.ok(
		swarmCall,
		"a swarm-dispatch event must have been emitted on throw",
	);
	const [, entry] = swarmCall;
	assert.equal(entry.data.outcome, "error");
	assert.ok(entry.data.error?.includes("network timeout"));
});

test("journal write failure does not break subagent dispatch (fail-open)", async () => {
	process.env.SF_SUBAGENT_VIA_SWARM = "1";
	swarmDispatchAndWait.mockResolvedValueOnce(makeDeterministicSwarmResult());
	emitJournalEvent.mockImplementationOnce(() => {
		throw new Error("disk full");
	});

	const agents = makeAgents();
	const result = await runSingleAgent(
		DEFAULT_CWD,
		agents,
		"worker",
		"task",
		undefined,
		1,
		undefined,
		undefined,
		NOOP_MAKE_DETAILS,
		undefined,
		undefined,
	);

	// Must still succeed even though journal threw
	assert.equal(result.exitCode, 0);
});
