import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { upsert, findByName, del, McpdHttpError } from '../src/engine';
import { startMockMcpd, type MockMcpd } from './mock-mcpd';

let mock: MockMcpd;
const conn = () => ({ url: mock.baseUrl, token: 'test-token' });

beforeEach(async () => {
  mock = await startMockMcpd();
});
afterEach(async () => {
  await mock.close();
});

describe('engine.upsert', () => {
  it('creates when absent: GET lookup then POST full spec', async () => {
    const row = await upsert(conn(), 'llms', { name: 'qwen', type: 'openai', model: 'qwen3' }, ['name', 'type']);
    expect(row.id).toMatch(/^cmock/);
    const methods = mock.requests.map((r) => `${r.method} ${r.path}`);
    expect(methods).toEqual(['GET /api/v1/llms', 'POST /api/v1/llms']);
    const post = mock.requests.find((r) => r.method === 'POST')!;
    expect(post.body).toEqual({ name: 'qwen', type: 'openai', model: 'qwen3' });
  });

  it('updates when present: PUT with immutable name+type STRIPPED', async () => {
    mock.seed('llms', { id: 'cmock9', name: 'qwen', type: 'openai', model: 'old' });
    await upsert(conn(), 'llms', { name: 'qwen', type: 'openai', model: 'new-model' }, ['name', 'type']);
    const put = mock.requests.find((r) => r.method === 'PUT')!;
    expect(put.path).toBe('/api/v1/llms/cmock9');
    // The immutability contract: name and type must NOT be in the PUT body.
    expect(put.body).toEqual({ model: 'new-model' });
    expect(mock.store['llms']!.get('cmock9')).toMatchObject({ model: 'new-model', type: 'openai' });
  });

  it('sends Authorization: Bearer on every call', async () => {
    await upsert(conn(), 'llms', { name: 'q', type: 'openai', model: 'm' }, ['name', 'type']);
    expect(mock.requests.every((r) => r.auth === 'Bearer test-token')).toBe(true);
  });

  it('retries on 429 then succeeds', async () => {
    mock.failNextWith(429, 1); // first request (the GET lookup) 429s once
    const row = await upsert(conn(), 'llms', { name: 'q', type: 'openai', model: 'm' }, ['name', 'type']);
    expect(row.id).toMatch(/^cmock/);
    expect(mock.requests.filter((r) => r.method === 'GET').length).toBe(2); // retried
  });
});

describe('engine.findByName / del', () => {
  it('findByName returns null when absent', async () => {
    expect(await findByName(conn(), 'llms', 'nope')).toBeNull();
  });

  it('del is idempotent when the row is already gone', async () => {
    await expect(del(conn(), 'llms', 'ghost')).resolves.toBeUndefined();
    expect(mock.requests.some((r) => r.method === 'DELETE')).toBe(false);
  });

  it('del removes an existing row by resolved id', async () => {
    mock.seed('llms', { id: 'cmock3', name: 'gone', type: 'openai', model: 'm' });
    await del(conn(), 'llms', 'gone');
    expect(mock.store['llms']!.has('cmock3')).toBe(false);
  });
});

describe('engine error surface', () => {
  it('throws McpdHttpError on a 500', async () => {
    mock.failNextWith(500, 10);
    await expect(findByName(conn(), 'llms', 'x')).rejects.toBeInstanceOf(McpdHttpError);
  });
});
