/**
 * RPC Protocol v2 test suite.
 *
 * Tests v1 backward compatibility, v2 init handshake, protocol locking,
 * v2 feature type shapes, and RpcClient command serialization against
 * mock child processes using PassThrough streams.
 */

import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, it } from "vitest";
import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js";
import {
	buildRpcLaunchSpec,
	collectRpcDescendantPids,
	resolveRpcInitTimeoutMs,
	shouldDetachRpcChild,
	signalRpcProcessTree,
} from "./rpc-client.js";
import type {
	RpcCommand,
	RpcCostUpdateEvent,
	RpcExecutionCompleteEvent,
	RpcInitResult,
	RpcProtocolVersion,
	RpcResponse,
	RpcSessionState,
	RpcV2Event,
} from "./rpc-types.js";

// ============================================================================
// Helpers
// ============================================================================

/** Collect JSONL output lines from a stream */
function collectLines(stream: PassThrough): {
	lines: unknown[];
	detach: () => void;
} {
	const lines: unknown[] = [];
	const detach = attachJsonlLineReader(stream, (line) => {
		try {
			lines.push(JSON.parse(line));
		} catch {
			// skip non-JSON lines
		}
	});
	return { lines, detach };
}

/** Write a command as JSONL to a writable stream and wait for drain */
function writeLine(stream: PassThrough, obj: unknown): void {
	stream.write(serializeJsonLine(obj));
}

/**
 * Create a mock "child process" with piped stdin/stdout.
 * clientStdin  → data flows into the "server" (from the client's perspective, this is what the client writes to)
 * clientStdout ← data flows out of the "server" (from the client's perspective, this is what the client reads from)
 *
 * The test acts as the "server": read from clientStdin, write to clientStdout.
 */
function createMockProcess() {
	// Client writes to this → server reads from it
	const clientStdin = new PassThrough();
	// Server writes to this → client reads from it
	const clientStdout = new PassThrough();

	return { clientStdin, clientStdout };
}

/** Wait a tick for async handlers to process */
function tick(ms = 10): Promise<void> {
	return new Promise((resolve) => setTimeout(resolve, ms));
}

// ============================================================================
// JSONL utilities
// ============================================================================

describe("JSONL utilities", () => {
	it("serializeJsonLine produces newline-terminated JSON", () => {
		const result = serializeJsonLine({ type: "test", value: 42 });
		assert.equal(result, '{"type":"test","value":42}\n');
	});

	it("serializeJsonLine handles nested objects", () => {
		const result = serializeJsonLine({ a: { b: [1, 2, 3] } });
		assert.ok(result.endsWith("\n"));
		const parsed = JSON.parse(result.trim());
		assert.deepEqual(parsed, { a: { b: [1, 2, 3] } });
	});

	it("attachJsonlLineReader splits on LF only", async () => {
		const stream = new PassThrough();
		const { lines, detach } = collectLines(stream);

		stream.write('{"a":1}\n{"b":2}\n');
		await tick();

		assert.equal(lines.length, 2);
		assert.deepEqual(lines[0], { a: 1 });
		assert.deepEqual(lines[1], { b: 2 });
		detach();
	});

	it("attachJsonlLineReader handles partial writes", async () => {
		const stream = new PassThrough();
		const { lines, detach } = collectLines(stream);

		stream.write('{"partial":');
		await tick();
		assert.equal(lines.length, 0);

		stream.write('"value"}\n');
		await tick();
		assert.equal(lines.length, 1);
		assert.deepEqual(lines[0], { partial: "value" });
		detach();
	});

	it("attachJsonlLineReader handles CR+LF", async () => {
		const stream = new PassThrough();
		const { lines, detach } = collectLines(stream);

		stream.write('{"cr":"lf"}\r\n');
		await tick();
		assert.equal(lines.length, 1);
		assert.deepEqual(lines[0], { cr: "lf" });
		detach();
	});

	it("detach stops line delivery", async () => {
		const stream = new PassThrough();
		const { lines, detach } = collectLines(stream);

		stream.write('{"before":1}\n');
		await tick();
		assert.equal(lines.length, 1);

		detach();

		stream.write('{"after":2}\n');
		await tick();
		// Should still be 1 since we detached
		assert.equal(lines.length, 1);
	});
});

// ============================================================================
// v2 type shape assertions
// ============================================================================

