import { describe, it, expect, vi } from 'vitest';
import { LlmService, LlmAuthVerificationError } from '../src/services/llm.service.js';
import type { ILlmRepository } from '../src/repositories/llm.repository.js';
import type { Llm, Secret } from '@prisma/client';

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: {},
    version: 1,
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  };
}

function makeSecret(overrides: Partial<Secret> = {}): Secret {
  return {
    id: 'sec-anthropic',
    name: 'anthropic-key',
    backendId: 'backend-plaintext',
    data: {},
    externalRef: '',
    version: 1,
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  };
}

function mockRepo(initial: Llm[] = []): ILlmRepository {
  const rows = new Map<string, Llm>(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 (tier: string) => [...rows.values()].filter((r) => r.tier === tier)),
    create: vi.fn(async (data) => {
      const row = makeLlm({
        id: `llm-${String(rows.size + 1)}`,
        name: data.name,
        type: data.type,
        model: data.model,
        url: data.url ?? '',
        tier: data.tier ?? 'fast',
        description: data.description ?? '',
        apiKeySecretId: data.apiKeySecretId ?? null,
        apiKeySecretKey: data.apiKeySecretKey ?? null,
        extraConfig: (data.extraConfig ?? {}) as Llm['extraConfig'],
      });
      rows.set(row.id, row);
      return row;
    }),
    update: vi.fn(async (id, data) => {
      const existing = rows.get(id);
      if (!existing) throw new Error('not found');
      const next: Llm = {
        ...existing,
        ...(data.model !== undefined ? { model: data.model } : {}),
        ...(data.url !== undefined ? { url: data.url } : {}),
        ...(data.tier !== undefined ? { tier: data.tier } : {}),
        ...(data.description !== undefined ? { description: data.description } : {}),
        ...(data.apiKeySecretId !== undefined ? { apiKeySecretId: data.apiKeySecretId } : {}),
        ...(data.apiKeySecretKey !== undefined ? { apiKeySecretKey: data.apiKeySecretKey } : {}),
        ...(data.extraConfig !== undefined ? { extraConfig: data.extraConfig as Llm['extraConfig'] } : {}),
      };
      rows.set(id, next);
      return next;
    }),
    delete: vi.fn(async (id) => { rows.delete(id); }),
  };
}

function mockSecrets(secretByName: Record<string, Secret>, resolved: Record<string, string> = {}): {
  getById: ReturnType<typeof vi.fn>;
  getByName: ReturnType<typeof vi.fn>;
  resolveData: ReturnType<typeof vi.fn>;
} {
  return {
    getById: vi.fn(async (id: string) => {
      for (const s of Object.values(secretByName)) if (s.id === id) return s;
      throw new Error(`secret not found: ${id}`);
    }),
    getByName: vi.fn(async (name: string) => {
      const s = secretByName[name];
      if (!s) throw new Error(`secret not found: ${name}`);
      return s;
    }),
    resolveData: vi.fn(async () => resolved),
  };
}

