import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter, PassThrough, Writable } from "node:stream";

// ---------------------------------------------------------------------------
// Hoisted mocks
// ---------------------------------------------------------------------------
const { mockSpawn } = vi.hoisted(() => ({
  mockSpawn: vi.fn(),
}));

vi.mock("node:child_process", () => ({
  spawn: mockSpawn,
}));

import { CodexAppServerClient, type ApprovalDecision } from "./app-server-client.js";

// ---------------------------------------------------------------------------
// Helpers: fake child process
// ---------------------------------------------------------------------------
class FakeProcess extends EventEmitter {
  stdin = new Writable({
    write(_chunk, _encoding, callback) {
      callback();
    },
  });
  stdout = new PassThrough();
  stderr = new PassThrough();
  exitCode: number | null = null;
  pid = 12345;

  /** Lines written to stdin (captured for assertions) */
  stdinLines: string[] = [];

  constructor() {
    super();
    // Capture stdin writes
    const origWrite = this.stdin.write.bind(this.stdin);
    this.stdin.write = ((chunk: string | Buffer, ...args: unknown[]) => {
      this.stdinLines.push(chunk.toString());
      return origWrite(chunk, ...args);
    }) as typeof this.stdin.write;
  }

  /** Simulate the server sending a line on stdout */
  sendLine(data: string): void {
    this.stdout.write(data + "\n");
  }

  /** Simulate process exit */
  simulateExit(code: number | null = 0, signal: string | null = null): void {
    this.exitCode = code;
    this.emit("exit", code, signal);
  }

  kill(_signal?: string): boolean {
    return true;
  }
}

function createFakeProcess(): FakeProcess {
  const proc = new FakeProcess();
  mockSpawn.mockReturnValue(proc);
  return proc;
}

/** Parse JSON-RPC messages written to stdin */
function parseStdinMessages(proc: FakeProcess): Array<Record<string, unknown>> {
  return proc.stdinLines
    .flatMap((line) => line.split("\n"))
    .filter((line) => line.trim())
    .map((line) => JSON.parse(line) as Record<string, unknown>);
}

/** Find a request by method in stdin messages */
function findRequest(proc: FakeProcess, method: string): Record<string, unknown> | undefined {
  return parseStdinMessages(proc).find((msg) => msg["method"] === method);
}

beforeEach(() => {
  vi.clearAllMocks();
});

// ---------------------------------------------------------------------------
// Shared helper: connect a client to a fake process (handshake)
// ---------------------------------------------------------------------------
async function connectClient(
  client: CodexAppServerClient,
  proc: FakeProcess,
): Promise<void> {
  const connectPromise = client.connect();
  await new Promise((r) => setTimeout(r, 10));
  const initReq = findRequest(proc, "initialize");
  if (initReq) {
    proc.sendLine(JSON.stringify({ id: initReq["id"], result: {} }));
  }
  await connectPromise;
}

async function closeClient(
  client: CodexAppServerClient,
  proc: FakeProcess,
): Promise<void> {
  const closePromise = client.close();
  proc.simulateExit(0);
  await closePromise;
}

