import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { callReflectionLlm, setReflectionRetrySleep } from './llm-client.js';
import type { ReflectionLlmConfig } from './llm-client.js';

const cfg: ReflectionLlmConfig = {
  endpoint: 'http://localhost:11434/v1',
  model: 'test-model',
};

const validResult = {
  memory_changes: [],
  piece_changes: { should_edit: false },
  reasoning: 'x',
};

/**
 * The reflection client now routes through OpenAICompatClient, which speaks
 * streaming SSE. Build a fake streaming `Response` that emits the given SSE
 * `data:` payloads, then `[DONE]`.
 */
function sseResponse(chunks: unknown[]): Response {
  const lines = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`);
  lines.push('data: [DONE]\n\n');
  const encoder = new TextEncoder();
  let i = 0;
  return {
    ok: true,
    status: 200,
    headers: { get: () => null },
    body: {
      getReader: () => ({
        read: async () =>
          i < lines.length
            ? { done: false, value: encoder.encode(lines[i++]) }
            : { done: true, value: undefined },
        releaseLock: () => {},
      }),
    },
  } as unknown as Response;
}

/** A complete, valid submit_reflection tool-call stream with usage. */
function okStream(args: unknown = validResult): Response {
  return sseResponse([
    {
      model: 'test-model',
      choices: [
        {
          delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'submit_reflection', arguments: JSON.stringify(args) } }] },
          finish_reason: null,
        },
      ],
    },
    { choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
    { choices: [], usage: { prompt_tokens: 42, completion_tokens: 17 } },
  ]);
}

function httpError(status: number, bodyText: string): Response {
  return {
    ok: false,
    status,
    headers: { get: () => null },
    text: () => Promise.resolve(bodyText),
  } as unknown as Response;
}

beforeEach(() => {
  // No real backoff sleeps in tests.
  setReflectionRetrySleep(async () => {});
});

afterEach(() => {
  vi.unstubAllGlobals();
});

describe('callReflectionLlm', () => {
  it('happy path: parses tool_call arguments and extracts token usage', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okStream()));

    const result = await callReflectionLlm(cfg, 'system prompt', 'user prompt');

    expect(result.parsed.memory_changes).toEqual([]);
    expect(result.parsed.piece_changes.should_edit).toBe(false);
    expect(result.parsed.reasoning).toBe('x');
    expect(result.tokensIn).toBe(42);
    expect(result.tokensOut).toBe(17);
    expect(result.durationMs).toBeGreaterThanOrEqual(0);
  });

  it('reconstructs raw with the resolved tool_call + usage (#500)', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okStream()));
    const result = await callReflectionLlm(cfg, 'system', 'user');
    const raw = result.raw as { usage?: unknown; choices?: Array<{ message?: { tool_calls?: Array<{ function?: { name?: string; arguments?: string } }> } }> };
    expect(raw.usage).toEqual({ prompt_tokens: 42, completion_tokens: 17 });
    const tc = raw.choices?.[0]?.message?.tool_calls?.[0]?.function;
    expect(tc?.name).toBe('submit_reflection');
    expect(JSON.parse(tc!.arguments!)).toMatchObject({ reasoning: 'x' });
  });

  it('retries a 5xx (backend tool-call parse failure) and succeeds on resample', async () => {
    const fetchMock = vi.fn()
      .mockResolvedValueOnce(httpError(500, '{"error":{"message":"Failed to parse input at pos 41"}}'))
      .mockResolvedValueOnce(okStream());
    vi.stubGlobal('fetch', fetchMock);

    const result = await callReflectionLlm(cfg, 's', 'u');
    expect(result.parsed.reasoning).toBe('x');
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it('gives up after 3 attempts of persistent 5xx', async () => {
    const fetchMock = vi.fn().mockResolvedValue(httpError(500, 'parse error'));
    vi.stubGlobal('fetch', fetchMock);

    await expect(callReflectionLlm(cfg, 's', 'u')).rejects.toThrow('HTTP 500');
    expect(fetchMock).toHaveBeenCalledTimes(3);
  });

  it('does NOT retry a 4xx (deterministic config error, e.g. invalid api key)', async () => {
    const fetchMock = vi.fn().mockResolvedValue(httpError(401, 'invalid api key'));
    vi.stubGlobal('fetch', fetchMock);

    await expect(callReflectionLlm(cfg, 's', 'u')).rejects.toThrow('HTTP 401');
    expect(fetchMock).toHaveBeenCalledTimes(1);
  });

  it('retries when no tool_call present, then throws after exhaustion', async () => {
    // A stream that yields only text and finishes — no submit_reflection call.
    const noToolStream = () => sseResponse([
      { choices: [{ delta: { content: 'just text' }, finish_reason: 'stop' }] },
    ]);
    const fetchMock = vi.fn().mockImplementation(async () => noToolStream());
    vi.stubGlobal('fetch', fetchMock);

    await expect(callReflectionLlm(cfg, 'system prompt', 'user prompt'))
      .rejects.toThrow('no submit_reflection tool_call');
    expect(fetchMock).toHaveBeenCalledTimes(3);
  });

  it('does NOT retry a budget_exhausted gateway sentinel (fail fast)', async () => {
    // SSE sentinel error → client yields gatewayErrorType=budget_exhausted.
    const fetchMock = vi.fn().mockResolvedValue(
      sseResponse([{ error: { type: 'budget_exhausted', message: 'over quota' } }]),
    );
    vi.stubGlobal('fetch', fetchMock);

    await expect(callReflectionLlm(cfg, 's', 'u')).rejects.toThrow('budget_exhausted');
    expect(fetchMock).toHaveBeenCalledTimes(1);
  });

  it('does NOT retry a prompt-size preflight block (fail fast)', async () => {
    // Tiny context window forces the client preflight guard to block before
    // any fetch; resampling the identical prompt cannot help.
    const fetchMock = vi.fn().mockResolvedValue(okStream());
    vi.stubGlobal('fetch', fetchMock);

    await expect(callReflectionLlm({ ...cfg, contextLimitTokens: 1 }, 'system', 'user'))
      .rejects.toThrow('blocked before send');
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it('retries malformed tool_call arguments (client yields empty input)', async () => {
    // First stream carries broken JSON args → client parses to {} → structural
    // guard treats it as malformed → resample. Second stream is valid.
    const brokenStream = () => sseResponse([
      {
        choices: [
          { delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'submit_reflection', arguments: '{broken' } }] }, finish_reason: null },
        ],
      },
      { choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
    ]);
    const fetchMock = vi.fn()
      .mockImplementationOnce(async () => brokenStream())
      .mockImplementationOnce(async () => okStream());
    vi.stubGlobal('fetch', fetchMock);

    const result = await callReflectionLlm(cfg, 's', 'u');
    expect(result.parsed.reasoning).toBe('x');
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });
});