describe("v2 type shapes", () => {
	it("RpcInitResult has required fields", () => {
		const initResult: RpcInitResult = {
			protocolVersion: 2,
			sessionId: "test-session-123",
			capabilities: {
				events: ["execution_complete", "cost_update", "runtime_heartbeat"],
				commands: ["init", "shutdown", "subscribe"],
			},
		};
		assert.equal(initResult.protocolVersion, 2);
		assert.ok(typeof initResult.sessionId === "string");
		assert.ok(Array.isArray(initResult.capabilities.events));
		assert.ok(Array.isArray(initResult.capabilities.commands));
		assert.ok(initResult.capabilities.events.includes("execution_complete"));
		assert.ok(initResult.capabilities.events.includes("cost_update"));
		assert.ok(initResult.capabilities.events.includes("runtime_heartbeat"));
		assert.ok(initResult.capabilities.commands.includes("init"));
		assert.ok(initResult.capabilities.commands.includes("shutdown"));
		assert.ok(initResult.capabilities.commands.includes("subscribe"));
	});

	it("RpcExecutionCompleteEvent matches expected shape", () => {
		const event: RpcExecutionCompleteEvent = {
			type: "execution_complete",
			runId: "run-abc-123",
			status: "completed",
			stats: {
				cost: 0.05,
				turns: 3,
				duration: 12000,
				tokens: { input: 1000, output: 500, cacheRead: 200, cacheWrite: 100 },
			} as any, // SessionStats is complex, we just verify shape
		};
		assert.equal(event.type, "execution_complete");
		assert.ok(typeof event.runId === "string");
		assert.ok(["completed", "error", "cancelled"].includes(event.status));
		assert.ok(event.stats !== undefined);
	});

	it("RpcExecutionCompleteEvent supports error status with reason", () => {
		const event: RpcExecutionCompleteEvent = {
			type: "execution_complete",
			runId: "run-err-456",
			status: "error",
			reason: "API rate limit exceeded",
			stats: {} as any,
		};
		assert.equal(event.status, "error");
		assert.equal(event.reason, "API rate limit exceeded");
	});

	it("RpcCostUpdateEvent matches expected shape", () => {
		const event: RpcCostUpdateEvent = {
			type: "cost_update",
			runId: "run-cost-789",
			turnCost: 0.01,
			cumulativeCost: 0.05,
			tokens: {
				input: 500,
				output: 200,
				cacheRead: 100,
				cacheWrite: 50,
			},
		};
		assert.equal(event.type, "cost_update");
		assert.ok(typeof event.runId === "string");
		assert.ok(typeof event.turnCost === "number");
		assert.ok(typeof event.cumulativeCost === "number");
		assert.ok(typeof event.tokens.input === "number");
		assert.ok(typeof event.tokens.output === "number");
		assert.ok(typeof event.tokens.cacheRead === "number");
		assert.ok(typeof event.tokens.cacheWrite === "number");
	});

	it("RpcV2Event discriminated union resolves by type field", () => {
		const events: RpcV2Event[] = [
			{
				type: "execution_complete",
				runId: "r1",
				status: "completed",
				stats: {} as any,
			},
			{
				type: "cost_update",
				runId: "r2",
				turnCost: 0.01,
				cumulativeCost: 0.03,
				tokens: { input: 100, output: 50, cacheRead: 10, cacheWrite: 5 },
			},
			{
				type: "runtime_heartbeat",
				sessionId: "s1",
				sessionFile: "/tmp/s1.jsonl",
				unitType: "execute-task",
				unitId: "M001/S01/T01",
				runtimeEpoch: 100,
				sourceEpoch: 101,
				emittedAt: 123,
			},
		];

		for (const event of events) {
			if (event.type === "execution_complete") {
				// TypeScript narrows to RpcExecutionCompleteEvent
				assert.ok("status" in event);
				assert.ok("stats" in event);
			} else if (event.type === "cost_update") {
				// TypeScript narrows to RpcCostUpdateEvent
				assert.ok("turnCost" in event);
				assert.ok("tokens" in event);
			} else if (event.type === "runtime_heartbeat") {
				assert.ok("runtimeEpoch" in event);
				assert.ok("sourceEpoch" in event);
			} else {
				assert.fail(`Unexpected event type: ${(event as any).type}`);
			}
		}
	});

	it("RpcProtocolVersion is 1 or 2", () => {
		const v1: RpcProtocolVersion = 1;
		const v2: RpcProtocolVersion = 2;
		assert.equal(v1, 1);
		assert.equal(v2, 2);
	});

	it("v2 prompt response includes optional runId field", () => {
		const v1Response: RpcResponse = {
			id: "1",
			type: "response",
			command: "prompt",
			success: true,
		};
		assert.equal(v1Response.success, true);
		assert.equal((v1Response as any).runId, undefined);

		const v2Response: RpcResponse = {
			id: "2",
			type: "response",
			command: "prompt",
			success: true,
			runId: "run-123",
		};
		assert.equal(v2Response.success, true);
		assert.equal((v2Response as any).runId, "run-123");
	});

	it("v2 command types are present in RpcCommand union", () => {
		// These compile — that's the actual test. Runtime verification:
		const initCmd: RpcCommand = { type: "init", protocolVersion: 2 };
		const shutdownCmd: RpcCommand = { type: "shutdown" };
		const subscribeCmd: RpcCommand = {
			type: "subscribe",
			events: ["agent_end"],
		};

		assert.equal(initCmd.type, "init");
		assert.equal(shutdownCmd.type, "shutdown");
		assert.equal(subscribeCmd.type, "subscribe");
	});

	it("init command supports optional clientId", () => {
		const cmd: RpcCommand = {
			type: "init",
			protocolVersion: 2,
			clientId: "my-client",
		};
		assert.equal(cmd.type, "init");
		if (cmd.type === "init") {
			assert.equal(cmd.clientId, "my-client");
		}
	});

	it("shutdown command supports optional graceful flag", () => {
		const cmd: RpcCommand = { type: "shutdown", graceful: true };
		if (cmd.type === "shutdown") {
			assert.equal(cmd.graceful, true);
		}
	});

	it("v2 response types include init, shutdown, subscribe", () => {
		const initResp: RpcResponse = {
			type: "response",
			command: "init",
			success: true,
			data: {
				protocolVersion: 2,
				sessionId: "s1",
				capabilities: { events: [], commands: [] },
			},
		};
		const shutdownResp: RpcResponse = {
			type: "response",
			command: "shutdown",
			success: true,
		};
		const subscribeResp: RpcResponse = {
			type: "response",
			command: "subscribe",
			success: true,
		};

		assert.equal(initResp.command, "init");
		assert.equal(shutdownResp.command, "shutdown");
		assert.equal(subscribeResp.command, "subscribe");
	});
});

