import { describe, it, expect, vi, afterEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerVirtualLlmRoutes } from '../src/routes/virtual-llms.js';
import type {
  VirtualLlmService,
  VirtualSessionHandle,
} from '../src/services/virtual-llm.service.js';

let app: FastifyInstance;

afterEach(async () => {
  if (app) await app.close();
});

function fakeService(overrides: Partial<VirtualLlmService> = {}): VirtualLlmService {
  return {
    register: vi.fn(async (input) => ({
      providerSessionId: input.providerSessionId ?? 'sess-generated',
      llms: [],
    })),
    heartbeat: vi.fn(async () => undefined),
    bindSession: vi.fn(),
    unbindSession: vi.fn(async () => undefined),
    enqueueInferTask: vi.fn(),
    completeTask: vi.fn(() => true),
    pushTaskChunk: vi.fn(() => true),
    failTask: vi.fn(() => true),
    gcSweep: vi.fn(),
    ...overrides,
  } as unknown as VirtualLlmService;
}

async function setupApp(svc: VirtualLlmService): Promise<FastifyInstance> {
  app = Fastify({ logger: false });
  registerVirtualLlmRoutes(app, svc);
  await app.ready();
  return app;
}

describe('POST /api/v1/llms/_provider-register', () => {
  it('returns 400 when providers is missing or empty', async () => {
    await setupApp(fakeService());
    const a = await app.inject({ method: 'POST', url: '/api/v1/llms/_provider-register', payload: {} });
    expect(a.statusCode).toBe(400);
    const b = await app.inject({ method: 'POST', url: '/api/v1/llms/_provider-register', payload: { providers: [] } });
    expect(b.statusCode).toBe(400);
  });

  it('returns 400 when a provider entry is missing required fields', async () => {
    await setupApp(fakeService());
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/llms/_provider-register',
      payload: { providers: [{ name: 'incomplete' }] },
    });
    expect(res.statusCode).toBe(400);
  });

  it('forwards a valid registration to the service and returns 201', async () => {
    const register = vi.fn(async () => ({
      providerSessionId: 'sess-xyz',
      llms: [{ id: 'l1' }],
    }));
    await setupApp(fakeService({ register: register as unknown as VirtualLlmService['register'] }));
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/llms/_provider-register',
      payload: {
        providerSessionId: 'sess-xyz',
        providers: [{ name: 'vllm-local', type: 'openai', model: 'm', tier: 'fast', extraConfig: { gpu: 1 } }],
      },
    });
    expect(res.statusCode).toBe(201);
    expect(register).toHaveBeenCalledWith({
      providerSessionId: 'sess-xyz',
      providers: [{ name: 'vllm-local', type: 'openai', model: 'm', tier: 'fast', extraConfig: { gpu: 1 } }],
      ownerId: 'system',
    });
    expect(res.json()).toMatchObject({ providerSessionId: 'sess-xyz' });
  });

  it('surfaces service errors with their declared status code (e.g. 409 conflict)', async () => {
    const register = vi.fn(async () => {
      throw Object.assign(new Error('Cannot publish over public LLM: dup'), { statusCode: 409 });
    });
    await setupApp(fakeService({ register: register as unknown as VirtualLlmService['register'] }));
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/llms/_provider-register',
      payload: { providers: [{ name: 'dup', type: 'openai', model: 'm' }] },
    });
    expect(res.statusCode).toBe(409);
    expect(res.json()).toMatchObject({ error: expect.stringMatching(/public LLM/) });
  });
});

describe('POST /api/v1/llms/_provider-heartbeat', () => {
  it('returns 400 without providerSessionId', async () => {
    await setupApp(fakeService());
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/llms/_provider-heartbeat',
      payload: {},
    });
    expect(res.statusCode).toBe(400);
  });

  it('forwards the sessionId to service.heartbeat', async () => {
    const heartbeat = vi.fn(async () => undefined);
    await setupApp(fakeService({ heartbeat }));
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/llms/_provider-heartbeat',
      payload: { providerSessionId: 'sess-abc' },
    });
    expect(res.statusCode).toBe(200);
    expect(heartbeat).toHaveBeenCalledWith('sess-abc');
  });
});

describe('POST /api/v1/llms/_provider-task/:taskId/result', () => {
  it('forwards { error } to service.failTask', async () => {
    const failTask = vi.fn(() => true);
    await setupApp(fakeService({ failTask }));
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/llms/_provider-task/t-1/result',
      payload: { error: 'upstream blew up' },
    });
    expect(res.statusCode).toBe(200);
    expect(failTask).toHaveBeenCalledWith('t-1', expect.objectContaining({ message: 'upstream blew up' }));
  });

  it('forwards { chunk } to service.pushTaskChunk', async () => {
    const pushTaskChunk = vi.fn(() => true);
    await setupApp(fakeService({ pushTaskChunk }));
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/llms/_provider-task/t-2/result',
      payload: { chunk: { data: 'hello' } },
    });
    expect(res.statusCode).toBe(200);
    expect(pushTaskChunk).toHaveBeenCalledWith('t-2', { data: 'hello' });
  });

  it('forwards { status, body } to service.completeTask', async () => {
    const completeTask = vi.fn(() => true);
    await setupApp(fakeService({ completeTask }));
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/llms/_provider-task/t-3/result',
      payload: { status: 200, body: { ok: true } },
    });
    expect(res.statusCode).toBe(200);
    expect(completeTask).toHaveBeenCalledWith('t-3', { status: 200, body: { ok: true } });
  });

  it('returns 400 for an empty/unrecognised result body', async () => {
    await setupApp(fakeService());
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/llms/_provider-task/t-4/result',
      payload: {},
    });
    expect(res.statusCode).toBe(400);
  });
});

describe('GET /api/v1/llms/_provider-stream', () => {
  it('returns 400 without the x-mcpctl-provider-session header', async () => {
    await setupApp(fakeService());
    const res = await app.inject({
      method: 'GET',
      url: '/api/v1/llms/_provider-stream',
    });
    expect(res.statusCode).toBe(400);
  });

  // Note: a full SSE handshake test would require a real HTTP listen
  // because `app.inject` holds the response open and never returns under
  // the `text/event-stream` keep-alive. The smoke test in Stage 6 spins
  // up a real listener and exercises the open → bind → task → close
  // round-trip end to end.
});
