import { describe, it, expect, vi, afterEach } from 'vitest';
import { z } from 'zod';
import { complete } from './client';
import type { LlmConfig } from './types';

const base: LlmConfig = {
  enabled: true, provider: 'anthropic', model: 'claude-opus-4-8',
  baseUrl: 'https://api.anthropic.com', apiKey: 'secret-key-123', rememberKey: true,
};

function mockFetch(impl: (url: string, init: RequestInit) => Response | Promise<Response>) {
  const fn = vi.fn(impl);
  vi.stubGlobal('fetch', fn);
  return fn;
}
function anthropicReply(text: string) {
  return new Response(JSON.stringify({ content: [{ type: 'text', text }] }), { status: 200 });
}
function openaiReply(text: string) {
  return new Response(JSON.stringify({ choices: [{ message: { content: text } }] }), { status: 200 });
}

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

describe('complete — guards', () => {
  it('short-circuits when disabled without calling fetch', async () => {
    const fn = mockFetch(() => anthropicReply('hi'));
    const r = await complete({ ...base, enabled: false }, { system: 's', user: 'u' });
    expect(r).toEqual({ ok: false, error: 'disabled', message: expect.any(String) });
    expect(fn).not.toHaveBeenCalled();
  });
  it('reports no-key without calling fetch', async () => {
    const fn = mockFetch(() => anthropicReply('hi'));
    const r = await complete({ ...base, apiKey: '  ' }, { system: 's', user: 'u' });
    expect(r.ok).toBe(false);
    if (!r.ok) expect(r.error).toBe('no-key');
    expect(fn).not.toHaveBeenCalled();
  });
});

describe('complete — request shapes', () => {
  it('builds the Anthropic body + headers', async () => {
    const fn = mockFetch(() => anthropicReply('hello'));
    const r = await complete(base, { system: 'sys', user: 'hi' });
    expect(r).toEqual({ ok: true, text: 'hello' });
    const [url, init] = fn.mock.calls[0]!;
    expect(url).toBe('https://api.anthropic.com/v1/messages');
    const headers = init.headers as Record<string, string>;
    expect(headers['x-api-key']).toBe('secret-key-123');
    expect(headers['anthropic-version']).toBe('2023-06-01');
    expect(headers['anthropic-dangerous-direct-browser-access']).toBe('true');
    const body = JSON.parse(init.body as string);
    expect(body.model).toBe('claude-opus-4-8');
    expect(body.messages).toEqual([{ role: 'user', content: 'hi' }]);
  });

  it('builds the OpenAI body + headers, with json mode when a schema is given', async () => {
    const fn = mockFetch(() => openaiReply('{"n":5}'));
    const cfg: LlmConfig = { ...base, provider: 'openai-compatible', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o' };
    const r = await complete(cfg, { system: 'sys', user: 'hi', schema: z.object({ n: z.number() }) });
    expect(r).toEqual({ ok: true, data: { n: 5 } });
    const [url, init] = fn.mock.calls[0]!;
    expect(url).toBe('https://api.openai.com/v1/chat/completions');
    const headers = init.headers as Record<string, string>;
    expect(headers.authorization).toBe('Bearer secret-key-123');
    const body = JSON.parse(init.body as string);
    expect(body.response_format).toEqual({ type: 'json_object' });
    expect(body.messages[0].role).toBe('system');
  });
});

describe('complete — structured output', () => {
  const schema = z.object({ name: z.string() });
  it('parses JSON wrapped in markdown fences', async () => {
    mockFetch(() => anthropicReply('```json\n{"name":"Goblin"}\n```'));
    const r = await complete(base, { system: 's', user: 'u', schema });
    expect(r).toEqual({ ok: true, data: { name: 'Goblin' } });
  });
  it('returns parse error on malformed JSON', async () => {
    mockFetch(() => anthropicReply('not json at all'));
    const r = await complete(base, { system: 's', user: 'u', schema });
    expect(r.ok).toBe(false);
    if (!r.ok) expect(r.error).toBe('parse');
  });
  it('returns parse error when JSON does not match the schema', async () => {
    mockFetch(() => anthropicReply('{"wrong":1}'));
    const r = await complete(base, { system: 's', user: 'u', schema });
    expect(r.ok).toBe(false);
    if (!r.ok) expect(r.error).toBe('parse');
  });
});

describe('complete — errors never leak the key', () => {
  it('maps non-2xx to http', async () => {
    mockFetch(() => new Response('rate limited', { status: 429 }));
    const r = await complete(base, { system: 's', user: 'u' });
    expect(r.ok).toBe(false);
    if (!r.ok) {
      expect(r.error).toBe('http');
      expect(r.message).not.toContain('secret-key-123');
    }
  });
  it('maps fetch rejection to network', async () => {
    mockFetch(() => { throw new TypeError('Failed to fetch'); });
    const r = await complete(base, { system: 's', user: 'u' });
    expect(r.ok).toBe(false);
    if (!r.ok) {
      expect(r.error).toBe('network');
      expect(r.message).not.toContain('secret-key-123');
    }
  });
  it('maps abort/timeout', async () => {
    mockFetch((_url, init) => new Promise((_resolve, reject) => {
      init.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
    }));
    const r = await complete(base, { system: 's', user: 'u', timeoutMs: 10 });
    expect(r.ok).toBe(false);
    if (!r.ok) expect(r.error).toBe('timeout');
  });
});
