import { describe, it, expect, vi, afterEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerLlmRoutes } from '../src/routes/llms.js';
import { LlmService } from '../src/services/llm.service.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import type { ILlmRepository } from '../src/repositories/llm.repository.js';
import type { Llm, Secret } from '@prisma/client';

let app: FastifyInstance;

function makeLlm(overrides: Partial<Llm> = {}): Llm {
  return {
    id: 'llm-1',
    name: 'claude',
    type: 'anthropic',
    model: 'claude-3-5-sonnet-20241022',
    url: '',
    tier: 'heavy',
    description: '',
    apiKeySecretId: null,
    apiKeySecretKey: null,
    extraConfig: {},
    poolName: null,
    kind: 'public',
    providerSessionId: null,
    lastHeartbeatAt: null,
    status: 'active',
    inactiveSince: null,
    version: 1,
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  };
}

function mockRepo(initial: Llm[] = []): ILlmRepository {
  const rows = new Map(initial.map((r) => [r.id, r]));
  return {
    findAll: vi.fn(async () => [...rows.values()]),
    findById: vi.fn(async (id: string) => rows.get(id) ?? null),
    findByName: vi.fn(async (name: string) => {
      for (const r of rows.values()) if (r.name === name) return r;
      return null;
    }),
    findByTier: vi.fn(async () => []),
    findByPoolName: vi.fn(async (poolName: string) => {
      const out: Llm[] = [];
      for (const r of rows.values()) {
        if (r.poolName === poolName) out.push(r);
        else if (r.poolName === null && r.name === poolName) out.push(r);
      }
      return out;
    }),
    findBySessionId: vi.fn(async () => []),
    findStaleVirtuals: vi.fn(async () => []),
    findExpiredInactives: vi.fn(async () => []),
    create: vi.fn(async (data) => {
      const row = makeLlm({ id: 'new-id', name: data.name, type: data.type, model: data.model });
      rows.set(row.id, row);
      return row;
    }),
    update: vi.fn(async (id, data) => {
      const existing = rows.get(id)!;
      const next: Llm = {
        ...existing,
        ...(data.model !== undefined ? { model: data.model } : {}),
      };
      rows.set(id, next);
      return next;
    }),
    delete: vi.fn(async (id) => { rows.delete(id); }),
  };
}

function mockSecretService() {
  const sec: Secret = {
    id: 'sec-1', name: 'anthropic-key', backendId: 'b', data: {}, externalRef: '',
    version: 1, createdAt: new Date(), updatedAt: new Date(),
  };
  return {
    getById: vi.fn(async (id: string) => {
      if (id === sec.id) return sec;
      throw new Error('not found');
    }),
    getByName: vi.fn(async (name: string) => {
      if (name === sec.name) return sec;
      throw new Error('not found');
    }),
    resolveData: vi.fn(async () => ({ token: 'sk-ant-xyz' })),
  };
}

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

async function createApp(repo: ILlmRepository): Promise<FastifyInstance> {
  app = Fastify({ logger: false });
  app.setErrorHandler(errorHandler);
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const service = new LlmService(repo, mockSecretService() as any);
  registerLlmRoutes(app, service);
  await app.ready();
  return app;
}

