import { describe, it, expect, afterEach } from "vitest";
import { createServer, type ServerInstance } from "../server.js";
import type { Fixture } from "../types.js";
import { connectWebSocket } from "./ws-test-client.js";

// --- fixtures ---

const textFixture: Fixture = {
  match: { userMessage: "hello" },
  response: { content: "Hi there!" },
};

const toolFixture: Fixture = {
  match: { userMessage: "weather" },
  response: {
    toolCalls: [{ name: "get_weather", arguments: '{"city":"NYC"}' }],
  },
};

const errorFixture: Fixture = {
  match: { userMessage: "fail" },
  response: {
    error: { message: "Rate limited", type: "RESOURCE_EXHAUSTED", code: "rate_limit" },
    status: 429,
  },
};

const toolResultFixture: Fixture = {
  match: { toolCallId: "call_gemini_get_weather_0" },
  response: { content: "Weather in NYC is sunny, 72F" },
};

const allFixtures: Fixture[] = [textFixture, toolResultFixture, toolFixture, errorFixture];

// --- helpers ---

const GEMINI_WS_PATH =
  "/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent";

function setupMsg(model = "gemini-2.0-flash-exp"): string {
  return JSON.stringify({
    setup: { model },
  });
}

function clientContentMsg(text: string): string {
  return JSON.stringify({
    clientContent: {
      turns: [{ role: "user", parts: [{ text }] }],
      turnComplete: true,
    },
  });
}

function toolResponseMsg(name: string, response: unknown, id?: string): string {
  return JSON.stringify({
    toolResponse: {
      functionResponses: [{ id, name, response }],
    },
  });
}

// --- tests ---

let instance: ServerInstance | null = null;

afterEach(async () => {
  if (instance) {
    await new Promise<void>((resolve) => {
      instance!.server.close(() => resolve());
    });
    instance = null;
  }
});

