import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { OpenAICompatClient, type LLMEvent, type Message } from './openai-compat.js';

function createSSEResponse(chunks: string[], shouldError = false, errorAfterChunks = 0): Response {
  let chunkIndex = 0;
  const encoder = new TextEncoder();
  const stream = new ReadableStream<Uint8Array>({
    pull(controller) {
      if (shouldError && chunkIndex >= errorAfterChunks) {
        controller.error(new Error('Connection reset'));
        return;
      }
      if (chunkIndex < chunks.length) {
        controller.enqueue(encoder.encode(chunks[chunkIndex] + '\n'));
        chunkIndex++;
      } else {
        controller.close();
      }
    },
  });
  return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
}

function makeSseResponse(chunks: Array<Record<string, unknown> | '[DONE]'>): Response {
  const encoder = new TextEncoder();
  const stream = new ReadableStream<Uint8Array>({
    start(controller) {
      for (const chunk of chunks) {
        const data = chunk === '[DONE]' ? chunk : JSON.stringify(chunk);
        controller.enqueue(encoder.encode(`data: ${data}\n\n`));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    status: 200,
    headers: { 'Content-Type': 'text/event-stream' },
  });
}

async function collectEvents(client: OpenAICompatClient, messages: Message[], tools?: undefined, externalSignal?: AbortSignal): Promise<LLMEvent[]> {
  const events: LLMEvent[] = [];
  for await (const event of client.chat(messages, tools, externalSignal)) {
    events.push(event);
  }
  return events;
}

describe('OpenAICompatClient retry', () => {
  afterEach(() => {
    vi.restoreAllMocks();
    vi.unstubAllGlobals();
  });

  it('retries retryable HTTP responses and continues streaming on success', async () => {
    const fetchMock = vi.fn()
      .mockResolvedValueOnce(new Response('busy', { status: 503 }))
      .mockResolvedValueOnce(makeSseResponse([
        { choices: [{ delta: { content: 'hello' } }] },
        '[DONE]',
      ]));
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient(
      'http://llm.test/v1',
      'test-model',
      undefined,
      { maxAttempts: 2, backoffMs: [0], retryableStatus: [503] },
    );

    const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]);

    expect(fetchMock).toHaveBeenCalledTimes(2);
    expect(events).toEqual([
      { type: 'text', text: 'hello' },
      { type: 'done', usage: undefined },
    ]);
  });

  it('retries transient fetch errors', async () => {
    const fetchMock = vi.fn()
      .mockRejectedValueOnce(new TypeError('socket hang up'))
      .mockResolvedValueOnce(makeSseResponse([
        { choices: [{ delta: { content: 'ok' } }] },
        '[DONE]',
      ]));
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient(
      'http://llm.test/v1',
      'test-model',
      undefined,
      { maxAttempts: 2, backoffMs: [0], retryableStatus: [503] },
    );

    const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]);

    expect(fetchMock).toHaveBeenCalledTimes(2);
    expect(events).toEqual([
      { type: 'text', text: 'ok' },
      { type: 'done', usage: undefined },
    ]);
  });

  it('does not retry non-retryable HTTP responses', async () => {
    const fetchMock = vi.fn().mockResolvedValue(new Response('bad request', { status: 400 }));
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient(
      'http://llm.test/v1',
      'test-model',
      undefined,
      { maxAttempts: 3, backoffMs: [0, 0], retryableStatus: [503] },
    );

    const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]);

    expect(fetchMock).toHaveBeenCalledTimes(1);
    expect(events).toEqual([
      { type: 'error', error: 'HTTP 400: bad request' },
    ]);
  });

  it('blocks oversized requests before fetch', async () => {
    const fetchMock = vi.fn();
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient(
      'http://llm.test/v1',
      'test-model',
      undefined,
      { maxAttempts: 3, backoffMs: [0, 0], retryableStatus: [503] },
      10_000,
      1_000,
    );

    const events = await collectEvents(client, [{ role: 'user', content: 'x'.repeat(4_000) }]);

    expect(fetchMock).not.toHaveBeenCalled();
    expect(events).toEqual([
      expect.objectContaining({
        type: 'error',
        error: expect.stringContaining('LLM request blocked before send'),
      }),
    ]);
  });

  it('allows requests that are only slightly above the old conservative cap', async () => {
    const fetchMock = vi.fn().mockResolvedValue(makeSseResponse([
      { choices: [{ delta: { content: 'ok' } }] },
      '[DONE]',
    ]));
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient(
      'http://llm.test/v1',
      'test-model',
      undefined,
      { maxAttempts: 1, backoffMs: [0], retryableStatus: [503] },
      10_000,
      32_000,
    );

    const events = await collectEvents(client, [{ role: 'user', content: 'x'.repeat(15_950) }]);

    expect(fetchMock).toHaveBeenCalledTimes(1);
    expect(events).toEqual([
      { type: 'text', text: 'ok' },
      { type: 'done', usage: undefined },
    ]);
  });

  it('uses the provided context limit directly when larger than the legacy 32k cap', async () => {
    const fetchMock = vi.fn().mockResolvedValue(makeSseResponse([
      { choices: [{ delta: { content: 'ok' } }] },
      '[DONE]',
    ]));
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient(
      'http://llm.test/v1',
      'test-model',
      undefined,
      { maxAttempts: 1, backoffMs: [0], retryableStatus: [503] },
      10_000,
      200_000,
    );

    // 30k ASCII chars are well under 200_000 * 0.8 = 160_000.
    // Before the cap was removed, this fired the 32k preflight guard.
    const events = await collectEvents(client, [{ role: 'user', content: 'x'.repeat(30_000) }]);

    expect(fetchMock).toHaveBeenCalledTimes(1);
    expect(events).toEqual([
      { type: 'text', text: 'ok' },
      { type: 'done', usage: undefined },
    ]);
  });

  it('still blocks prompts that exceed the provided context limit', async () => {
    const fetchMock = vi.fn();
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient(
      'http://llm.test/v1',
      'test-model',
      undefined,
      { maxAttempts: 1, backoffMs: [0], retryableStatus: [503] },
      10_000,
      200_000,
    );

    // 200_000 * 0.8 = 160_000 max prompt.
    const events = await collectEvents(client, [{ role: 'user', content: 'x'.repeat(600_000) }]);

    expect(fetchMock).not.toHaveBeenCalled();
    expect(events).toEqual([
      expect.objectContaining({
        type: 'error',
        error: expect.stringContaining('context 200,000'),
      }),
    ]);
  });

  it('counts image_url parts as fixed image cost instead of data URL text', async () => {
    const fetchMock = vi.fn().mockResolvedValue(makeSseResponse([
      { choices: [{ delta: { content: 'ok' } }] },
      '[DONE]',
    ]));
    vi.stubGlobal('fetch', fetchMock);
    const preflightLines: string[] = [];

    const client = new OpenAICompatClient(
      'http://llm.test/v1',
      'test-model',
      undefined,
      { maxAttempts: 1, backoffMs: [0], retryableStatus: [503] },
      10_000,
      32_000,
      0.8,
      (line) => preflightLines.push(line),
    );

    const events = await collectEvents(client, [{
      role: 'user',
      content: [
        { type: 'text', text: 'inspect this' },
        { type: 'image_url', image_url: { url: `data:image/png;base64,${'A'.repeat(200_000)}` } },
      ],
    }]);

    expect(fetchMock).toHaveBeenCalledTimes(1);
    expect(events).toEqual([
      { type: 'text', text: 'ok' },
      { type: 'done', usage: undefined },
    ]);
    expect(preflightLines[0]).toContain('images=1,imageTokenCost=1024');
    expect(preflightLines[0]).toContain('requestJsonChars=200,');
  });
});