describe('LlmService', () => {
  it('create parses input and resolves apiKeyRef → secret id', async () => {
    const repo = mockRepo();
    const sec = makeSecret();
    const secrets = mockSecrets({ 'anthropic-key': sec });
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const svc = new LlmService(repo, secrets as any);

    const view = await svc.create({
      name: 'claude',
      type: 'anthropic',
      model: 'claude-3-5-sonnet-20241022',
      tier: 'heavy',
      apiKeyRef: { name: 'anthropic-key', key: 'token' },
    });

    expect(view.name).toBe('claude');
    expect(view.apiKeyRef).toEqual({ name: 'anthropic-key', key: 'token' });
    expect(secrets.getByName).toHaveBeenCalledWith('anthropic-key');
    expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({
      apiKeySecretId: sec.id,
      apiKeySecretKey: 'token',
    }));
  });

  it('create without apiKeyRef leaves FK columns null', async () => {
    const repo = mockRepo();
    const secrets = mockSecrets({});
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const svc = new LlmService(repo, secrets as any);

    const view = await svc.create({
      name: 'ollama-local',
      type: 'ollama',
      model: 'llama3',
      url: 'http://localhost:11434',
      tier: 'fast',
    });

    expect(view.apiKeyRef).toBeNull();
    expect(secrets.getByName).not.toHaveBeenCalled();
  });

  it('create rejects duplicate name', async () => {
    const repo = mockRepo([makeLlm({ name: 'claude' })]);
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const svc = new LlmService(repo, mockSecrets({}) as any);
    await expect(svc.create({
      name: 'claude', type: 'anthropic', model: 'x',
    })).rejects.toThrow(/already exists/);
  });

  it('update with apiKeyRef null unlinks the secret', async () => {
    const sec = makeSecret();
    const repo = mockRepo([makeLlm({ apiKeySecretId: sec.id, apiKeySecretKey: 'token' })]);
    const secrets = mockSecrets({ 'anthropic-key': sec });
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const svc = new LlmService(repo, secrets as any);

    await svc.update('llm-1', { apiKeyRef: null });
    expect(repo.update).toHaveBeenCalledWith('llm-1', expect.objectContaining({
      apiKeySecretId: null,
      apiKeySecretKey: null,
    }));
  });

  it('resolveApiKey reads through SecretService', async () => {
    const sec = makeSecret();
    const repo = mockRepo([makeLlm({ apiKeySecretId: sec.id, apiKeySecretKey: 'token' })]);
    const secrets = mockSecrets({ 'anthropic-key': sec }, { token: 'sk-ant-xyz' });
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const svc = new LlmService(repo, secrets as any);

    const key = await svc.resolveApiKey('claude');
    expect(key).toBe('sk-ant-xyz');
  });

  it('resolveApiKey throws when Llm has no apiKeyRef', async () => {
    const repo = mockRepo([makeLlm()]);
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const svc = new LlmService(repo, mockSecrets({}) as any);
    await expect(svc.resolveApiKey('claude')).rejects.toThrow(/no apiKeyRef/);
  });

  it('resolveApiKey throws when the secret key is missing', async () => {
    const sec = makeSecret();
    const repo = mockRepo([makeLlm({ apiKeySecretId: sec.id, apiKeySecretKey: 'missing-key' })]);
    const secrets = mockSecrets({ 'anthropic-key': sec }, { token: 'x' });
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const svc = new LlmService(repo, secrets as any);
    await expect(svc.resolveApiKey('claude')).rejects.toThrow(/no key 'missing-key'/);
  });

  it('list returns views with apiKeyRef rendered from secret name', async () => {
    const sec = makeSecret();
    const repo = mockRepo([makeLlm({ apiKeySecretId: sec.id, apiKeySecretKey: 'token' })]);
    const secrets = mockSecrets({ 'anthropic-key': sec });
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const svc = new LlmService(repo, secrets as any);

    const items = await svc.list();
    expect(items).toHaveLength(1);
    expect(items[0]!.apiKeyRef).toEqual({ name: 'anthropic-key', key: 'token' });
  });

  it('delete happy path', async () => {
    const repo = mockRepo([makeLlm()]);
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const svc = new LlmService(repo, mockSecrets({}) as any);
    await svc.delete('llm-1');
    expect(repo.delete).toHaveBeenCalledWith('llm-1');
  });

  it('validation: rejects invalid type', async () => {
    const repo = mockRepo();
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const svc = new LlmService(repo, mockSecrets({}) as any);
    await expect(svc.create({ name: 'x', type: 'bogus', model: 'y' })).rejects.toThrow();
  });

  it('validation: rejects invalid tier', async () => {
    const repo = mockRepo();
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const svc = new LlmService(repo, mockSecrets({}) as any);
    await expect(svc.create({
      name: 'x', type: 'openai', model: 'gpt-4', tier: 'warp-speed',
    })).rejects.toThrow();
  });

  // ── Auth verification at registration time ────────────────────────────
  // Catches misconfigured tokens / wrong URLs at create/update, not at
  // first chat. The actual upstream-probe logic lives in each adapter's
  // verifyAuth(); these tests exercise the service's reaction to the
  // probe result.

  it('create: throws LlmAuthVerificationError when adapter probe returns reason=auth', async () => {
    const repo = mockRepo();
    const sec = makeSecret({ id: 'sec-bad', name: 'bad-key' });
    const secrets = mockSecrets({ 'bad-key': sec }, { token: 'sk-bad' });
    const adapters = {
      get: vi.fn(() => ({
        kind: 'openai',
        verifyAuth: vi.fn(async () => ({ ok: false, reason: 'auth', status: 401, body: '{"error":"invalid_api_key"}' })),
      })),
    } as unknown as Parameters<typeof LlmService>[2]['adapters'];
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const svc = new LlmService(repo, secrets as any, { adapters });
    await expect(svc.create({
      name: 'wrong-key', type: 'openai', model: 'gpt-4o',
      apiKeyRef: { name: 'bad-key', key: 'token' },
    })).rejects.toThrow(LlmAuthVerificationError);
    // Repo.create should NOT have been called — no row written.
    expect(repo.create).not.toHaveBeenCalled();
  });

  it('create: warn-only when probe returns reason=unreachable (still creates row)', async () => {
    const repo = mockRepo();
    const sec = makeSecret({ id: 'sec-x', name: 'k' });
    const secrets = mockSecrets({ k: sec }, { token: 'k' });
    const log = { warn: vi.fn() };
    const adapters = {
      get: vi.fn(() => ({
        kind: 'openai',
        verifyAuth: vi.fn(async () => ({ ok: false, reason: 'unreachable', error: 'ECONNREFUSED' })),
      })),
    } as unknown as Parameters<typeof LlmService>[2]['adapters'];
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const svc = new LlmService(repo, secrets as any, { adapters, log });
    const view = await svc.create({
      name: 'offline', type: 'openai', model: 'gpt-4o',
      url: 'http://localhost:9999',
      apiKeyRef: { name: 'k', key: 'token' },
    });
    expect(view.name).toBe('offline');
    expect(repo.create).toHaveBeenCalledOnce();
    expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('unreachable'));
  });

  it('create: warn-only when probe returns reason=unexpected (404 from a stripped proxy)', async () => {
    const repo = mockRepo();
    const sec = makeSecret({ id: 'sec-x', name: 'k' });
    const secrets = mockSecrets({ k: sec }, { token: 'k' });
    const log = { warn: vi.fn() };
    const adapters = {
      get: vi.fn(() => ({
        kind: 'openai',
        verifyAuth: vi.fn(async () => ({ ok: false, reason: 'unexpected', status: 404, body: 'not found' })),
      })),
    } as unknown as Parameters<typeof LlmService>[2]['adapters'];
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const svc = new LlmService(repo, secrets as any, { adapters, log });
    const view = await svc.create({
      name: 'stripped-proxy', type: 'openai', model: 'gpt-4o',
      apiKeyRef: { name: 'k', key: 'token' },
    });
    expect(view.name).toBe('stripped-proxy');
    expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('HTTP 404'));
  });

  it('create: skipAuthCheck=true bypasses the probe', async () => {
    const repo = mockRepo();
    const sec = makeSecret({ id: 'sec-x', name: 'k' });
    const secrets = mockSecrets({ k: sec }, { token: 'k' });
    const verifyAuth = vi.fn(async () => ({ ok: false, reason: 'auth', status: 401, body: 'no' }));
    const adapters = {
      get: vi.fn(() => ({ kind: 'openai', verifyAuth })),
    } as unknown as Parameters<typeof LlmService>[2]['adapters'];
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const svc = new LlmService(repo, secrets as any, { adapters });
    const view = await svc.create({
      name: 'offline-staging', type: 'openai', model: 'gpt-4o',
      apiKeyRef: { name: 'k', key: 'token' },
    }, { skipAuthCheck: true });
    expect(view.name).toBe('offline-staging');
    expect(verifyAuth).not.toHaveBeenCalled();
  });

  it('create: probe is skipped when no apiKeyRef (nothing to verify)', async () => {
    const repo = mockRepo();
    const verifyAuth = vi.fn();
    const adapters = {
      get: vi.fn(() => ({ kind: 'openai', verifyAuth })),
    } as unknown as Parameters<typeof LlmService>[2]['adapters'];
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const svc = new LlmService(repo, mockSecrets({}) as any, { adapters });
    await svc.create({ name: 'no-key', type: 'ollama', model: 'llama3', url: 'http://localhost:11434' });
    expect(verifyAuth).not.toHaveBeenCalled();
  });

  it('update: probes only when apiKeyRef or url changes', async () => {
    const existing = makeLlm({ id: 'llm-up', name: 'up', apiKeySecretId: 'sec-x', apiKeySecretKey: 'token' });
    const repo = mockRepo([existing]);
    const sec = makeSecret({ id: 'sec-x', name: 'k' });
    const secrets = mockSecrets({ k: sec }, { token: 'k' });
    const verifyAuth = vi.fn(async () => ({ ok: true }));
    const adapters = {
      get: vi.fn(() => ({ kind: 'openai', verifyAuth })),
    } as unknown as Parameters<typeof LlmService>[2]['adapters'];
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const svc = new LlmService(repo, secrets as any, { adapters });

    // Description-only update — no probe.
    await svc.update('llm-up', { description: 'new' });
    expect(verifyAuth).not.toHaveBeenCalled();

    // URL change — probe runs.
    await svc.update('llm-up', { url: 'http://new-host:4000' });
    expect(verifyAuth).toHaveBeenCalledOnce();
  });
});
