/**
 * Usage recording at the OpenAICompatClient completion boundary.
 *
 * Verifies the single finalizeDone funnel:
 *  - a successful stream records exactly one event with model (chunk.model),
 *    tokens from usage, source=direct, route=endpoint host
 *  - a usage-less but successful stream still records (tokens 0)
 *  - an error stream records nothing (abort/timeout/error never `done`)
 *  - proxy mode records source=gateway, route=backendId (x-litellm-model-id)
 *  - no recorder installed = no-op (never throws)
 *
 * Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
 */
import { describe, it, expect, vi, afterEach } from 'vitest';
import { OpenAICompatClient } from './openai-compat.js';
import { getDefaultProviderRetryConfig } from '../config.js';
import { setLlmUsageRecorder, type LlmUsageEvent } from './usage-recorder.js';

const NO_RETRY = { ...getDefaultProviderRetryConfig(), maxAttempts: 1 };

function sseResponse(chunks: unknown[], headers: Record<string, string> = {}): 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: (k: string) => headers[k.toLowerCase()] ?? null },
    body: {
      getReader: () => ({
        read: async () =>
          i < lines.length
            ? { done: false, value: encoder.encode(lines[i++]) }
            : { done: true, value: undefined },
        releaseLock: () => {},
      }),
    },
  } as unknown as Response;
}

const textChunk = (model: string) => ({ model, choices: [{ delta: { content: 'hi' }, finish_reason: null }] });
const usageChunk = (pin: number, pout: number) => ({ choices: [], usage: { prompt_tokens: pin, completion_tokens: pout } });

async function drain(client: OpenAICompatClient, ctx?: { userId?: string }): Promise<void> {
  for await (const _ of client.chat([{ role: 'user', content: 'q' }], undefined, undefined, ctx)) {
    // discard
  }
}

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

describe('LLM usage recording', () => {
  it('records one direct event with model, tokens, and endpoint-host route', async () => {
    const events: LlmUsageEvent[] = [];
    setLlmUsageRecorder((e) => events.push(e));
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([textChunk('llama-3.1-8b'), usageChunk(120, 30)])));

    const client = new OpenAICompatClient('http://gpu-1.local:11434/v1', 'role-auto');
    await drain(client, { userId: 'u1' });

    expect(events).toHaveLength(1);
    expect(events[0]).toEqual({
      userId: 'u1', source: 'direct', model: 'llama-3.1-8b', route: 'gpu-1.local:11434',
      tokensIn: 120, tokensOut: 30,
    });
  });

  it('records a usage-less success with zero tokens', async () => {
    const events: LlmUsageEvent[] = [];
    setLlmUsageRecorder((e) => events.push(e));
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([textChunk('m')])));

    await drain(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' });
    expect(events).toHaveLength(1);
    expect(events[0]).toMatchObject({ tokensIn: 0, tokensOut: 0 });
  });

  it('falls back userId to system and model to routing key', async () => {
    const events: LlmUsageEvent[] = [];
    setLlmUsageRecorder((e) => events.push(e));
    // No `model` field in the chunk → fall back to the client's routing key.
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([{ choices: [{ delta: { content: 'x' }, finish_reason: null }] }, usageChunk(1, 1)])));

    await drain(new OpenAICompatClient('http://h:1/v1', 'routing-key')); // no context
    expect(events[0]).toMatchObject({ userId: 'system', model: 'routing-key' });
  });

  it('records nothing on an error stream', async () => {
    const events: LlmUsageEvent[] = [];
    setLlmUsageRecorder((e) => events.push(e));
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
      ok: false, status: 500, headers: { get: () => null }, text: () => Promise.resolve('boom'),
    } as unknown as Response));

    await drain(new OpenAICompatClient('http://h:1/v1', 'm', undefined, NO_RETRY), { userId: 'u1' });
    expect(events).toHaveLength(0);
  });

  it('records source=gateway with backendId route in proxy mode', async () => {
    const events: LlmUsageEvent[] = [];
    setLlmUsageRecorder((e) => events.push(e));
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
      sseResponse([textChunk('qwen-72b'), usageChunk(5, 2)], { 'x-litellm-model-id': 'pool-a-node-3' }),
    ));

    const client = new OpenAICompatClient(
      'http://gateway:4000/v1', 'role-quality', undefined, undefined, undefined, undefined, undefined, undefined,
      { proxy: true },
    );
    await drain(client, { userId: 'u9' });

    expect(events).toHaveLength(1);
    expect(events[0]).toMatchObject({ source: 'gateway', model: 'qwen-72b', route: 'pool-a-node-3' });
  });

  it('is a no-op when no recorder is installed', async () => {
    setLlmUsageRecorder(null);
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([textChunk('m'), usageChunk(1, 1)])));
    await expect(drain(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' })).resolves.toBeUndefined();
  });
});

// --- issue #498: don't record unknown completions ---

/** Raw SSE response with full control over whether [DONE] is sent. */
function rawSse(chunks: unknown[], withDone: boolean): Response {
  const lines = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`);
  if (withDone) 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;
}

async function collect(client: OpenAICompatClient, ctx?: { userId?: string }) {
  const out: string[] = [];
  for await (const e of client.chat([{ role: 'user', content: 'q' }], undefined, undefined, ctx)) out.push(e.type);
  return out;
}

describe('LLM usage recording — unknown completions (#498)', () => {
  it('does NOT record an EOF with no chunks and no [DONE]', async () => {
    const events: LlmUsageEvent[] = [];
    setLlmUsageRecorder((e) => events.push(e));
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(rawSse([], false)));
    const types = await collect(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' });
    expect(events).toHaveLength(0);
    expect(types[types.length - 1]).toBe('done'); // still terminates gracefully
  });

  it('DOES record an EOF (no [DONE]) once a real chunk arrived', async () => {
    const events: LlmUsageEvent[] = [];
    setLlmUsageRecorder((e) => events.push(e));
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(rawSse([textChunk('m'), usageChunk(3, 2)], false)));
    await drain(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' });
    expect(events).toHaveLength(1);
    expect(events[0]).toMatchObject({ tokensIn: 3, tokensOut: 2 });
  });

  it('does NOT record an error-only stream that EOFs without [DONE]', async () => {
    // An unknown error payload alone is not a real chunk; if the stream then
    // closes without [DONE], it must not be counted as a request.
    const events: LlmUsageEvent[] = [];
    setLlmUsageRecorder((e) => events.push(e));
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(rawSse([{ error: { type: 'mystery', message: 'kaboom' } }], false)));
    const types = await collect(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' });
    expect(events).toHaveLength(0);
    expect(types[types.length - 1]).toBe('done'); // unknown error falls through, EOF → done
  });
});