// ============================================================================
// v1 backward compatibility
// ============================================================================

describe("v1 backward compatibility — command shapes", () => {
	it("v1 prompt command has no protocolVersion or runId", () => {
		const cmd: RpcCommand = { type: "prompt", message: "hello" };
		assert.equal(cmd.type, "prompt");
		assert.equal((cmd as any).protocolVersion, undefined);
		assert.equal((cmd as any).runId, undefined);
	});

	it("v1 get_state response has no v2 fields", () => {
		const state: RpcSessionState = {
			thinkingLevel: "medium",
			isStreaming: false,
			isCompacting: false,
			steeringMode: "all",
			followUpMode: "all",
			sessionId: "test-id",
			autoCompactionEnabled: true,
			autoRetryEnabled: false,
			retryInProgress: false,
			retryAttempt: 0,
			messageCount: 0,
			pendingMessageCount: 0,
			extensionsReady: true,
		};
		// v1 state should not include any v2-specific fields
		assert.equal((state as any).protocolVersion, undefined);
		assert.equal((state as any).runId, undefined);
	});

	it("v1 prompt response has no runId", () => {
		const resp: RpcResponse = {
			id: "1",
			type: "response",
			command: "prompt",
			success: true,
		};
		assert.equal(resp.success, true);
		// runId is optional; in v1 mode it won't be present
		assert.equal((resp as any).runId, undefined);
	});

	it("error response shape is consistent across v1 and v2", () => {
		const errResp: RpcResponse = {
			id: "err-1",
			type: "response",
			command: "init",
			success: false,
			error: "Protocol version already locked. init must be the first command.",
		};
		assert.equal(errResp.success, false);
		if (!errResp.success) {
			assert.ok(typeof errResp.error === "string");
			assert.ok(errResp.error.length > 0);
		}
	});
});

// ============================================================================
// RpcClient command serialization tests (mock process)
// ============================================================================