describe('OpenAICompatClient stream retry', () => {
  let fetchMock: ReturnType<typeof vi.fn>;
  const originalFetch = globalThis.fetch;

  beforeEach(() => {
    fetchMock = vi.fn();
    globalThis.fetch = fetchMock;
  });
  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  const retryConfig = {
    maxAttempts: 3,
    backoffMs: [0, 0, 0],
    retryableStatus: [500],
  };

  it('正常なストリームを処理する', async () => {
    fetchMock.mockResolvedValueOnce(createSSEResponse([
      'data: {"choices":[{"delta":{"content":"hello"},"finish_reason":null}]}',
      'data: [DONE]',
    ]));
    const client = new OpenAICompatClient('http://test', 'model', undefined, retryConfig);
    const events = [];
    for await (const event of client.chat([{ role: 'user', content: 'hi' }])) {
      events.push(event);
    }
    expect(events).toContainEqual({ type: 'text', text: 'hello' });
    expect(events).toContainEqual({ type: 'done', usage: undefined });
  });

  it('tool_calls を正しく蓄積して emit する', async () => {
    fetchMock.mockResolvedValueOnce(createSSEResponse([
      'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"Read","arguments":""}}]},"finish_reason":null}]}',
      'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\":\\"test\\"}"}}]},"finish_reason":null}]}',
      'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}',
      'data: [DONE]',
    ]));
    const client = new OpenAICompatClient('http://test', 'model', undefined, retryConfig);
    const events = [];
    for await (const event of client.chat([{ role: 'user', content: 'hi' }])) {
      events.push(event);
    }
    const toolEvent = events.find(e => e.type === 'tool_use');
    expect(toolEvent).toBeDefined();
    expect(toolEvent).toMatchObject({
      type: 'tool_use',
      id: 'call_1',
      name: 'Read',
      input: { path: 'test' },
    });
  });

  it('emits tool_use_delta snapshots (full accumulated args) while streaming', async () => {
    fetchMock.mockResolvedValueOnce(createSSEResponse([
      'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"Write","arguments":""}}]},"finish_reason":null}]}',
      'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"content\\":\\"ab"}}]},"finish_reason":null}]}',
      'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"cd\\"}"}}]},"finish_reason":null}]}',
      'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}',
      'data: [DONE]',
    ]));
    const client = new OpenAICompatClient('http://test', 'model', undefined, retryConfig);
    const events = [];
    for await (const event of client.chat([{ role: 'user', content: 'hi' }])) {
      events.push(event);
    }
    const deltas = events.filter(e => e.type === 'tool_use_delta');
    expect(deltas).toHaveLength(2);
    // Each delta carries the FULL accumulated args so far (snapshot), so a
    // late-attaching client always receives the opening JSON structure.
    expect(deltas[0]).toMatchObject({ type: 'tool_use_delta', index: 0, callId: 'call_1', name: 'Write', chunk: '{"content":"ab' });
    expect(deltas[1]).toMatchObject({ type: 'tool_use_delta', index: 0, callId: 'call_1', name: 'Write', chunk: '{"content":"abcd"}' });
    // Final aggregated tool_use must still be emitted unchanged
    const toolEvent = events.find(e => e.type === 'tool_use');
    expect(toolEvent).toMatchObject({ type: 'tool_use', id: 'call_1', name: 'Write', input: { content: 'abcd' } });
  });

  it('ストリーム途中エラーでリトライし完了する', async () => {
    // 1回目: 1チャンク後にエラー
    fetchMock.mockResolvedValueOnce(
      createSSEResponse([
        'data: {"choices":[{"delta":{"content":"partial"},"finish_reason":null}]}',
      ], true, 1)
    );
    // 2回目: 正常完了
    fetchMock.mockResolvedValueOnce(createSSEResponse([
      'data: {"choices":[{"delta":{"content":"hello"},"finish_reason":null}]}',
      'data: [DONE]',
    ]));

    const client = new OpenAICompatClient('http://test', 'model', undefined, retryConfig);
    const events = [];
    for await (const event of client.chat([{ role: 'user', content: 'hi' }])) {
      events.push(event);
    }
    expect(events.some(e => e.type === 'done')).toBe(true);
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it('maxAttempts 超過でエラーを返す', async () => {
    // 全試行でストリームエラー
    for (let i = 0; i < 3; i++) {
      fetchMock.mockResolvedValueOnce(
        createSSEResponse([
          'data: {"choices":[{"delta":{"content":"x"},"finish_reason":null}]}',
        ], true, 1)
      );
    }

    const client = new OpenAICompatClient('http://test', 'model', undefined, {
      maxAttempts: 3,
      backoffMs: [0, 0, 0],
      retryableStatus: [500],
    });
    const events = [];
    for await (const event of client.chat([{ role: 'user', content: 'hi' }])) {
      events.push(event);
    }
    expect(events.some(e => e.type === 'error')).toBe(true);
    expect(fetchMock).toHaveBeenCalledTimes(3);
  });

  it('fetch 接続エラーでリトライする', async () => {
    fetchMock.mockRejectedValueOnce(new Error('ECONNREFUSED'));
    fetchMock.mockResolvedValueOnce(createSSEResponse([
      'data: {"choices":[{"delta":{"content":"ok"},"finish_reason":null}]}',
      'data: [DONE]',
    ]));

    const client = new OpenAICompatClient('http://test', 'model', undefined, retryConfig);
    const events = [];
    for await (const event of client.chat([{ role: 'user', content: 'hi' }])) {
      events.push(event);
    }
    expect(events.some(e => e.type === 'done')).toBe(true);
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it('HTTP 500 でリトライする', async () => {
    fetchMock.mockResolvedValueOnce(new Response('Internal Server Error', { status: 500 }));
    fetchMock.mockResolvedValueOnce(createSSEResponse([
      'data: {"choices":[{"delta":{"content":"ok"},"finish_reason":null}]}',
      'data: [DONE]',
    ]));

    const client = new OpenAICompatClient('http://test', 'model', undefined, retryConfig);
    const events = [];
    for await (const event of client.chat([{ role: 'user', content: 'hi' }])) {
      events.push(event);
    }
    expect(events.some(e => e.type === 'done')).toBe(true);
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });
});

describe('OpenAICompatClient external AbortSignal', () => {
  afterEach(() => {
    vi.restoreAllMocks();
    vi.unstubAllGlobals();
  });

  it('returns cancelled error immediately when external signal is already aborted', async () => {
    const fetchMock = vi.fn();
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient('http://llm.test/v1', 'test-model');
    const abortController = new AbortController();
    abortController.abort();

    const events = await collectEvents(client, [{ role: 'user', content: 'hi' }], undefined, abortController.signal);

    expect(fetchMock).not.toHaveBeenCalled();
    expect(events).toEqual([
      { type: 'error', error: 'Request cancelled by caller' },
    ]);
  });

  it('includes dynamic timeout minutes in error message (default 10 minutes)', async () => {
    // Use a short timeout so the test completes quickly
    const timeoutMs = 100;
    const abortError = new DOMException('The operation was aborted', 'AbortError');
    const fetchMock = vi.fn().mockRejectedValue(abortError);
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient(
      'http://llm.test/v1',
      'test-model',
      undefined,
      { maxAttempts: 1, backoffMs: [0], retryableStatus: [] },
      timeoutMs,
    );

    const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]);

    expect(events).toEqual([
      { type: 'error', error: 'Request timed out (0 minutes)' },
    ]);
  });

  it('shows correct minutes for custom timeout', async () => {
    const timeoutMs = 5 * 60 * 1000; // 5 minutes
    const abortError = new DOMException('The operation was aborted', 'AbortError');
    const fetchMock = vi.fn().mockRejectedValue(abortError);
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient(
      'http://llm.test/v1',
      'test-model',
      undefined,
      { maxAttempts: 1, backoffMs: [0], retryableStatus: [] },
      timeoutMs,
    );

    const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]);

    expect(events).toEqual([
      { type: 'error', error: 'Request timed out (5 minutes)' },
    ]);
  });
});