// =========================================================================
// Construction & Connection
// =========================================================================
describe("CodexAppServerClient", () => {
  describe("constructor", () => {
    it("creates client with default options", () => {
      const client = new CodexAppServerClient();
      expect(client.isConnected).toBe(false);
    });

    it("accepts custom options", () => {
      const client = new CodexAppServerClient({
        binaryPath: "/custom/codex",
        cwd: "/my/project",
        requestTimeout: 5000,
      });
      expect(client.isConnected).toBe(false);
    });
  });

  describe("connect", () => {
    it("spawns codex app-server and verifies spawn args", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });
      await connectClient(client, proc);

      expect(client.isConnected).toBe(true);
      expect(mockSpawn).toHaveBeenCalledWith("codex", ["app-server"], expect.any(Object));

      // Should have sent initialize request and initialized notification
      const initReq = findRequest(proc, "initialize");
      expect(initReq).toBeDefined();
      expect(initReq!["jsonrpc"]).toBe("2.0");
      const initializedNotif = findRequest(proc, "initialized");
      expect(initializedNotif).toBeDefined();
      expect(initializedNotif!["jsonrpc"]).toBe("2.0");

      await closeClient(client, proc);
    });

    it("uses custom binary path", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ binaryPath: "/opt/codex", requestTimeout: 500 });
      await connectClient(client, proc);

      expect(mockSpawn).toHaveBeenCalledWith("/opt/codex", ["app-server"], expect.any(Object));
      await closeClient(client, proc);
    });

    it("throws if already connected", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });
      await connectClient(client, proc);

      await expect(client.connect()).rejects.toThrow("already connected");
      await closeClient(client, proc);
    });

    it("throws if client is closed", async () => {
      const client = new CodexAppServerClient();
      await client.close();
      await expect(client.connect()).rejects.toThrow("closed");
    });

    it("forwards cwd and env to spawn", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({
        cwd: "/my/project",
        env: { OPENAI_API_KEY: "sk-test" },
        requestTimeout: 500,
      });
      await connectClient(client, proc);

      expect(mockSpawn).toHaveBeenCalledWith("codex", ["app-server"], expect.objectContaining({
        cwd: "/my/project",
        stdio: ["pipe", "pipe", "pipe"],
      }));
      // env should merge with process.env
      const spawnCall = mockSpawn.mock.calls[0];
      expect(spawnCall[2].env).toBeDefined();
      expect(spawnCall[2].env.OPENAI_API_KEY).toBe("sk-test");

      await closeClient(client, proc);
    });

    it("emits 'connected' event after handshake", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });
      const events: unknown[] = [];
      client.on("connected", (result: unknown) => events.push(result));

      await connectClient(client, proc);

      expect(events).toHaveLength(1);
      await closeClient(client, proc);
    });

    it("sends clientInfo in initialize request", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });
      await connectClient(client, proc);

      const initReq = findRequest(proc, "initialize");
      expect(initReq).toBeDefined();
      const params = initReq!["params"] as Record<string, unknown>;
      const clientInfo = params["clientInfo"] as Record<string, unknown>;
      expect(clientInfo["name"]).toBe("ao-agent-codex");
      expect(clientInfo["version"]).toBe("0.1.0");

      await closeClient(client, proc);
    });
  });

  describe("close", () => {
    it("sends SIGTERM to the process", async () => {
      const proc = createFakeProcess();
      const killSpy = vi.spyOn(proc, "kill");
      const client = new CodexAppServerClient({ requestTimeout: 500 });
      await connectClient(client, proc);

      await closeClient(client, proc);

      expect(killSpy).toHaveBeenCalledWith("SIGTERM");
      expect(client.isConnected).toBe(false);
    });

    it("is idempotent — calling close twice is safe", async () => {
      const client = new CodexAppServerClient();
      await client.close();
      await client.close(); // Should not throw
    });

    it("rejects pending requests on close", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });
      await connectClient(client, proc);

      // Send a request that won't be answered
      const requestPromise = client.threadList();

      await closeClient(client, proc);

      await expect(requestPromise).rejects.toThrow("closed");
    });
  });

  // =========================================================================
  // Request / Response
  // =========================================================================
  describe("sendRequest", () => {
    let proc: FakeProcess;
    let client: CodexAppServerClient;

    beforeEach(async () => {
      proc = createFakeProcess();
      client = new CodexAppServerClient({ requestTimeout: 500 });
      await connectClient(client, proc);
    });

    afterEach(async () => {
      await closeClient(client, proc);
    });

    it("correlates responses by id", async () => {
      const promise = client.sendRequest("thread/list", {});
      await new Promise((r) => setTimeout(r, 10));

      const msgs = parseStdinMessages(proc);
      const req = msgs.find((m) => m["method"] === "thread/list");
      expect(req).toBeDefined();

      proc.sendLine(JSON.stringify({
        id: req!["id"],
        result: { threads: [{ id: "t-1" }] },
      }));

      const result = await promise;
      expect(result).toEqual({ threads: [{ id: "t-1" }] });
    });

    it("rejects on JSON-RPC error response", async () => {
      const promise = client.sendRequest("thread/start", {});
      await new Promise((r) => setTimeout(r, 10));

      const msgs = parseStdinMessages(proc);
      const req = msgs.find((m) => m["method"] === "thread/start");

      proc.sendLine(JSON.stringify({
        id: req!["id"],
        error: { code: -32600, message: "Invalid params" },
      }));

      await expect(promise).rejects.toThrow("Invalid params");
    });

    it("times out if no response received", async () => {
      // requestTimeout is 500ms
      const promise = client.sendRequest("thread/start", {});
      await expect(promise).rejects.toThrow("timed out");
    }, 2000);

    it("throws if client is not initialized", async () => {
      const uninitClient = new CodexAppServerClient();
      await expect(uninitClient.sendRequest("thread/list", {})).rejects.toThrow("not initialized");
    });

    it("throws after client is closed", async () => {
      await closeClient(client, proc);
      // After close, initialized=false so "not initialized" check fires first
      await expect(client.sendRequest("thread/list", {})).rejects.toThrow();
      // Prevent afterEach from double-closing
      proc = createFakeProcess();
      client = new CodexAppServerClient({ requestTimeout: 500 });
      await connectClient(client, proc);
    });

    it("resolves to empty object when response has no result field", async () => {
      const promise = client.sendRequest("thread/list", {});
      await new Promise((r) => setTimeout(r, 10));

      const msgs = parseStdinMessages(proc);
      const req = msgs.find((m) => m["method"] === "thread/list");

      // Response with id but no result field
      proc.sendLine(JSON.stringify({ id: req!["id"] }));

      const result = await promise;
      expect(result).toEqual({});
    });
  });

  // =========================================================================
  // Thread Management
  // =========================================================================
  describe("thread management", () => {
    let proc: FakeProcess;
    let client: CodexAppServerClient;

    beforeEach(async () => {
      proc = createFakeProcess();
      client = new CodexAppServerClient({ requestTimeout: 500 });

      const connectPromise = client.connect();
      await new Promise((r) => setTimeout(r, 10));
      const initReq = findRequest(proc, "initialize");
      proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
      await connectPromise;
    });

    afterEach(async () => {
      const closePromise = client.close();
      proc.simulateExit(0);
      await closePromise;
    });

    it("threadStart sends thread/start request with params", async () => {
      const promise = client.threadStart({ model: "o3-mini", cwd: "/project" });
      await new Promise((r) => setTimeout(r, 10));

      const req = findRequest(proc, "thread/start");
      expect(req).toBeDefined();
      expect(req!["params"]).toEqual({ model: "o3-mini", cwd: "/project" });

      proc.sendLine(JSON.stringify({ id: req!["id"], result: { id: "thread-abc" } }));
      const result = await promise;
      expect(result).toEqual({ id: "thread-abc" });
    });

    it("threadResume sends thread/resume request with threadId", async () => {
      const promise = client.threadResume("thread-123");
      await new Promise((r) => setTimeout(r, 10));

      const req = findRequest(proc, "thread/resume");
      expect(req).toBeDefined();
      expect(req!["params"]).toEqual({ threadId: "thread-123" });

      proc.sendLine(JSON.stringify({ id: req!["id"], result: { id: "thread-123" } }));
      const result = await promise;
      expect(result).toEqual({ id: "thread-123" });
    });

    it("threadList sends thread/list request", async () => {
      const promise = client.threadList("cursor-1", 10);
      await new Promise((r) => setTimeout(r, 10));

      const req = findRequest(proc, "thread/list");
      expect(req).toBeDefined();
      expect(req!["params"]).toEqual({ cursor: "cursor-1", limit: 10 });

      proc.sendLine(JSON.stringify({ id: req!["id"], result: { threads: [] } }));
      await promise;
    });

    it("threadStart sends thread/start with empty params by default", async () => {
      const promise = client.threadStart();
      await new Promise((r) => setTimeout(r, 10));

      const req = findRequest(proc, "thread/start");
      expect(req).toBeDefined();
      expect(req!["params"]).toEqual({});

      proc.sendLine(JSON.stringify({ id: req!["id"], result: { id: "thread-default" } }));
      const result = await promise;
      expect(result).toEqual({ id: "thread-default" });
    });

    it("threadArchive sends thread/archive request", async () => {
      const promise = client.threadArchive("thread-old");
      await new Promise((r) => setTimeout(r, 10));

      const req = findRequest(proc, "thread/archive");
      expect(req!["params"]).toEqual({ threadId: "thread-old" });

      proc.sendLine(JSON.stringify({ id: req!["id"], result: {} }));
      await promise;
    });
  });

  // =========================================================================
  // Turn Management
  // =========================================================================
  describe("turn management", () => {
    let proc: FakeProcess;
    let client: CodexAppServerClient;

    beforeEach(async () => {
      proc = createFakeProcess();
      client = new CodexAppServerClient({ requestTimeout: 500 });

      const connectPromise = client.connect();
      await new Promise((r) => setTimeout(r, 10));
      const initReq = findRequest(proc, "initialize");
      proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
      await connectPromise;
    });

    afterEach(async () => {
      const closePromise = client.close();
      proc.simulateExit(0);
      await closePromise;
    });

    it("turnStart sends turn/start with text input", async () => {
      const promise = client.turnStart({
        threadId: "t-1",
        input: "Fix the bug in auth.ts",
      });
      await new Promise((r) => setTimeout(r, 10));

      const req = findRequest(proc, "turn/start");
      expect(req).toBeDefined();
      expect(req!["params"]).toEqual({
        threadId: "t-1",
        input: [{ type: "text", text: "Fix the bug in auth.ts" }],
      });

      proc.sendLine(JSON.stringify({ id: req!["id"], result: { turnId: "turn-1" } }));
      const result = await promise;
      expect(result).toEqual({ turnId: "turn-1" });
    });

    it("turnStart includes optional cwd and model params", async () => {
      const promise = client.turnStart({
        threadId: "t-1",
        input: "Fix it",
        cwd: "/custom/dir",
        model: "o3-mini",
      });
      await new Promise((r) => setTimeout(r, 10));

      const req = findRequest(proc, "turn/start");
      expect(req).toBeDefined();
      expect(req!["params"]).toEqual({
        threadId: "t-1",
        input: [{ type: "text", text: "Fix it" }],
        cwd: "/custom/dir",
        model: "o3-mini",
      });

      proc.sendLine(JSON.stringify({ id: req!["id"], result: { turnId: "turn-2" } }));
      await promise;
    });

    it("turnInterrupt sends turn/interrupt", async () => {
      const promise = client.turnInterrupt("t-1", "turn-1");
      await new Promise((r) => setTimeout(r, 10));

      const req = findRequest(proc, "turn/interrupt");
      expect(req!["params"]).toEqual({ threadId: "t-1", turnId: "turn-1" });

      proc.sendLine(JSON.stringify({ id: req!["id"], result: {} }));
      await promise;
    });
  });

  // =========================================================================
  // Notifications
  // =========================================================================
  describe("notifications", () => {
    it("routes server notifications to handler", async () => {
      const proc = createFakeProcess();
      const notifications: Array<{ method: string; params: Record<string, unknown> }> = [];

      const client = new CodexAppServerClient({
        requestTimeout: 500,
        onNotification: (method, params) => {
          notifications.push({ method, params });
        },
      });

      const connectPromise = client.connect();
      await new Promise((r) => setTimeout(r, 10));
      const initReq = findRequest(proc, "initialize");
      proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
      await connectPromise;

      // Server sends a notification
      proc.sendLine(JSON.stringify({
        method: "turn/completed",
        params: { threadId: "t-1", turnId: "turn-1" },
      }));
      await new Promise((r) => setTimeout(r, 10));

      expect(notifications).toHaveLength(1);
      expect(notifications[0]).toEqual({
        method: "turn/completed",
        params: { threadId: "t-1", turnId: "turn-1" },
      });

      const closePromise = client.close();
      proc.simulateExit(0);
      await closePromise;
    });

    it("emits notification events on the EventEmitter", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });

      const connectPromise = client.connect();
      await new Promise((r) => setTimeout(r, 10));
      const initReq = findRequest(proc, "initialize");
      proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
      await connectPromise;

      const events: Array<[string, Record<string, unknown>]> = [];
      client.on("notification", (method: string, params: Record<string, unknown>) => {
        events.push([method, params]);
      });

      proc.sendLine(JSON.stringify({
        method: "thread/tokenUsage/updated",
        params: { inputTokens: 100 },
      }));
      await new Promise((r) => setTimeout(r, 10));

      expect(events).toHaveLength(1);
      expect(events[0]![0]).toBe("thread/tokenUsage/updated");

      const closePromise = client.close();
      proc.simulateExit(0);
      await closePromise;
    });
  });

  // =========================================================================
  // Approval Requests
  // =========================================================================
  describe("approval handling", () => {
    it("auto-accepts approvals when no handler is provided", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });

      const connectPromise = client.connect();
      await new Promise((r) => setTimeout(r, 10));
      const initReq = findRequest(proc, "initialize");
      proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
      await connectPromise;

      // Server sends approval request
      proc.sendLine(JSON.stringify({
        id: 0,
        method: "item/fileChange/requestApproval",
        params: { path: "test.ts" },
      }));
      await new Promise((r) => setTimeout(r, 10));

      // Should have auto-responded with accept
      const approvalResponse = proc.stdinLines.find((l) => l.includes('"accept"'));
      expect(approvalResponse).toBeDefined();

      const closePromise = client.close();
      proc.simulateExit(0);
      await closePromise;
    });

    it("falls back to 'decline' when approval handler throws", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({
        requestTimeout: 500,
        onApproval: async () => {
          throw new Error("handler crashed");
        },
      });

      await connectClient(client, proc);

      proc.sendLine(JSON.stringify({
        id: 99,
        method: "item/fileChange/requestApproval",
        params: { path: "dangerous.ts" },
      }));
      await new Promise((r) => setTimeout(r, 10));

      // Should have responded with "decline" due to handler error
      const declineResponse = proc.stdinLines.find((l) => l.includes('"decline"'));
      expect(declineResponse).toBeDefined();

      await closeClient(client, proc);
    });

    it("emits 'approval' event for approval requests", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });
      const events: Array<[string | number, string, Record<string, unknown>]> = [];
      client.on("approval", (id: string | number, method: string, params: Record<string, unknown>) => {
        events.push([id, method, params]);
      });

      await connectClient(client, proc);

      proc.sendLine(JSON.stringify({
        id: 7,
        method: "item/commandExecution/requestApproval",
        params: { command: ["ls"] },
      }));
      await new Promise((r) => setTimeout(r, 10));

      expect(events).toHaveLength(1);
      expect(events[0]![1]).toBe("item/commandExecution/requestApproval");

      await closeClient(client, proc);
    });

    it("delegates to custom approval handler", async () => {
      const proc = createFakeProcess();
      const decisions: ApprovalDecision[] = [];

      const client = new CodexAppServerClient({
        requestTimeout: 500,
        onApproval: async (_id, _method, _params) => {
          decisions.push("decline");
          return "decline";
        },
      });

      const connectPromise = client.connect();
      await new Promise((r) => setTimeout(r, 10));
      const initReq = findRequest(proc, "initialize");
      proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
      await connectPromise;

      proc.sendLine(JSON.stringify({
        id: 42,
        method: "item/commandExecution/requestApproval",
        params: { command: ["rm", "-rf", "/"] },
      }));
      await new Promise((r) => setTimeout(r, 10));

      expect(decisions).toEqual(["decline"]);

      const closePromise = client.close();
      proc.simulateExit(0);
      await closePromise;
    });
  });

  // =========================================================================
  // Process lifecycle
  // =========================================================================
  describe("process lifecycle", () => {
    it("rejects pending requests when process exits", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 5000 });

      const connectPromise = client.connect();
      await new Promise((r) => setTimeout(r, 10));
      const initReq = findRequest(proc, "initialize");
      proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
      await connectPromise;

      // Start a request
      const requestPromise = client.threadList();
      await new Promise((r) => setTimeout(r, 10));

      // Process exits unexpectedly
      proc.simulateExit(1, null);

      await expect(requestPromise).rejects.toThrow("exited");
      expect(client.isConnected).toBe(false);
    });

    it("emits exit event on process exit", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });

      const connectPromise = client.connect();
      await new Promise((r) => setTimeout(r, 10));
      const initReq = findRequest(proc, "initialize");
      proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
      await connectPromise;

      const exits: Array<[number | null, string | null]> = [];
      client.on("exit", (code: number | null, signal: string | null) => {
        exits.push([code, signal]);
      });

      proc.simulateExit(0, null);
      expect(exits).toEqual([[0, null]]);
    });

    it("emits error and rejects pending on process spawn error", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 5000 });

      await connectClient(client, proc);

      const errors: Error[] = [];
      client.on("error", (err: Error) => errors.push(err));

      // Start a request
      const requestPromise = client.threadList();
      await new Promise((r) => setTimeout(r, 10));

      // Process emits error
      proc.emit("error", new Error("spawn ENOENT"));

      await expect(requestPromise).rejects.toThrow("spawn ENOENT");
      expect(errors).toHaveLength(1);
      expect(errors[0]!.message).toBe("spawn ENOENT");
      expect(client.isConnected).toBe(false);
    });

    it("rejects pending requests before emitting error (no listener crash safe)", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 5000 });

      await connectClient(client, proc);

      // Do NOT attach an error listener — emit("error") will throw
      // Start a request that will be pending
      const requestPromise = client.threadList();
      await new Promise((r) => setTimeout(r, 10));

      // Process emits error — without a listener, emit("error") throws.
      // Pending requests must still be rejected before that throw.
      try {
        proc.emit("error", new Error("spawn ENOENT"));
      } catch {
        // Expected: uncaught error event from EventEmitter
      }

      await expect(requestPromise).rejects.toThrow("spawn ENOENT");
    });

    it("handles malformed JSON lines gracefully", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });

      const connectPromise = client.connect();
      await new Promise((r) => setTimeout(r, 10));
      const initReq = findRequest(proc, "initialize");
      proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
      await connectPromise;

      // Send garbage — should not crash
      proc.sendLine("not valid json {{{");
      proc.sendLine("");
      proc.sendLine("   ");

      // Client should still be connected
      expect(client.isConnected).toBe(true);

      const closePromise = client.close();
      proc.simulateExit(0);
      await closePromise;
    });
  });

  // =========================================================================
  // sendNotification & sendApprovalResponse
  // =========================================================================
  describe("sendNotification", () => {
    it("sends a notification without id (no response expected)", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });
      await connectClient(client, proc);

      client.sendNotification("custom/event", { key: "value" });
      await new Promise((r) => setTimeout(r, 10));

      const msg = parseStdinMessages(proc).find((m) => m["method"] === "custom/event");
      expect(msg).toBeDefined();
      expect(msg!["params"]).toEqual({ key: "value" });
      expect(msg!["id"]).toBeUndefined();

      await closeClient(client, proc);
    });

    it("is a no-op on closed client", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });
      await connectClient(client, proc);
      await closeClient(client, proc);

      // Should not throw
      client.sendNotification("custom/event", {});
    });
  });

  describe("sendApprovalResponse", () => {
    it("sends approval response with decision", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });
      await connectClient(client, proc);

      client.sendApprovalResponse(42, "acceptForSession");
      await new Promise((r) => setTimeout(r, 10));

      const response = proc.stdinLines.find((l) => l.includes('"acceptForSession"'));
      expect(response).toBeDefined();
      const parsed = JSON.parse(response!) as Record<string, unknown>;
      expect(parsed["id"]).toBe(42);
      expect(parsed["result"]).toEqual({ decision: "acceptForSession" });

      await closeClient(client, proc);
    });

    it("is a no-op on closed client", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });
      await connectClient(client, proc);
      await closeClient(client, proc);

      // Should not throw
      client.sendApprovalResponse("req-1", "cancel");
    });
  });

  // =========================================================================
  // handleLine edge cases
  // =========================================================================
  describe("handleLine edge cases", () => {
    it("ignores JSON arrays", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });
      await connectClient(client, proc);

      // Send a JSON array — should be silently ignored
      proc.sendLine(JSON.stringify([1, 2, 3]));
      await new Promise((r) => setTimeout(r, 10));

      expect(client.isConnected).toBe(true);
      await closeClient(client, proc);
    });

    it("ignores JSON primitives (string, number)", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });
      await connectClient(client, proc);

      proc.sendLine(JSON.stringify("hello"));
      proc.sendLine(JSON.stringify(42));
      proc.sendLine(JSON.stringify(null));
      await new Promise((r) => setTimeout(r, 10));

      expect(client.isConnected).toBe(true);
      await closeClient(client, proc);
    });

    it("ignores messages with unknown id and no method", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });
      await connectClient(client, proc);

      // Message with id that doesn't match any pending request and no method
      proc.sendLine(JSON.stringify({ id: "unknown-id-999", result: { data: "orphan" } }));
      await new Promise((r) => setTimeout(r, 10));

      expect(client.isConnected).toBe(true);
      await closeClient(client, proc);
    });
  });

  // =========================================================================
  // Model Discovery
  // =========================================================================
  describe("model discovery", () => {
    it("modelList sends model/list request", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });

      const connectPromise = client.connect();
      await new Promise((r) => setTimeout(r, 10));
      const initReq = findRequest(proc, "initialize");
      proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
      await connectPromise;

      const promise = client.modelList();
      await new Promise((r) => setTimeout(r, 10));

      const req = findRequest(proc, "model/list");
      expect(req).toBeDefined();

      proc.sendLine(JSON.stringify({
        id: req!["id"],
        result: { models: [{ id: "o3-mini" }, { id: "gpt-4o" }] },
      }));

      const result = await promise;
      expect(result).toEqual({ models: [{ id: "o3-mini" }, { id: "gpt-4o" }] });

      const closePromise = client.close();
      proc.simulateExit(0);
      await closePromise;
    });
  });

  // =========================================================================
  // Concurrency & resource cleanup (review fixes)
  // =========================================================================
  describe("concurrency guards", () => {
    it("throws if connect() is called while already connecting", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });

      // Start connect but don't complete the handshake
      const connectPromise = client.connect();

      // Second connect() should throw immediately
      await expect(client.connect()).rejects.toThrow("already connecting");

      // Complete the handshake to clean up
      await new Promise((r) => setTimeout(r, 10));
      const initReq = findRequest(proc, "initialize");
      proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
      await connectPromise;
      await closeClient(client, proc);
    });

    it("cleans up process when initialize handshake fails", async () => {
      const proc = createFakeProcess();
      // Make kill trigger exit so close() doesn't hang
      vi.spyOn(proc, "kill").mockImplementation((_signal?: string) => {
        proc.simulateExit(1, "SIGTERM");
        return true;
      });
      const client = new CodexAppServerClient({ requestTimeout: 200 });

      // Start connect — don't answer the initialize request so it times out
      await expect(client.connect()).rejects.toThrow("timed out");

      // Client should have cleaned up — not connected, not connecting
      expect(client.isConnected).toBe(false);
    });

    it("allows retry after a failed connect (does not permanently close)", async () => {
      // First attempt: spawn a process that times out during handshake
      const proc1 = createFakeProcess();
      vi.spyOn(proc1, "kill").mockImplementation((_signal?: string) => {
        proc1.simulateExit(1, "SIGTERM");
        return true;
      });
      const client = new CodexAppServerClient({ requestTimeout: 200 });

      await expect(client.connect()).rejects.toThrow("timed out");
      expect(client.isConnected).toBe(false);

      // Second attempt should NOT throw "Client is closed" — it should be retryable
      const proc2 = createFakeProcess();
      const connectPromise = client.connect();
      await new Promise((r) => setTimeout(r, 10));
      const initReq = findRequest(proc2, "initialize");
      proc2.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
      await connectPromise;

      expect(client.isConnected).toBe(true);
      await closeClient(client, proc2);
    });

    it("drains stderr without blocking the child process", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });
      await connectClient(client, proc);

      // Write a large amount of data to stderr — should not block
      const largeChunk = "x".repeat(65536);
      proc.stderr.write(largeChunk);
      await new Promise((r) => setTimeout(r, 10));

      expect(client.isConnected).toBe(true);
      await closeClient(client, proc);
    });

    it("resets connecting flag if spawn throws synchronously", async () => {
      // Make spawn throw (e.g. EMFILE, ENOMEM)
      mockSpawn.mockImplementationOnce(() => {
        throw new Error("spawn EMFILE");
      });

      const client = new CodexAppServerClient({ requestTimeout: 500 });
      await expect(client.connect()).rejects.toThrow("spawn EMFILE");

      // connecting flag should be reset — a retry should not throw "already connecting"
      const proc = createFakeProcess();
      const connectPromise = client.connect();
      await new Promise((r) => setTimeout(r, 10));
      const initReq = findRequest(proc, "initialize");
      proc.sendLine(JSON.stringify({ id: initReq!["id"], result: {} }));
      await connectPromise;

      expect(client.isConnected).toBe(true);
      await closeClient(client, proc);
    });
  });

  describe("readline cleanup on process exit/error", () => {
    it("closes readline on unexpected process exit", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });
      await connectClient(client, proc);

      // Spy on readline close — the client stores it privately so we check
      // indirectly: after exit, sending another line should not cause errors
      proc.simulateExit(1, null);
      await new Promise((r) => setTimeout(r, 10));

      // Client should be disconnected
      expect(client.isConnected).toBe(false);

      // Sending data on stdout should not throw (readline is closed)
      expect(() => proc.sendLine('{"id":"x","result":{}}')).not.toThrow();
    });

    it("closes readline on process error", async () => {
      const proc = createFakeProcess();
      const client = new CodexAppServerClient({ requestTimeout: 500 });
      await connectClient(client, proc);

      // Catch the error event on the client to prevent unhandled error
      const errors: Error[] = [];
      client.on("error", (err: Error) => errors.push(err));

      proc.emit("error", new Error("process crashed"));
      await new Promise((r) => setTimeout(r, 10));

      expect(client.isConnected).toBe(false);
      expect(errors).toHaveLength(1);
    });
  });
});