describe("RpcClient command serialization", () => {
	// We import the class dynamically to avoid the full module graph at test time.
	// Instead we test the protocol framing directly — what gets written to stdin and
	// what comes back from stdout — using PassThrough streams.

	it("init command serializes correctly", () => {
		const cmd = { id: "req_1", type: "init", protocolVersion: 2 };
		const serialized = serializeJsonLine(cmd);
		const parsed = JSON.parse(serialized);
		assert.equal(parsed.type, "init");
		assert.equal(parsed.protocolVersion, 2);
		assert.equal(parsed.id, "req_1");
	});

	it("init command with clientId serializes correctly", () => {
		const cmd = {
			id: "req_1",
			type: "init",
			protocolVersion: 2,
			clientId: "test-client",
		};
		const serialized = serializeJsonLine(cmd);
		const parsed = JSON.parse(serialized);
		assert.equal(parsed.clientId, "test-client");
	});

	it("init timeout defaults longer than normal request timeout", () => {
		assert.equal(resolveRpcInitTimeoutMs(), 120_000);
	});

	it("init timeout can be overridden by env", () => {
		process.env.SF_RPC_INIT_TIMEOUT_MS = "45000";
		try {
			assert.equal(resolveRpcInitTimeoutMs(), 45_000);
		} finally {
			delete process.env.SF_RPC_INIT_TIMEOUT_MS;
		}
	});

	it("shutdown command serializes correctly", () => {
		const cmd = { id: "req_2", type: "shutdown" };
		const serialized = serializeJsonLine(cmd);
		const parsed = JSON.parse(serialized);
		assert.equal(parsed.type, "shutdown");
		assert.equal(parsed.id, "req_2");
	});

	it("subscribe command serializes correctly with event list", () => {
		const cmd = {
			id: "req_3",
			type: "subscribe",
			events: ["agent_end", "cost_update"],
		};
		const serialized = serializeJsonLine(cmd);
		const parsed = JSON.parse(serialized);
		assert.equal(parsed.type, "subscribe");
		assert.deepEqual(parsed.events, ["agent_end", "cost_update"]);
	});

	it("subscribe command with wildcard serializes correctly", () => {
		const cmd = { id: "req_4", type: "subscribe", events: ["*"] };
		const serialized = serializeJsonLine(cmd);
		const parsed = JSON.parse(serialized);
		assert.deepEqual(parsed.events, ["*"]);
	});

	it("subscribe command with empty array serializes correctly", () => {
		const cmd = { id: "req_5", type: "subscribe", events: [] as string[] };
		const serialized = serializeJsonLine(cmd);
		const parsed = JSON.parse(serialized);
		assert.deepEqual(parsed.events, []);
	});

	it("sendUIResponse serializes correct JSONL", () => {
		const response = {
			type: "extension_ui_response",
			id: "ui-req-123",
			value: "test-value",
		};
		const serialized = serializeJsonLine(response);
		const parsed = JSON.parse(serialized);
		assert.equal(parsed.type, "extension_ui_response");
		assert.equal(parsed.id, "ui-req-123");
		assert.equal(parsed.value, "test-value");
	});

	it("sendUIResponse with cancelled flag serializes correctly", () => {
		const response = {
			type: "extension_ui_response",
			id: "ui-req-456",
			cancelled: true,
		};
		const serialized = serializeJsonLine(response);
		const parsed = JSON.parse(serialized);
		assert.equal(parsed.type, "extension_ui_response");
		assert.equal(parsed.cancelled, true);
	});

	it("sendUIResponse with confirmed flag serializes correctly", () => {
		const response = {
			type: "extension_ui_response",
			id: "ui-req-789",
			confirmed: true,
		};
		const serialized = serializeJsonLine(response);
		const parsed = JSON.parse(serialized);
		assert.equal(parsed.confirmed, true);
	});

	it("sendUIResponse with multiple values serializes correctly", () => {
		const response = {
			type: "extension_ui_response",
			id: "ui-req-multi",
			values: ["opt-a", "opt-b"],
		};
		const serialized = serializeJsonLine(response);
		const parsed = JSON.parse(serialized);
		assert.deepEqual(parsed.values, ["opt-a", "opt-b"]);
	});

	it("prompt command with runId in v2 response", () => {
		const response = {
			id: "req_10",
			type: "response",
			command: "prompt",
			success: true,
			runId: "run-uuid-abc",
		};
		const serialized = serializeJsonLine(response);
		const parsed = JSON.parse(serialized);
		assert.equal(parsed.runId, "run-uuid-abc");
		assert.equal(parsed.command, "prompt");
		assert.equal(parsed.success, true);
	});

	it("typescript cli paths launch through resolve-ts", () => {
		const repoRoot = new URL("../../../../../", import.meta.url).pathname;
		const cliPath = `${repoRoot}src/loader.ts`;
		const launchSpec = buildRpcLaunchSpec(cliPath);
		assert.equal(launchSpec.command, "node");
		assert.equal(launchSpec.args[0], "--import");
		assert.match(
			launchSpec.args[1],
			/src\/resources\/extensions\/sf\/tests\/resolve-ts\.mjs$/,
		);
		assert.equal(launchSpec.args[2], "--experimental-strip-types");
		assert.equal(launchSpec.args[3], cliPath);
	});

	it("compiled js cli paths launch directly", () => {
		const launchSpec = buildRpcLaunchSpec("/tmp/dist/cli.js");
		assert.equal(launchSpec.command, "node");
		assert.deepEqual(launchSpec.args, ["/tmp/dist/cli.js"]);
	});

	it("non-js executable shims launch directly", () => {
		const launchSpec = buildRpcLaunchSpec("/tmp/bin/sf-from-source");
		assert.equal(launchSpec.command, "/tmp/bin/sf-from-source");
		assert.deepEqual(launchSpec.args, []);
	});

	it("rpc child runs detached on Unix so stop can target its process group", () => {
		assert.equal(shouldDetachRpcChild(), process.platform !== "win32");
	});

	it("signalRpcProcessTree targets Unix process group before direct child fallback", () => {
		const calls: Array<[number, NodeJS.Signals]> = [];
		const originalKill = process.kill;
		const child = {
			pid: 12345,
			kill: (_signal: NodeJS.Signals) => {
				throw new Error("direct child fallback should not be used");
			},
		} as unknown as import("node:child_process").ChildProcess;

		try {
			(process as unknown as { kill: typeof process.kill }).kill = ((
				pid: number,
				signal?: NodeJS.Signals | number,
			) => {
				calls.push([pid, signal as NodeJS.Signals]);
				return true;
			}) as typeof process.kill;

			signalRpcProcessTree(child, "SIGTERM");
		} finally {
			(process as unknown as { kill: typeof process.kill }).kill = originalKill;
		}

		if (process.platform === "win32") {
			assert.deepEqual(calls, []);
		} else {
			assert.deepEqual(calls, [[-12345, "SIGTERM"]]);
		}
	});

	it("collectRpcDescendantPids finds shell-tool children before parent shutdown", async () => {
		if (process.platform === "win32") return;

		const child = spawn("sh", ["-c", "sleep 10 & wait"], {
			stdio: "ignore",
		});
		try {
			await tick(100);
			assert.ok(child.pid);
			const descendants = collectRpcDescendantPids(child.pid!);
			assert.ok(
				descendants.length >= 1,
				"expected shell child process to be listed",
			);
		} finally {
			for (const pid of child.pid ? collectRpcDescendantPids(child.pid) : []) {
				try {
					process.kill(pid, "SIGTERM");
				} catch {}
			}
			child.kill("SIGTERM");
		}
	});
});