describe('OpenAICompatClient proxy backend headers', () => {
  afterEach(() => {
    vi.restoreAllMocks();
    vi.unstubAllGlobals();
  });

  function makeSseResponseWithHeaders(
    chunks: Array<Record<string, unknown> | '[DONE]'>,
    headers: Record<string, string>,
  ): Response {
    const encoder = new TextEncoder();
    const stream = new ReadableStream<Uint8Array>({
      start(controller) {
        for (const chunk of chunks) {
          const data = chunk === '[DONE]' ? chunk : JSON.stringify(chunk);
          controller.enqueue(encoder.encode(`data: ${data}\n\n`));
        }
        controller.close();
      },
    });
    return new Response(stream, {
      status: 200,
      headers: { 'Content-Type': 'text/event-stream', ...headers },
    });
  }

  it('emits a backend event when proxy=true and x-litellm-model-id header is present', async () => {
    const fetchMock = vi.fn().mockResolvedValue(
      makeSseResponseWithHeaders(
        [{ choices: [{ delta: { content: 'ok' } }] }, '[DONE]'],
        { 'x-litellm-model-id': 'gpu-rtx-a' },
      ),
    );
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient(
      'http://litellm.test/v1',
      'qwen3:8b',
      undefined,
      { maxAttempts: 1, backoffMs: [0], retryableStatus: [] },
      undefined,
      undefined,
      undefined,
      undefined,
      { proxy: true },
    );

    const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]);
    expect(events).toEqual([
      { type: 'backend', backendId: 'gpu-rtx-a', cacheKey: null },
      { type: 'text', text: 'ok' },
      { type: 'done', usage: undefined },
    ]);
  });

  it('includes cacheKey when x-litellm-cache-key is present', async () => {
    const fetchMock = vi.fn().mockResolvedValue(
      makeSseResponseWithHeaders(
        [{ choices: [{ delta: { content: 'cached' } }] }, '[DONE]'],
        { 'x-litellm-model-id': 'gpu-h100-b', 'x-litellm-cache-key': 'sha:abc123' },
      ),
    );
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient(
      'http://litellm.test/v1',
      undefined,
      undefined,
      { maxAttempts: 1, backoffMs: [0], retryableStatus: [] },
      undefined,
      undefined,
      undefined,
      undefined,
      { proxy: true },
    );

    const events = await collectEvents(client, [{ role: 'user', content: 'q' }]);
    expect(events[0]).toEqual({ type: 'backend', backendId: 'gpu-h100-b', cacheKey: 'sha:abc123' });
  });

  it('does not emit a backend event when proxy=false (direct worker)', async () => {
    const fetchMock = vi.fn().mockResolvedValue(
      makeSseResponseWithHeaders(
        [{ choices: [{ delta: { content: 'ok' } }] }, '[DONE]'],
        // Even if the upstream happens to set the header, direct mode must ignore it.
        { 'x-litellm-model-id': 'should-be-ignored' },
      ),
    );
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient(
      'http://gpu.test/v1',
      'qwen3:8b',
      undefined,
      { maxAttempts: 1, backoffMs: [0], retryableStatus: [] },
    );

    const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]);
    expect(events.find(e => e.type === 'backend')).toBeUndefined();
  });

  it('does not emit a backend event when proxy=true but header is missing', async () => {
    const fetchMock = vi.fn().mockResolvedValue(
      makeSseResponseWithHeaders(
        [{ choices: [{ delta: { content: 'ok' } }] }, '[DONE]'],
        {},
      ),
    );
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient(
      'http://litellm.test/v1',
      undefined,
      undefined,
      { maxAttempts: 1, backoffMs: [0], retryableStatus: [] },
      undefined,
      undefined,
      undefined,
      undefined,
      { proxy: true },
    );

    const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]);
    expect(events.find(e => e.type === 'backend')).toBeUndefined();
  });

  it('trims whitespace from x-litellm-model-id and x-litellm-cache-key', async () => {
    // config-api.ts trims its side too; without symmetric trim here, the
    // backend id keyed by the worker would never match what the UI shows.
    const fetchMock = vi.fn().mockResolvedValue(
      makeSseResponseWithHeaders(
        [{ choices: [{ delta: { content: 'ok' } }] }, '[DONE]'],
        { 'x-litellm-model-id': '  gpu-a  ', 'x-litellm-cache-key': '  sha:xyz  ' },
      ),
    );
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient(
      'http://litellm.test/v1',
      undefined,
      undefined,
      { maxAttempts: 1, backoffMs: [0], retryableStatus: [] },
      undefined,
      undefined,
      undefined,
      undefined,
      { proxy: true },
    );

    const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]);
    expect(events[0]).toEqual({ type: 'backend', backendId: 'gpu-a', cacheKey: 'sha:xyz' });
  });

  it('does not emit a backend event when x-litellm-model-id is whitespace-only', async () => {
    const fetchMock = vi.fn().mockResolvedValue(
      makeSseResponseWithHeaders(
        [{ choices: [{ delta: { content: 'ok' } }] }, '[DONE]'],
        { 'x-litellm-model-id': '   ' },
      ),
    );
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient(
      'http://litellm.test/v1',
      undefined,
      undefined,
      { maxAttempts: 1, backoffMs: [0], retryableStatus: [] },
      undefined,
      undefined,
      undefined,
      undefined,
      { proxy: true },
    );

    const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]);
    expect(events.find(e => e.type === 'backend')).toBeUndefined();
  });

  it('drops whitespace-only cacheKey to null while keeping a valid backendId', async () => {
    const fetchMock = vi.fn().mockResolvedValue(
      makeSseResponseWithHeaders(
        [{ choices: [{ delta: { content: 'ok' } }] }, '[DONE]'],
        { 'x-litellm-model-id': 'gpu-a', 'x-litellm-cache-key': '   ' },
      ),
    );
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient(
      'http://litellm.test/v1',
      undefined,
      undefined,
      { maxAttempts: 1, backoffMs: [0], retryableStatus: [] },
      undefined,
      undefined,
      undefined,
      undefined,
      { proxy: true },
    );

    const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]);
    expect(events[0]).toEqual({ type: 'backend', backendId: 'gpu-a', cacheKey: null });
  });
});

