import { afterEach, describe, expect, it, vi } from 'vitest';
import {
  fetchCatalog,
  fetchHarnesses,
  fetchPersistedSelection,
  persistSelection,
} from './chat-api';

function json(body: unknown, status = 200): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { 'Content-Type': 'application/json' },
  });
}

function stubFetch(): ReturnType<typeof vi.fn> {
  const fetchMock = vi.fn();
  vi.stubGlobal('fetch', fetchMock);
  return fetchMock;
}

/** Every URL the client actually requested, across all calls. */
function requestedUrls(fetchMock: ReturnType<typeof vi.fn>): string[] {
  return fetchMock.mock.calls.map((call) => String(call[0]));
}

describe('chat-api', () => {
  afterEach(() => {
    vi.unstubAllGlobals();
  });

  it('fetchHarnesses GETs /api/harnesses and returns typed summaries (harness id separate from provider)', async () => {
    const fetchMock = stubFetch();
    fetchMock.mockResolvedValue(
      json([
        { id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
        { id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
      ]),
    );

    const harnesses = await fetchHarnesses();

    expect(fetchMock).toHaveBeenCalledOnce();
    expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses');
    expect(harnesses).toEqual([
      { id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] },
      { id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] },
    ]);
  });

  it('fetchCatalog GETs the harness-scoped catalog and returns only its model entries', async () => {
    const fetchMock = stubFetch();
    fetchMock.mockResolvedValue(
      json({
        harnessId: 'pi',
        version: '2026-08-11',
        fingerprint: 'abc123',
        models: [
          {
            harnessId: 'pi',
            providerId: 'openai',
            modelId: 'gpt-5',
            displayName: 'GPT-5',
            reasoningCapability: true,
            inputTypes: ['text'],
            authState: 'ready',
            availability: 'available',
          },
        ],
      }),
    );

    const result = await fetchCatalog('pi');

    expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses/pi/catalog');
    expect(result.ok).toBe(true);
    if (!result.ok) throw new Error('expected ok catalog');
    expect(result.catalog.harnessId).toBe('pi');
    expect(result.catalog.models).toHaveLength(1);
    expect(result.catalog.models[0]).toMatchObject({
      harnessId: 'pi',
      providerId: 'openai',
      modelId: 'gpt-5',
      availability: 'available',
    });
  });

  it('normalizes a catalog 404 into a typed catalog_unavailable result without surfacing the raw body', async () => {
    const fetchMock = stubFetch();
    fetchMock.mockResolvedValue(
      json(
        {
          code: 'adapter_unavailable',
          message: 'raw gateway detail that must not leak verbatim',
          harnessId: 'attacker-echo',
          extra: { hostile: 'blob' },
        },
        404,
      ),
    );

    const result = await fetchCatalog('ghost');

    expect(result.ok).toBe(false);
    if (result.ok) throw new Error('expected unavailable result');
    expect(result.code).toBe('catalog_unavailable');
    // harnessId comes from the request, never the (untrusted) response body.
    expect(result.harnessId).toBe('ghost');
    expect(typeof result.message).toBe('string');
    // The raw response body is never rendered/returned verbatim.
    expect(JSON.stringify(result)).not.toContain('hostile');
    expect(JSON.stringify(result)).not.toContain('attacker-echo');
  });

  it('fetchPersistedSelection returns the stored tuple, or null when unset', async () => {
    const fetchMock = stubFetch();
    fetchMock.mockResolvedValueOnce(
      json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
    );
    await expect(fetchPersistedSelection()).resolves.toEqual({
      harnessId: 'pi',
      providerId: 'openai',
      modelId: 'gpt-5',
    });
    expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/chat/preferences/selection');

    fetchMock.mockResolvedValueOnce(json({ selection: null }));
    await expect(fetchPersistedSelection()).resolves.toBeNull();
  });

  it('persistSelection PUTs the structured tuple (not free text) and returns the confirmed selection', async () => {
    const fetchMock = stubFetch();
    fetchMock.mockResolvedValue(
      json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }),
    );

    const result = await persistSelection({
      harnessId: 'pi',
      providerId: 'openai',
      modelId: 'gpt-5',
    });

    expect(result.ok).toBe(true);
    const call = fetchMock.mock.calls[0];
    expect(String(call?.[0])).toBe('/api/chat/preferences/selection');
    const init = call?.[1] as RequestInit;
    expect(String(init.method).toUpperCase()).toBe('PUT');
    // The body is exactly the structured tuple — harness/provider/model kept distinct.
    expect(JSON.parse(String(init.body))).toEqual({
      harnessId: 'pi',
      providerId: 'openai',
      modelId: 'gpt-5',
    });
  });

  it('normalizes a selection 422 into a typed error preserving the requested tuple exactly', async () => {
    const fetchMock = stubFetch();
    fetchMock.mockResolvedValue(
      json(
        {
          code: 'model_unavailable',
          message: 'raw detail that must not leak',
          selection: { harnessId: 'x', providerId: 'y', modelId: 'z' },
        },
        422,
      ),
    );

    const requested = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' };
    const result = await persistSelection(requested);

    expect(result.ok).toBe(false);
    if (result.ok) throw new Error('expected failed persist');
    expect(['selection_invalid', 'model_unavailable']).toContain(result.code);
    // The requested tuple is preserved unchanged — not replaced by the body's echo.
    expect(result.requested).toEqual(requested);
    expect(JSON.stringify(result)).not.toContain('raw detail');
  });

  it('never requests any /api/providers* endpoint', async () => {
    const fetchMock = stubFetch();
    fetchMock.mockResolvedValue(json([]));
    await fetchHarnesses();
    fetchMock.mockResolvedValue(
      json({ harnessId: 'pi', version: '1', fingerprint: 'f', models: [] }),
    );
    await fetchCatalog('pi');
    fetchMock.mockResolvedValue(json({ selection: null }));
    await fetchPersistedSelection();

    for (const url of requestedUrls(fetchMock)) {
      expect(url).not.toContain('/api/providers');
    }
  });
});