// ============================================================================
// Client ↔ Mock server integration (PassThrough streams)
// ============================================================================

describe("Client ↔ Mock server protocol exchange", () => {
	let clientStdin: PassThrough;
	let clientStdout: PassThrough;

	beforeEach(() => {
		const mockProc = createMockProcess();
		clientStdin = mockProc.clientStdin;
		clientStdout = mockProc.clientStdout;
	});

	afterEach(() => {
		clientStdin.destroy();
		clientStdout.destroy();
	});

	it("init handshake: client writes init, server responds with init_result", async () => {
		// Collect what the client would write
		const { lines: clientWrites, detach: detachStdin } =
			collectLines(clientStdin);

		// Client sends init command
		writeLine(clientStdin, { id: "req_1", type: "init", protocolVersion: 2 });
		await tick();

		assert.equal(clientWrites.length, 1);
		const initCmd = clientWrites[0] as any;
		assert.equal(initCmd.type, "init");
		assert.equal(initCmd.protocolVersion, 2);

		// Server responds with init_result
		const initResult: RpcInitResult = {
			protocolVersion: 2,
			sessionId: "sess-abc",
			capabilities: {
				events: ["execution_complete", "cost_update", "runtime_heartbeat"],
				commands: ["init", "shutdown", "subscribe"],
			},
		};
		writeLine(clientStdout, {
			id: "req_1",
			type: "response",
			command: "init",
			success: true,
			data: initResult,
		});

		// Collect server response
		const { lines: serverResponses, detach: detachStdout } =
			collectLines(clientStdout);
		// Already wrote above, but let's verify the shape by re-writing
		writeLine(clientStdout, {
			id: "req_verify",
			type: "response",
			command: "init",
			success: true,
			data: initResult,
		});
		await tick();

		const resp = serverResponses[0] as any;
		assert.equal(resp.type, "response");
		assert.equal(resp.command, "init");
		assert.equal(resp.success, true);
		assert.equal(resp.data.protocolVersion, 2);
		assert.ok(typeof resp.data.sessionId === "string");

		detachStdin();
		detachStdout();
	});

	it("shutdown: client writes shutdown, server acknowledges", async () => {
		const { lines: clientWrites, detach } = collectLines(clientStdin);

		writeLine(clientStdin, { id: "req_2", type: "shutdown" });
		await tick();

		const cmd = clientWrites[0] as any;
		assert.equal(cmd.type, "shutdown");

		detach();
	});

	it("subscribe: client writes subscribe with event list", async () => {
		const { lines: clientWrites, detach } = collectLines(clientStdin);

		writeLine(clientStdin, {
			id: "req_3",
			type: "subscribe",
			events: ["agent_end", "execution_complete"],
		});
		await tick();

		const cmd = clientWrites[0] as any;
		assert.equal(cmd.type, "subscribe");
		assert.deepEqual(cmd.events, ["agent_end", "execution_complete"]);

		detach();
	});

	it("sendUIResponse: client writes extension_ui_response", async () => {
		const { lines: clientWrites, detach } = collectLines(clientStdin);

		writeLine(clientStdin, {
			type: "extension_ui_response",
			id: "ui-123",
			value: "selected-option",
		});
		await tick();

		const msg = clientWrites[0] as any;
		assert.equal(msg.type, "extension_ui_response");
		assert.equal(msg.id, "ui-123");
		assert.equal(msg.value, "selected-option");

		detach();
	});

	it("v2 event filtering: subscribe with empty array should filter all", async () => {
		// An empty event filter means no events pass through (Set with 0 entries)
		const subscribeCmd = {
			id: "req_4",
			type: "subscribe",
			events: [] as string[],
		};
		const serialized = serializeJsonLine(subscribeCmd);
		const parsed = JSON.parse(serialized);
		assert.deepEqual(parsed.events, []);
		// Server-side: `eventFilter = new Set([])` — Set.has(anything) returns false
		const filter = new Set(parsed.events as string[]);
		assert.equal(filter.has("agent_end"), false);
		assert.equal(filter.has("execution_complete"), false);
		assert.equal(filter.size, 0);
	});

	it("v2 event filtering: subscribe with wildcard resets filter", async () => {
		// Server-side: `events.includes("*")` → `eventFilter = null`
		const subscribeCmd = { type: "subscribe", events: ["*"] };
		const parsed = JSON.parse(serializeJsonLine(subscribeCmd));
		const hasWildcard = (parsed.events as string[]).includes("*");
		assert.equal(hasWildcard, true);
		// When wildcard is detected, filter becomes null (all events pass)
	});

	it("multiple commands can be sent sequentially", async () => {
		const { lines, detach } = collectLines(clientStdin);

		writeLine(clientStdin, { id: "1", type: "init", protocolVersion: 2 });
		writeLine(clientStdin, {
			id: "2",
			type: "subscribe",
			events: ["agent_end"],
		});
		writeLine(clientStdin, { id: "3", type: "prompt", message: "hello" });
		await tick();

		assert.equal(lines.length, 3);
		assert.equal((lines[0] as any).type, "init");
		assert.equal((lines[1] as any).type, "subscribe");
		assert.equal((lines[2] as any).type, "prompt");

		detach();
	});
});