describe('OpenAICompatClient gateway sentinel error events', () => {
  afterEach(() => {
    vi.restoreAllMocks();
    vi.unstubAllGlobals();
  });

  function makeSseResponseWithChunks(chunks: Array<string>): Response {
    const encoder = new TextEncoder();
    const stream = new ReadableStream<Uint8Array>({
      start(controller) {
        for (const chunk of chunks) {
          controller.enqueue(encoder.encode(`data: ${chunk}\n\n`));
        }
        controller.close();
      },
    });
    return new Response(stream, {
      status: 200,
      headers: { 'Content-Type': 'text/event-stream' },
    });
  }

  for (const errType of ['gateway_shutdown', 'gateway_timeout', 'budget_exhausted', 'rate_limited'] as const) {
    it(`emits structured error event with gatewayErrorType=${errType}`, async () => {
      const fetchMock = vi.fn(async () =>
        makeSseResponseWithChunks([
          JSON.stringify({ choices: [{ delta: { content: 'partial' } }] }),
          JSON.stringify({ error: { type: errType, message: 'server says nope' } }),
        ]),
      );
      vi.stubGlobal('fetch', fetchMock);

      const client = new OpenAICompatClient(
        'http://gateway.test/v1',
        undefined,
        undefined,
        { maxAttempts: 1, backoffMs: [0], retryableStatus: [] },
      );

      const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]);
      // partial text event then structured error, no [DONE]
      const errorEvent = events.find(e => e.type === 'error');
      expect(errorEvent).toBeDefined();
      expect((errorEvent as { gatewayErrorType?: string }).gatewayErrorType).toBe(errType);
      expect((errorEvent as { error: string }).error).toContain(errType);
    });
  }

  it('unknown error.type falls through to generic stream parse (no early return)', async () => {
    const fetchMock = vi.fn(async () =>
      makeSseResponseWithChunks([
        JSON.stringify({ error: { type: 'mystery_error', message: 'x' } }),
        JSON.stringify({ choices: [{ delta: { content: 'ok' } }] }),
        '[DONE]',
      ]),
    );
    vi.stubGlobal('fetch', fetchMock);

    const client = new OpenAICompatClient(
      'http://gateway.test/v1',
      undefined,
      undefined,
      { maxAttempts: 1, backoffMs: [0], retryableStatus: [] },
    );

    const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]);
    // Unknown gateway error.type should not short-circuit — we keep parsing
    // and the normal text/done flow continues.
    expect(events.some(e => e.type === 'text' && e.text === 'ok')).toBe(true);
    expect(events.at(-1)?.type).toBe('done');
  });
});