describe('Llm Routes', () => {
  it('GET /api/v1/llms returns a list', async () => {
    await createApp(mockRepo([makeLlm()]));
    const res = await app.inject({ method: 'GET', url: '/api/v1/llms' });
    expect(res.statusCode).toBe(200);
    const body = res.json<Array<{ name: string }>>();
    expect(body).toHaveLength(1);
    expect(body[0]!.name).toBe('claude');
  });

  it('GET /api/v1/llms/:id returns 404 when missing', async () => {
    await createApp(mockRepo());
    const res = await app.inject({ method: 'GET', url: '/api/v1/llms/missing' });
    expect(res.statusCode).toBe(404);
  });

  it('GET /api/v1/llms/:nameOrId resolves by human name when not a CUID', async () => {
    await createApp(mockRepo([makeLlm({ id: 'llm-1', name: 'claude' })]));
    const res = await app.inject({ method: 'GET', url: '/api/v1/llms/claude' });
    expect(res.statusCode).toBe(200);
    expect(res.json<{ name: string; id: string }>().name).toBe('claude');
  });

  it('HEAD /api/v1/llms/:name returns 200 for an existing Llm (failover RBAC pre-check)', async () => {
    await createApp(mockRepo([makeLlm({ name: 'claude' })]));
    const res = await app.inject({ method: 'HEAD', url: '/api/v1/llms/claude' });
    expect(res.statusCode).toBe(200);
  });

  it('HEAD /api/v1/llms/:name returns 404 for a missing Llm', async () => {
    await createApp(mockRepo());
    const res = await app.inject({ method: 'HEAD', url: '/api/v1/llms/missing' });
    expect(res.statusCode).toBe(404);
  });

  it('POST /api/v1/llms creates and returns 201', async () => {
    await createApp(mockRepo());
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/llms',
      payload: {
        name: 'ollama-local',
        type: 'ollama',
        model: 'llama3',
        url: 'http://localhost:11434',
      },
    });
    expect(res.statusCode).toBe(201);
    expect(res.json<{ name: string }>().name).toBe('ollama-local');
  });

  it('POST /api/v1/llms rejects bad input with 400', async () => {
    await createApp(mockRepo());
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/llms',
      payload: { name: '', type: 'anthropic', model: 'x' },
    });
    expect(res.statusCode).toBe(400);
  });

  it('POST /api/v1/llms returns 409 when name exists', async () => {
    await createApp(mockRepo([makeLlm({ name: 'claude' })]));
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/llms',
      payload: { name: 'claude', type: 'anthropic', model: 'x' },
    });
    expect(res.statusCode).toBe(409);
  });

  it('PUT /api/v1/llms/:id updates model', async () => {
    await createApp(mockRepo([makeLlm({ id: 'llm-1' })]));
    const res = await app.inject({
      method: 'PUT',
      url: '/api/v1/llms/llm-1',
      payload: { model: 'claude-3-opus' },
    });
    expect(res.statusCode).toBe(200);
    expect(res.json<{ model: string }>().model).toBe('claude-3-opus');
  });

  it('PUT /api/v1/llms/:id returns 404 when missing', async () => {
    await createApp(mockRepo());
    const res = await app.inject({
      method: 'PUT',
      url: '/api/v1/llms/missing',
      payload: { model: 'x' },
    });
    expect(res.statusCode).toBe(404);
  });

  it('DELETE /api/v1/llms/:id returns 204', async () => {
    await createApp(mockRepo([makeLlm({ id: 'llm-1' })]));
    const res = await app.inject({ method: 'DELETE', url: '/api/v1/llms/llm-1' });
    expect(res.statusCode).toBe(204);
  });

  it('DELETE /api/v1/llms/:id returns 404 when missing', async () => {
    await createApp(mockRepo());
    const res = await app.inject({ method: 'DELETE', url: '/api/v1/llms/missing' });
    expect(res.statusCode).toBe(404);
  });

  // ── v4: GET /api/v1/llms/:name/members ──

  it('GET /api/v1/llms/:name/members returns all members of an explicit pool', async () => {
    await createApp(mockRepo([
      makeLlm({ id: 'l1', name: 'qwen-prod-1', poolName: 'qwen-pool', model: 'qwen3' }),
      makeLlm({ id: 'l2', name: 'qwen-prod-2', poolName: 'qwen-pool', model: 'qwen3' }),
      makeLlm({ id: 'l3', name: 'qwen-prod-3', poolName: 'qwen-pool', model: 'qwen3', status: 'inactive' }),
      makeLlm({ id: 'other', name: 'gpt-4o', poolName: null, model: 'gpt-4o' }),
    ]));
    // Hit via any pool member's name — the route resolves the anchor's
    // effective pool key and lists all matching rows.
    const res = await app.inject({ method: 'GET', url: '/api/v1/llms/qwen-prod-1/members' });
    expect(res.statusCode).toBe(200);
    const body = res.json<{
      poolName: string;
      explicitPoolName: string | null;
      size: number;
      activeCount: number;
      members: Array<{ name: string }>;
    }>();
    expect(body.poolName).toBe('qwen-pool');
    expect(body.explicitPoolName).toBe('qwen-pool');
    expect(body.size).toBe(3);
    expect(body.activeCount).toBe(2);
    expect(body.members.map((m) => m.name).sort()).toEqual(['qwen-prod-1', 'qwen-prod-2', 'qwen-prod-3']);
  });

  it('GET /api/v1/llms/:name/members for a solo Llm returns a pool of 1', async () => {
    await createApp(mockRepo([
      makeLlm({ id: 'solo', name: 'gpt-4o', poolName: null, model: 'gpt-4o' }),
    ]));
    const res = await app.inject({ method: 'GET', url: '/api/v1/llms/gpt-4o/members' });
    expect(res.statusCode).toBe(200);
    const body = res.json<{ poolName: string; explicitPoolName: string | null; size: number; activeCount: number }>();
    expect(body.poolName).toBe('gpt-4o');
    expect(body.explicitPoolName).toBeNull();
    expect(body.size).toBe(1);
    expect(body.activeCount).toBe(1);
  });

  it('GET /api/v1/llms/:name/members returns 404 when the anchor name does not exist', async () => {
    await createApp(mockRepo());
    const res = await app.inject({ method: 'GET', url: '/api/v1/llms/nope/members' });
    expect(res.statusCode).toBe(404);
  });
});