// ============================================================================
// Negative tests — malformed inputs, error paths, boundary conditions
// ============================================================================

describe("Negative tests — protocol error shapes", () => {
	it("init with missing protocolVersion produces a type error at compile time", () => {
		// Runtime check: a message missing protocolVersion is malformed
		const malformed = { type: "init" } as any;
		assert.equal(malformed.protocolVersion, undefined);
		// Server would treat this as v1 lock since it's not a valid init
	});

	it("subscribe with non-array events is a type violation", () => {
		// Runtime: server expects events to be string[]
		const malformed = { type: "subscribe", events: "agent_end" } as any;
		assert.equal(typeof malformed.events, "string"); // Not an array
		assert.equal(Array.isArray(malformed.events), false);
	});

	it("double init error response shape", () => {
		// When init is sent after protocol lock, server returns error
		const errorResp: RpcResponse = {
			id: "req_dup",
			type: "response",
			command: "init",
			success: false,
			error: "Protocol version already locked. init must be the first command.",
		};
		assert.equal(errorResp.success, false);
		if (!errorResp.success) {
			assert.ok(errorResp.error.includes("already locked"));
		}
	});

	it("init after v1 lock error response shape", () => {
		// First command was get_state (v1 lock), then init arrives
		const errorResp: RpcResponse = {
			id: "req_late_init",
			type: "response",
			command: "init",
			success: false,
			error: "Protocol version already locked. init must be the first command.",
		};
		assert.equal(errorResp.success, false);
		if (!errorResp.success) {
			assert.ok(errorResp.error.includes("init must be the first command"));
		}
	});

	it("unknown command type produces error response", () => {
		const errorResp: RpcResponse = {
			id: "req_unknown",
			type: "response",
			command: "nonexistent",
			success: false,
			error: "Unknown command: nonexistent",
		};
		assert.equal(errorResp.success, false);
		if (!errorResp.success) {
			assert.ok(errorResp.error.includes("Unknown command"));
		}
	});

	it("malformed JSON parse error shape", () => {
		const errorResp: RpcResponse = {
			type: "response",
			command: "parse",
			success: false,
			error: "Failed to parse command: Unexpected token",
		};
		assert.equal(errorResp.command, "parse");
		assert.equal(errorResp.success, false);
	});

	it("shutdown works in both v1 and v2 — no version gating", () => {
		// shutdown returns success regardless of protocolVersion
		const v1Shutdown: RpcResponse = {
			id: "s1",
			type: "response",
			command: "shutdown",
			success: true,
		};
		const v2Shutdown: RpcResponse = {
			id: "s2",
			type: "response",
			command: "shutdown",
			success: true,
		};
		assert.equal(v1Shutdown.success, true);
		assert.equal(v2Shutdown.success, true);
	});
});