describe('OpenAICompatClient tool-call flush at stream end', () => {
  afterEach(() => {
    vi.restoreAllMocks();
    vi.unstubAllGlobals();
  });

  // Some OpenAI-compat backends finish a forced/named tool call with
  // finish_reason 'stop' (or omit it) instead of 'tool_calls'. The client must
  // still surface the accumulated tool_use at the done boundary, not drop it.
  it('emits tool_use when the stream ends on finish_reason "stop" (via [DONE])', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeSseResponse([
      { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'do_it', arguments: '{"a":1}' } }] }, finish_reason: null }] },
      { choices: [{ delta: {}, finish_reason: 'stop' }] },
      '[DONE]',
    ])));
    const events = await collectEvents(new OpenAICompatClient('http://h:1/v1', 'm'), [{ role: 'user', content: 'q' }]);
    const toolUse = events.find((e) => e.type === 'tool_use');
    expect(toolUse).toMatchObject({ type: 'tool_use', name: 'do_it', input: { a: 1 } });
    // done still follows the tool_use
    expect(events[events.length - 1].type).toBe('done');
  });

  it('emits tool_use when the stream ends at EOF without [DONE]', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeSseResponse([
      { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c2', function: { name: 'fn', arguments: '{}' } }] }, finish_reason: null }] },
    ])));
    const events = await collectEvents(new OpenAICompatClient('http://h:1/v1', 'm'), [{ role: 'user', content: 'q' }]);
    expect(events.filter((e) => e.type === 'tool_use')).toHaveLength(1);
  });

  it('does not double-emit when finish_reason "tool_calls" already flushed', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeSseResponse([
      { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c3', function: { name: 'fn', arguments: '{}' } }] }, finish_reason: 'tool_calls' }] },
      '[DONE]',
    ])));
    const events = await collectEvents(new OpenAICompatClient('http://h:1/v1', 'm'), [{ role: 'user', content: 'q' }]);
    expect(events.filter((e) => e.type === 'tool_use')).toHaveLength(1);
  });
});
