import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { registerProviderRoutes } from '../providers.js';
import { load } from '../../services/provider-config.js';
import { loadProviderConfig } from '../../services/provider-config-registry.js';
import { clearProviderSnapshotCache } from '../../services/provider-snapshot.js';
import type { Config } from '../../config.js';
import type { Sql } from '../../db.js';

/** Minimal sql stub: available_agents reads return []. */
function mockSql(): Sql {
  return vi.fn((strings: TemplateStringsArray) => {
    const q = strings.join('');
    if (q.includes('available_agents')) return Promise.resolve([]);
    return Promise.resolve([]);
  }) as unknown as Sql;
}

let tmpCounter = 0;
function freshPath(): string {
  tmpCounter += 1;
  return join(tmpdir(), `coder-providers-routes-${process.pid}-${tmpCounter}.json`);
}

function buildApp(providersPath: string): FastifyInstance {
  const app = Fastify();
  // Mirror index.ts: tolerate empty JSON bodies.
  app.removeContentTypeParser(['application/json']);
  app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
    const str = (body as string) ?? '';
    if (str.trim().length === 0) return done(null, {});
    try {
      done(null, JSON.parse(str));
    } catch (err) {
      done(err as Error, undefined);
    }
  });
  const config = {
    CODER_PROVIDERS_PATH: providersPath,
    LLAMA_SWAP_URL: 'http://llama-swap.test',
    PROVIDER_PROBE_TTL_MS: 86_400_000,
  } as unknown as Config;
  registerProviderRoutes(app, mockSql(), config);
  return app;
}

const JSON_HEADERS = { 'content-type': 'application/json' };
const createdPaths: string[] = [];

beforeEach(() => {
  clearProviderSnapshotCache();
  loadProviderConfig('/nonexistent-coder-providers.json'); // reset registry to built-ins
  vi.restoreAllMocks();
  vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no network in test')));
});

afterEach(() => {
  for (const p of createdPaths.splice(0)) {
    try {
      rmSync(p, { force: true });
    } catch {
      /* ignore */
    }
  }
});

describe('GET /api/providers/config', () => {
  it('returns the current config file (built-ins-only when missing)', async () => {
    const path = freshPath();
    createdPaths.push(path);
    const app = buildApp(path);
    const res = await app.inject({ method: 'GET', url: '/api/providers/config' });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ providers: {} });
    await app.close();
  });

  it('reflects an existing file', async () => {
    const path = freshPath();
    createdPaths.push(path);
    writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false } } }));
    const app = buildApp(path);
    const res = await app.inject({ method: 'GET', url: '/api/providers/config' });
    expect(res.json()).toEqual({ providers: { goose: { enabled: false } } });
    await app.close();
  });
});

describe('PATCH /api/providers/config', () => {
  it('valid patch → 200, writes the merged file (order: validate→save→reload→clear)', async () => {
    const path = freshPath();
    createdPaths.push(path);
    writeFileSync(path, JSON.stringify({ providers: { goose: { label: 'Goose' } } }));
    const app = buildApp(path);

    const res = await app.inject({
      method: 'PATCH',
      url: '/api/providers/config',
      headers: JSON_HEADERS,
      payload: JSON.stringify({ providers: { opencode: { enabled: false } } }),
    });

    expect(res.statusCode).toBe(200);
    expect(res.json()).toMatchObject({ ok: true });
    // File written + merged (goose untouched, opencode added).
    const onDisk = load(path);
    expect(onDisk.providers).toEqual({
      goose: { label: 'Goose' },
      opencode: { enabled: false },
    });
    await app.close();
  });

  it('null value deletes the override', async () => {
    const path = freshPath();
    createdPaths.push(path);
    writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false }, opencode: { enabled: false } } }));
    const app = buildApp(path);

    const res = await app.inject({
      method: 'PATCH',
      url: '/api/providers/config',
      headers: JSON_HEADERS,
      payload: JSON.stringify({ providers: { goose: null } }),
    });

    expect(res.statusCode).toBe(200);
    expect(load(path).providers).toEqual({ opencode: { enabled: false } });
    await app.close();
  });

  it('INVALID body → 422 and the file is NOT written (validate before save)', async () => {
    const path = freshPath();
    createdPaths.push(path);
    const before = JSON.stringify({ providers: { goose: { enabled: true } } });
    writeFileSync(path, before);
    const app = buildApp(path);

    const res = await app.inject({
      method: 'PATCH',
      url: '/api/providers/config',
      headers: JSON_HEADERS,
      payload: JSON.stringify({ providers: { goose: { enabled: 'yes' } } }), // bad type
    });

    expect(res.statusCode).toBe(422);
    // File must be byte-for-byte unchanged — nothing written on a 422.
    expect(readFileSync(path, 'utf8')).toBe(before);
    await app.close();
  });

  it('save failure → 500 and the file is NOT created (no state divergence)', async () => {
    const path = join(tmpdir(), `no-such-dir-${process.pid}-${Date.now()}`, 'coder-providers.json');
    const app = buildApp(path);

    const res = await app.inject({
      method: 'PATCH',
      url: '/api/providers/config',
      headers: JSON_HEADERS,
      payload: JSON.stringify({ providers: { goose: { enabled: false } } }),
    });

    expect(res.statusCode).toBe(500);
    expect(existsSync(path)).toBe(false);
    await app.close();
  });
});

describe('POST /api/providers/refresh', () => {
  it('no body → refreshes all registered providers', async () => {
    const app = buildApp(freshPath());
    const res = await app.inject({ method: 'POST', url: '/api/providers/refresh' });
    expect(res.statusCode).toBe(200);
    expect(res.json().refreshed).toBeGreaterThan(0);
    await app.close();
  });

  it('subset body → refreshed count reflects only the requested providers', async () => {
    const app = buildApp(freshPath());
    const res = await app.inject({
      method: 'POST',
      url: '/api/providers/refresh',
      headers: JSON_HEADERS,
      payload: JSON.stringify({ providers: ['boocode'] }),
    });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ refreshed: 1 });
    await app.close();
  });
});

describe('GET /api/providers/:id/diagnostic', () => {
  it('known provider → 200 JSON { diagnostic }', async () => {
    const app = buildApp(freshPath());
    const res = await app.inject({ method: 'GET', url: '/api/providers/boocode/diagnostic' });
    expect(res.statusCode).toBe(200);
    expect(res.headers['content-type']).toContain('application/json');
    expect(res.json().diagnostic).toContain('provider: boocode');
    await app.close();
  });

  it('unknown provider → 404', async () => {
    const app = buildApp(freshPath());
    const res = await app.inject({ method: 'GET', url: '/api/providers/nope/diagnostic' });
    expect(res.statusCode).toBe(404);
    await app.close();
  });
});