// ============================================================================
// Protocol version detection logic (unit)
// ============================================================================

describe("Protocol version detection logic", () => {
	it("simulates v1 lock when first command is non-init", () => {
		let protocolVersion: 1 | 2 = 1;
		let protocolLocked = false;

		// Simulate first command being get_state
		const command = { type: "get_state" } as RpcCommand;

		if (!protocolLocked) {
			protocolLocked = true;
			if (command.type === "init") {
				protocolVersion = 2;
			} else {
				protocolVersion = 1;
			}
		}

		assert.equal(protocolVersion, 1);
		assert.equal(protocolLocked, true);
	});

	it("simulates v2 lock when first command is init", () => {
		let protocolVersion: 1 | 2 = 1;
		let protocolLocked = false;

		const command: RpcCommand = { type: "init", protocolVersion: 2 };

		if (!protocolLocked) {
			protocolLocked = true;
			if (command.type === "init") {
				protocolVersion = 2;
			} else {
				protocolVersion = 1;
			}
		}

		assert.equal(protocolVersion, 2);
		assert.equal(protocolLocked, true);
	});

	it("rejects re-init after v2 lock", () => {
		const protocolLocked = true; // already locked from first init
		let errorMessage: string | null = null;

		const command: RpcCommand = { type: "init", protocolVersion: 2 };

		if (protocolLocked && command.type === "init") {
			errorMessage =
				"Protocol version already locked. init must be the first command.";
		}

		assert.ok(errorMessage !== null);
		assert.ok(errorMessage!.includes("already locked"));
	});

	it("rejects init after v1 lock", () => {
		const protocolLocked = true; // already locked from first non-init command
		const protocolVersion: 1 | 2 = 1;
		let errorMessage: string | null = null;

		const command: RpcCommand = { type: "init", protocolVersion: 2 };

		if (protocolLocked && command.type === "init") {
			errorMessage =
				"Protocol version already locked. init must be the first command.";
		}

		assert.equal(protocolVersion, 1); // stays v1
		assert.ok(errorMessage !== null);
	});

	it("extension_ui_response bypasses protocol detection", () => {
		let protocolLocked = false;
		let protocolDetectionTriggered = false;

		// Simulate the handleInputLine logic
		const parsed = { type: "extension_ui_response", id: "ui-1", value: "ok" };

		if (parsed.type === "extension_ui_response") {
			// Bypass — do not touch protocolLocked
		} else {
			protocolDetectionTriggered = true;
			if (!protocolLocked) {
				protocolLocked = true;
			}
		}

		assert.equal(protocolLocked, false);
		assert.equal(protocolDetectionTriggered, false);
	});
});