describe("WebSocket Gemini Live BidiGenerateContent", () => {
  it("responds with setupComplete after setup message", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());

    const raw = await ws.waitForMessages(1);
    const msg = JSON.parse(raw[0]);
    expect(msg).toEqual({ setupComplete: {} });

    ws.close();
  });

  it("responds with setupComplete when using config alias instead of setup", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    // Use "config" instead of "setup"
    ws.send(JSON.stringify({ config: { model: "gemini-2.0-flash-exp" } }));

    const raw = await ws.waitForMessages(1);
    const msg = JSON.parse(raw[0]);
    expect(msg).toEqual({ setupComplete: {} });

    // Verify the session is set up by sending a message
    ws.send(clientContentMsg("hello"));
    const raw2 = await ws.waitForMessages(3); // setupComplete + content + turnComplete
    const contentMsg = JSON.parse(raw2[1]);
    expect(contentMsg.serverContent).toBeDefined();

    ws.close();
  });

  it("streams text response with serverContent and separate turnComplete", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    ws.send(clientContentMsg("hello"));

    // "Hi there!" is 9 chars, default chunkSize=20 → 1 chunk + 1 turnComplete
    const raw = await ws.waitForMessages(3); // setupComplete + 1 serverContent + turnComplete
    const contentMsg = JSON.parse(raw[1]);
    expect(contentMsg.serverContent).toBeDefined();
    expect(contentMsg.serverContent.modelTurn.parts[0].text).toBe("Hi there!");
    expect(contentMsg.serverContent.turnComplete).toBeUndefined();

    const turnCompleteMsg = JSON.parse(raw[2]);
    expect(turnCompleteMsg.serverContent.turnComplete).toBe(true);
    expect(turnCompleteMsg.serverContent.modelTurn).toBeUndefined();

    ws.close();
  });

  it("streams text in multiple chunks when content exceeds chunkSize", async () => {
    const longFixture: Fixture = {
      match: { userMessage: "long" },
      response: { content: "ABCDEFGHIJ" },
      chunkSize: 3,
    };
    instance = await createServer([longFixture]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    ws.send(clientContentMsg("long"));

    // "ABCDEFGHIJ" (10 chars) / chunkSize 3 → 4 chunks + 1 turnComplete
    const raw = await ws.waitForMessages(6); // 1 setupComplete + 4 chunks + 1 turnComplete
    const contentChunks = raw.slice(1, 5).map((r) => JSON.parse(r));

    // Content chunks should NOT have turnComplete
    for (const chunk of contentChunks) {
      expect(chunk.serverContent.turnComplete).toBeUndefined();
      expect(chunk.serverContent.modelTurn).toBeDefined();
    }

    // Last message is separate turnComplete
    const turnCompleteMsg = JSON.parse(raw[5]);
    expect(turnCompleteMsg.serverContent.turnComplete).toBe(true);
    expect(turnCompleteMsg.serverContent.modelTurn).toBeUndefined();

    // Reconstruct full text from content chunks only
    const fullText = contentChunks.map((c) => c.serverContent.modelTurn.parts[0].text).join("");
    expect(fullText).toBe("ABCDEFGHIJ");

    ws.close();
  });

  it("returns toolCall for tool call fixture with turnComplete", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1);

    ws.send(clientContentMsg("weather"));

    const raw = await ws.waitForMessages(3); // setupComplete + toolCall + turnComplete
    const msg = JSON.parse(raw[1]);
    expect(msg.toolCall).toBeDefined();
    expect(msg.toolCall.functionCalls).toHaveLength(1);
    expect(msg.toolCall.functionCalls[0].name).toBe("get_weather");
    expect(msg.toolCall.functionCalls[0].args).toEqual({ city: "NYC" });
    expect(msg.toolCall.functionCalls[0].id).toMatch(/^call_/);

    // Separate turnComplete message follows the toolCall
    const turnCompleteMsg = JSON.parse(raw[2]);
    expect(turnCompleteMsg.serverContent).toBeDefined();
    expect(turnCompleteMsg.serverContent.turnComplete).toBe(true);

    ws.close();
  });

  it("processes toolResponse and returns serverContent", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1);

    // First get a tool call (+ turnComplete)
    ws.send(clientContentMsg("weather"));
    await ws.waitForMessages(3); // setupComplete + toolCall + turnComplete

    // Send tool response
    ws.send(toolResponseMsg("get_weather", { temp: "72F" }, "call_gemini_get_weather_0"));

    // "Weather in NYC is sunny, 72F" is 28 chars, default chunkSize=20 → 2 content chunks + 1 turnComplete
    const raw = await ws.waitForMessages(6); // setupComplete + toolCall + turnComplete + 2 content + turnComplete
    const contentChunks = raw.slice(3, 5).map((r) => JSON.parse(r));

    // Content chunks should NOT have turnComplete
    expect(contentChunks[0].serverContent).toBeDefined();
    expect(contentChunks[0].serverContent.turnComplete).toBeUndefined();

    expect(contentChunks[1].serverContent).toBeDefined();
    expect(contentChunks[1].serverContent.turnComplete).toBeUndefined();

    // Separate turnComplete message
    const turnCompleteMsg = JSON.parse(raw[5]);
    expect(turnCompleteMsg.serverContent.turnComplete).toBe(true);

    // Reconstruct full text from content chunks
    const fullText = contentChunks.map((c) => c.serverContent.modelTurn.parts[0].text).join("");
    expect(fullText).toBe("Weather in NYC is sunny, 72F");

    ws.close();
  });

  it("returns error when no fixture matches", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1);

    ws.send(clientContentMsg("unknown-message-that-matches-nothing"));

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.error).toBeDefined();
    expect(msg.error.code).toBe(5); // gRPC NOT_FOUND
    expect(msg.error.message).toBe("No fixture matched");
    expect(msg.error.status).toBe("NOT_FOUND");

    ws.close();
  });

  it("returns error for error fixture", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1);

    ws.send(clientContentMsg("fail"));

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.error).toBeDefined();
    expect(msg.error.code).toBe(8); // gRPC RESOURCE_EXHAUSTED (mapped from HTTP 429)
    expect(msg.error.message).toBe("Rate limited");
    expect(msg.error.status).toBe("RESOURCE_EXHAUSTED");

    ws.close();
  });

  it("records journal entries with method WS", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1);

    ws.send(clientContentMsg("hello"));
    await ws.waitForMessages(3); // setupComplete + content + turnComplete

    // Small pause to ensure journal write completed
    await new Promise((r) => setTimeout(r, 50));

    expect(instance.journal.size).toBe(1);
    const entry = instance.journal.getLast();
    expect(entry!.method).toBe("WS");
    expect(entry!.path).toBe(GEMINI_WS_PATH);
    expect(entry!.response.status).toBe(200);
    expect(entry!.response.fixture).toBe(textFixture);

    ws.close();
  });

  it("truncateAfterChunks stops stream early, no turnComplete: true", async () => {
    const truncFixture: Fixture = {
      match: { userMessage: "truncate-gemini" },
      response: { content: "ABCDEFGHIJKLMNO" }, // 15 chars, chunkSize 3 => 5 chunks
      chunkSize: 3,
      latency: 5,
      truncateAfterChunks: 2,
    };
    instance = await createServer([truncFixture]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    ws.send(clientContentMsg("truncate-gemini"));

    // Wait for connection to be destroyed
    await ws.waitForClose();

    // Small pause for server-side processing
    await new Promise((r) => setTimeout(r, 50));

    // Check that no message with turnComplete: true was sent
    const raw = await ws.waitForMessages(1).catch(() => [] as string[]);
    if (raw.length > 1) {
      const chunks = raw.slice(1).map((r) => JSON.parse(r));
      const hasTurnComplete = chunks.some((c) => c.serverContent?.turnComplete === true);
      expect(hasTurnComplete).toBe(false);
    }
  });

  it("truncateAfterChunks records interrupted: true in journal", async () => {
    const truncFixture: Fixture = {
      match: { userMessage: "truncate-journal-gemini" },
      response: { content: "ABCDEFGHIJKLMNO" },
      chunkSize: 3,
      latency: 5,
      truncateAfterChunks: 2,
    };
    instance = await createServer([truncFixture]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    ws.send(clientContentMsg("truncate-journal-gemini"));

    // Wait for connection to be destroyed
    await ws.waitForClose();

    // Give server time to finalize journal
    await new Promise((r) => setTimeout(r, 50));

    const entry = instance.journal.getLast();
    expect(entry).not.toBeNull();
    expect(entry!.response.interrupted).toBe(true);
    expect(entry!.response.interruptReason).toBe("truncateAfterChunks");
  });

  // Gemini Live sends all tool calls in a single WS frame, so truncateAfterChunks: 1
  // interrupts after that frame is sent (preventing conversation history update).
  it("truncateAfterChunks with toolCalls records interrupted: true in journal", async () => {
    const truncFixture: Fixture = {
      match: { userMessage: "truncate-tool-gemini" },
      response: {
        toolCalls: [{ name: "get_weather", arguments: '{"city":"NYC"}' }],
      },
      latency: 5,
      truncateAfterChunks: 1,
    };
    instance = await createServer([truncFixture]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    ws.send(clientContentMsg("truncate-tool-gemini"));

    // Wait for connection to be destroyed
    await ws.waitForClose();

    // Give server time to finalize journal
    await new Promise((r) => setTimeout(r, 50));

    const entry = instance.journal.getLast();
    expect(entry).not.toBeNull();
    expect(entry!.response.interrupted).toBe(true);
    expect(entry!.response.interruptReason).toBe("truncateAfterChunks");
  });

  it("disconnectAfterMs interrupts stream and records in journal", async () => {
    const fixture: Fixture = {
      match: { userMessage: "disconnect-gemini" },
      response: { content: "ABCDEFGHIJKLMNOPQRSTUVWXYZ" },
      chunkSize: 1,
      latency: 20,
      disconnectAfterMs: 30,
    };
    instance = await createServer([fixture]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    ws.send(clientContentMsg("disconnect-gemini"));

    await ws.waitForClose();
    await new Promise((r) => setTimeout(r, 50));

    const entry = instance.journal.getLast();
    expect(entry).not.toBeNull();
    expect(entry!.response.interrupted).toBe(true);
    expect(entry!.response.interruptReason).toBe("disconnectAfterMs");
  });

  it("returns error for clientContent with missing turns", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    // Send clientContent without turns
    ws.send(JSON.stringify({ clientContent: {} }));

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.error).toBeDefined();
    expect(msg.error.code).toBe(3); // gRPC INVALID_ARGUMENT
    expect(msg.error.message).toBe("Missing 'turns' in clientContent");
    expect(msg.error.status).toBe("INVALID_ARGUMENT");

    ws.close();
  });

  it("returns error for clientContent with non-array turns", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    // Send clientContent with turns as a string instead of array
    ws.send(JSON.stringify({ clientContent: { turns: "not-an-array" } }));

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.error).toBeDefined();
    expect(msg.error.code).toBe(3); // gRPC INVALID_ARGUMENT
    expect(msg.error.message).toBe("Missing 'turns' in clientContent");
    expect(msg.error.status).toBe("INVALID_ARGUMENT");

    ws.close();
  });

  it("returns error for toolResponse with missing functionResponses", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    // Send toolResponse without functionResponses
    ws.send(JSON.stringify({ toolResponse: {} }));

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.error).toBeDefined();
    expect(msg.error.code).toBe(3); // gRPC INVALID_ARGUMENT
    expect(msg.error.message).toBe("Missing 'functionResponses' in toolResponse");
    expect(msg.error.status).toBe("INVALID_ARGUMENT");

    ws.close();
  });

  it("returns error for toolResponse with non-array functionResponses", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    // Send toolResponse with functionResponses as a string
    ws.send(JSON.stringify({ toolResponse: { functionResponses: "not-an-array" } }));

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.error).toBeDefined();
    expect(msg.error.code).toBe(3); // gRPC INVALID_ARGUMENT
    expect(msg.error.message).toBe("Missing 'functionResponses' in toolResponse");
    expect(msg.error.status).toBe("INVALID_ARGUMENT");

    ws.close();
  });

  it("returns error for malformed JSON", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    ws.send("not valid json {{{}");

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.error).toBeDefined();
    expect(msg.error.code).toBe(3); // gRPC INVALID_ARGUMENT
    expect(msg.error.message).toMatch(/^Malformed JSON:/);
    expect(msg.error.status).toBe("INVALID_ARGUMENT");

    ws.close();
  });

  it("returns error for unrecognized message type (no setup/clientContent/toolResponse)", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    // Send message with no recognized field
    ws.send(JSON.stringify({ someUnknownField: true }));

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.error).toBeDefined();
    expect(msg.error.code).toBe(3); // gRPC INVALID_ARGUMENT
    expect(msg.error.message).toBe("Expected clientContent or toolResponse");
    expect(msg.error.status).toBe("INVALID_ARGUMENT");

    ws.close();
  });

  it("closes with 1008 in strict mode when no fixture matches", async () => {
    instance = await createServer(allFixtures, { strict: true });
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    ws.send(clientContentMsg("unknown-no-match-strict"));

    await ws.waitForClose();
  });

  it("handles empty content text response", async () => {
    const emptyFixture: Fixture = {
      match: { userMessage: "empty-content" },
      response: { content: "" },
    };
    instance = await createServer([emptyFixture]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    ws.send(clientContentMsg("empty-content"));

    const raw = await ws.waitForMessages(3); // setupComplete + empty content + turnComplete
    const contentMsg = JSON.parse(raw[1]);
    expect(contentMsg.serverContent).toBeDefined();
    expect(contentMsg.serverContent.modelTurn.parts[0].text).toBe("");
    expect(contentMsg.serverContent.turnComplete).toBeUndefined();

    const turnCompleteMsg = JSON.parse(raw[2]);
    expect(turnCompleteMsg.serverContent.turnComplete).toBe(true);

    ws.close();
  });

  it("handles setup without model (uses default)", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    // Send setup without model field
    ws.send(JSON.stringify({ setup: {} }));

    const raw = await ws.waitForMessages(1);
    const msg = JSON.parse(raw[0]);
    expect(msg).toEqual({ setupComplete: {} });

    ws.close();
  });

  it("handles setup with tools", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(
      JSON.stringify({
        setup: {
          model: "gemini-2.0-flash-exp",
          tools: [
            {
              functionDeclarations: [
                {
                  name: "get_weather",
                  description: "Gets weather",
                  parameters: { type: "object" },
                },
              ],
            },
          ],
        },
      }),
    );

    const raw = await ws.waitForMessages(1);
    const msg = JSON.parse(raw[0]);
    expect(msg).toEqual({ setupComplete: {} });

    ws.close();
  });

  it("handles model turns with text in conversation history", async () => {
    // Test conversion of model turns with text content
    const multiTurnFixture: Fixture = {
      match: { userMessage: "follow-up" },
      response: { content: "Follow-up response" },
    };
    instance = await createServer([multiTurnFixture]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    // Send clientContent with both user and model turns
    ws.send(
      JSON.stringify({
        clientContent: {
          turns: [
            { role: "user", parts: [{ text: "first" }] },
            { role: "model", parts: [{ text: "model reply" }] },
            { role: "user", parts: [{ text: "follow-up" }] },
          ],
          turnComplete: true,
        },
      }),
    );

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.serverContent).toBeDefined();

    ws.close();
  });

  it("handles model turns with function calls in conversation history", async () => {
    const afterFuncFixture: Fixture = {
      match: { userMessage: "after-func" },
      response: { content: "After function response" },
    };
    instance = await createServer([afterFuncFixture]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    // Send clientContent with model turn containing functionCall
    ws.send(
      JSON.stringify({
        clientContent: {
          turns: [
            { role: "user", parts: [{ text: "do something" }] },
            {
              role: "model",
              parts: [{ functionCall: { name: "search", args: { q: "test" } } }],
            },
            {
              role: "user",
              parts: [
                { functionResponse: { name: "search", response: "results", id: "call_1" } },
                { text: "after-func" },
              ],
            },
          ],
          turnComplete: true,
        },
      }),
    );

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.serverContent).toBeDefined();

    ws.close();
  });

  it("handles toolResponse with non-string response values", async () => {
    const toolResultFixture2: Fixture = {
      match: { toolCallId: "call_gemini_search_0" },
      response: { content: "Search result" },
    };
    instance = await createServer([toolResultFixture2]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    // Send toolResponse where response is an object (not string)
    ws.send(
      JSON.stringify({
        toolResponse: {
          functionResponses: [
            { name: "search", response: { results: ["a", "b"] }, id: "call_gemini_search_0" },
          ],
        },
      }),
    );

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.serverContent).toBeDefined();

    ws.close();
  });

  it("handles tool call with malformed JSON arguments in fixture", async () => {
    const badArgsFixture: Fixture = {
      match: { userMessage: "bad-args" },
      response: {
        toolCalls: [{ name: "search", arguments: "not-json{{{" }],
      },
    };
    instance = await createServer([badArgsFixture]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    ws.send(clientContentMsg("bad-args"));

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    // Should still produce a toolCall with empty args object
    expect(msg.toolCall).toBeDefined();
    expect(msg.toolCall.functionCalls[0].name).toBe("search");
    expect(msg.toolCall.functionCalls[0].args).toEqual({});

    ws.close();
  });

  it("maps HTTP 401 to gRPC 16 (UNAUTHENTICATED)", async () => {
    const fixture: Fixture = {
      match: { userMessage: "unauth" },
      response: {
        error: { message: "Not authenticated", type: "UNAUTHENTICATED" },
        status: 401,
      },
    };
    instance = await createServer([fixture]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1);

    ws.send(clientContentMsg("unauth"));

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.error.code).toBe(16);
    expect(msg.error.message).toBe("Not authenticated");
    expect(msg.error.status).toBe("UNAUTHENTICATED");

    ws.close();
  });

  it("maps HTTP 403 to gRPC 7 (PERMISSION_DENIED)", async () => {
    const fixture: Fixture = {
      match: { userMessage: "forbidden" },
      response: {
        error: { message: "Forbidden", type: "PERMISSION_DENIED" },
        status: 403,
      },
    };
    instance = await createServer([fixture]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1);

    ws.send(clientContentMsg("forbidden"));

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.error.code).toBe(7);
    expect(msg.error.message).toBe("Forbidden");
    expect(msg.error.status).toBe("PERMISSION_DENIED");

    ws.close();
  });

  it("maps HTTP 409 to gRPC 10 (ABORTED)", async () => {
    const fixture: Fixture = {
      match: { userMessage: "conflict" },
      response: {
        error: { message: "Conflict", type: "ABORTED" },
        status: 409,
      },
    };
    instance = await createServer([fixture]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1);

    ws.send(clientContentMsg("conflict"));

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.error.code).toBe(10);
    expect(msg.error.message).toBe("Conflict");
    expect(msg.error.status).toBe("ABORTED");

    ws.close();
  });

  it("maps HTTP 501 to gRPC 12 (UNIMPLEMENTED)", async () => {
    const fixture: Fixture = {
      match: { userMessage: "not-impl" },
      response: {
        error: { message: "Not implemented", type: "UNIMPLEMENTED" },
        status: 501,
      },
    };
    instance = await createServer([fixture]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1);

    ws.send(clientContentMsg("not-impl"));

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.error.code).toBe(12);
    expect(msg.error.message).toBe("Not implemented");
    expect(msg.error.status).toBe("UNIMPLEMENTED");

    ws.close();
  });

  it("maps HTTP 503 to gRPC 14 (UNAVAILABLE)", async () => {
    const fixture: Fixture = {
      match: { userMessage: "unavailable" },
      response: {
        error: { message: "Service unavailable", type: "UNAVAILABLE" },
        status: 503,
      },
    };
    instance = await createServer([fixture]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1);

    ws.send(clientContentMsg("unavailable"));

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.error.code).toBe(14);
    expect(msg.error.message).toBe("Service unavailable");
    expect(msg.error.status).toBe("UNAVAILABLE");

    ws.close();
  });

  it("handles error fixture with default status 500", async () => {
    const errorNoStatusFixture: Fixture = {
      match: { userMessage: "error-no-status" },
      response: {
        error: { message: "Something went wrong", type: "server_error" },
      },
    };
    instance = await createServer([errorNoStatusFixture]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    ws.send(clientContentMsg("error-no-status"));

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.error).toBeDefined();
    expect(msg.error.code).toBe(13); // gRPC INTERNAL (mapped from HTTP 500)
    expect(msg.error.message).toBe("Something went wrong");

    ws.close();
  });

  it("handles turn with missing role (defaults to user)", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    // Send clientContent with turn missing role field
    ws.send(
      JSON.stringify({
        clientContent: {
          turns: [{ parts: [{ text: "hello" }] }],
          turnComplete: true,
        },
      }),
    );

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.serverContent).toBeDefined();

    ws.close();
  });

  it("handles user turn with functionResponse that has string response", async () => {
    // Fixture that matches a tool call id
    const toolResultFixtureStr: Fixture = {
      match: { toolCallId: "call_search_1" },
      response: { content: "Result processed" },
    };
    instance = await createServer([toolResultFixtureStr]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    // Send clientContent with functionResponse where response is a string
    // Provide explicit id so it matches the fixture's toolCallId
    ws.send(
      JSON.stringify({
        clientContent: {
          turns: [
            {
              role: "user",
              parts: [
                {
                  functionResponse: {
                    name: "search",
                    response: "string-result",
                    id: "call_search_1",
                  },
                },
              ],
            },
          ],
          turnComplete: true,
        },
      }),
    );

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.serverContent).toBeDefined();

    ws.close();
  });

  it("handles toolResponse with fallback id and string response", async () => {
    // Fixture matching on tool call id
    const toolResultFixture3: Fixture = {
      match: { toolCallId: "call_lookup_1" },
      response: { content: "Lookup done" },
    };
    instance = await createServer([toolResultFixture3]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    // Send toolResponse with explicit id and string response
    ws.send(
      JSON.stringify({
        toolResponse: {
          functionResponses: [
            { name: "lookup", response: "string-response-value", id: "call_lookup_1" },
          ],
        },
      }),
    );

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.serverContent).toBeDefined();

    ws.close();
  });

  it("generates random call_ ID when toolResponse functionResponse has no id", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    // Send toolResponse WITHOUT an id field — exercises the generateToolCallId() fallback
    ws.send(toolResponseMsg("get_weather", { temp: "72F" }));

    // No fixture will match the random ID, so we get a "No fixture matched" error
    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.error).toBeDefined();
    expect(msg.error.message).toBe("No fixture matched");

    // Small pause to ensure journal write completed
    await new Promise((r) => setTimeout(r, 50));

    // Inspect the journal to verify the generated tool_call_id starts with call_
    const entry = instance.journal.getLast();
    expect(entry).not.toBeNull();
    const messages = entry!.body!.messages;
    const toolMsg = messages.find((m) => m.role === "tool");
    expect(toolMsg).toBeDefined();
    expect(toolMsg!.tool_call_id).toMatch(/^call_/);
    // Verify it's a random ID (not the old deterministic format)
    expect(toolMsg!.tool_call_id).not.toMatch(/^call_gemini_/);

    ws.close();
  });

  it("generates random call_ ID when clientContent functionResponse has no id", async () => {
    const afterToolFixture: Fixture = {
      match: { userMessage: "continue-after-tool" },
      response: { content: "Continued" },
    };
    instance = await createServer([afterToolFixture]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    // Send clientContent with a functionResponse lacking an id, followed by user text
    ws.send(
      JSON.stringify({
        clientContent: {
          turns: [
            {
              role: "user",
              parts: [
                { functionResponse: { name: "search", response: { results: [] } } },
                { text: "continue-after-tool" },
              ],
            },
          ],
          turnComplete: true,
        },
      }),
    );

    const raw = await ws.waitForMessages(3); // setupComplete + content + turnComplete
    const contentMsg = JSON.parse(raw[1]);
    expect(contentMsg.serverContent).toBeDefined();

    // Small pause to ensure journal write completed
    await new Promise((r) => setTimeout(r, 50));

    // Inspect the journal to verify the generated tool_call_id starts with call_
    const entry = instance.journal.getLast();
    expect(entry).not.toBeNull();
    const messages = entry!.body!.messages;
    const toolMsg = messages.find((m) => m.role === "tool");
    expect(toolMsg).toBeDefined();
    expect(toolMsg!.tool_call_id).toMatch(/^call_/);
    // Verify it's a random ID (not the old deterministic format)
    expect(toolMsg!.tool_call_id).not.toMatch(/^call_gemini_/);

    ws.close();
  });

  it("handles setup with tools that have empty functionDeclarations", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(
      JSON.stringify({
        setup: {
          model: "gemini-2.0-flash-exp",
          tools: [{}], // No functionDeclarations
        },
      }),
    );

    const raw = await ws.waitForMessages(1);
    const msg = JSON.parse(raw[0]);
    expect(msg).toEqual({ setupComplete: {} });

    // Verify we can still send messages after setup with empty tools
    ws.send(clientContentMsg("hello"));
    const raw2 = await ws.waitForMessages(2);
    const msg2 = JSON.parse(raw2[1]);
    expect(msg2.serverContent).toBeDefined();

    ws.close();
  });

  it("handles unknown response type gracefully", async () => {
    const weirdFixture: Fixture = {
      match: { userMessage: "weird-response-gemini" },
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      response: { unknownField: "value" } as any,
    };
    instance = await createServer([weirdFixture]);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    ws.send(setupMsg());
    await ws.waitForMessages(1); // setupComplete

    ws.send(clientContentMsg("weird-response-gemini"));

    const raw = await ws.waitForMessages(2);
    const msg = JSON.parse(raw[1]);
    expect(msg.error).toBeDefined();
    expect(msg.error.code).toBe(13); // gRPC INTERNAL
    expect(msg.error.message).toBe("Fixture response did not match any known type");
    expect(msg.error.status).toBe("INTERNAL");

    ws.close();
  });

  it("returns error when message sent before setup", async () => {
    instance = await createServer(allFixtures);
    const ws = await connectWebSocket(instance.url, GEMINI_WS_PATH);

    // Send clientContent without setup first
    ws.send(clientContentMsg("hello"));

    const raw = await ws.waitForMessages(1);
    const msg = JSON.parse(raw[0]);
    expect(msg.error).toBeDefined();
    expect(msg.error.code).toBe(9); // gRPC FAILED_PRECONDITION
    expect(msg.error.message).toBe("Setup required");
    expect(msg.error.status).toBe("FAILED_PRECONDITION");

    ws.close();
  });
});