// ============================================================================
// v2 event filter logic (unit)
// ============================================================================

describe("v2 event filter logic", () => {
	/** Mimics the server-side event filter check: null means all events pass */
	function shouldEmit(filter: Set<string> | null, eventType: string): boolean {
		return !filter || filter.has(eventType);
	}

	it("null filter passes all events", () => {
		assert.equal(shouldEmit(null, "agent_end"), true);
		assert.equal(shouldEmit(null, "cost_update"), true);
		assert.equal(shouldEmit(null, "anything"), true);
	});

	it("filter with specific events passes matching events", () => {
		const filter = new Set(["agent_end", "cost_update"]);

		assert.equal(shouldEmit(filter, "agent_end"), true);
		assert.equal(shouldEmit(filter, "cost_update"), true);
		assert.equal(shouldEmit(filter, "execution_complete"), false);
		assert.equal(shouldEmit(filter, "message_start"), false);
	});

	it("empty Set filter blocks all events", () => {
		const filter = new Set<string>();

		assert.equal(shouldEmit(filter, "agent_end"), false);
		assert.equal(shouldEmit(filter, "cost_update"), false);
		assert.equal(shouldEmit(filter, "anything"), false);
		assert.equal(filter.size, 0);
	});

	it("wildcard subscribe resets filter to null", () => {
		let eventFilter: Set<string> | null = new Set(["agent_end"]);

		// Simulate subscribe with wildcard
		const events = ["*"];
		if (events.includes("*")) {
			eventFilter = null;
		} else {
			eventFilter = new Set(events);
		}

		assert.equal(eventFilter, null);
	});

	it("subscribe replaces previous filter", () => {
		let eventFilter: Set<string> | null = new Set(["agent_end"]);

		// Subscribe with different events
		const events = ["cost_update", "execution_complete"];
		if (events.includes("*")) {
			eventFilter = null;
		} else {
			eventFilter = new Set(events);
		}

		assert.equal(eventFilter!.has("agent_end"), false);
		assert.equal(eventFilter!.has("cost_update"), true);
		assert.equal(eventFilter!.has("execution_complete"), true);
	});

	it("filter applies to both regular and synthesized v2 events", () => {
		const eventFilter = new Set(["execution_complete"]);

		// Regular event
		assert.equal(eventFilter.has("agent_end"), false); // filtered out
		// Synthesized v2 event
		assert.equal(eventFilter.has("execution_complete"), true); // passes
		assert.equal(eventFilter.has("cost_update"), false); // filtered out
	});
});

// ============================================================================
// v2 runId injection logic (unit)
// ============================================================================

describe("v2 runId injection", () => {
	it("runId is present when protocolVersion is 2 and command is prompt/steer/follow_up", () => {
		const protocolVersion = 2;
		const commands = ["prompt", "steer", "follow_up"] as const;

		for (const cmdType of commands) {
			const runId = protocolVersion === 2 ? `run-${cmdType}-uuid` : undefined;
			assert.ok(
				runId !== undefined,
				`runId should be generated for ${cmdType} in v2`,
			);
			assert.ok(typeof runId === "string");
		}
	});

	it("runId is undefined when protocolVersion is 1", () => {
		// Test the v1 path: runId should not be generated
		function generateRunId(version: 1 | 2): string | undefined {
			return version === 2 ? "run-uuid" : undefined;
		}
		assert.equal(generateRunId(1), undefined);
		assert.ok(typeof generateRunId(2) === "string");
	});

	it("runId is injected into event output via spread", () => {
		const currentRunId = "run-abc-123";
		const event = { type: "message_start", message: { role: "assistant" } };

		// v2 injection logic from rpc-mode.ts
		const outputEvent = currentRunId
			? { ...event, runId: currentRunId }
			: event;

		assert.equal((outputEvent as any).runId, "run-abc-123");
		assert.equal((outputEvent as any).type, "message_start");
	});

	it("runId is not injected when null", () => {
		const currentRunId: string | null = null;
		const event = { type: "message_start", message: { role: "assistant" } };

		const outputEvent = currentRunId
			? { ...event, runId: currentRunId }
			: event;

		assert.equal((outputEvent as any).runId, undefined);
	});
});
