import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, readFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { ConfigManager } from '../config-manager.js';
import { mountConfigApi, __clearWorkerBackendsCache } from './config-api.js';

describe('Config API', () => {
  let app: express.Application;
  let cm: ConfigManager;
  let tempDir: string;

  beforeEach(() => {
    tempDir = mkdtempSync(join(tmpdir(), 'config-api-'));
    writeFileSync(join(tempDir, 'config.yaml'), [
      'config_version: 2',
      'llm:',
      '  workers:',
      '    - id: w1',
      '      connection_type: direct',
      '      endpoint: http://localhost:11434/v1',
      '      model: test-model',
      '      roles: [auto, fast, quality]',
      '      max_concurrency: 1',
      '      enabled: true',
    ].join('\n'));
    cm = new ConfigManager(join(tempDir, 'config.yaml'));
    app = express();
    app.use(express.json());
    mountConfigApi(app, cm);
  });

  it('GET /api/config returns v2 shape with etag', async () => {
    const res = await request(app).get('/api/config');
    expect(res.status).toBe(200);
    expect(res.body.config.configVersion).toBe(2);
    expect(res.body.config.llm.workers[0].model).toBe('test-model');
    expect(res.headers.etag).toBeDefined();
  });

  it('GET /api/config omits legacy provider block and flat storage keys', async () => {
    const res = await request(app).get('/api/config');
    expect(res.status).toBe(200);
    expect(res.body.config.provider).toBeUndefined();
    expect(res.body.config.worktreeDir).toBeUndefined();
    expect(res.body.config.customPiecesDir).toBeUndefined();
    expect(res.body.config.userFolderRoot).toBeUndefined();
  });

  it('GET /api/config exposes storage.* block when set', async () => {
    writeFileSync(join(tempDir, 'config.yaml'), [
      'config_version: 2',
      'llm:',
      '  workers:',
      '    - id: w1',
      '      connection_type: direct',
      '      endpoint: http://x/v1',
      '      model: m',
      'storage:',
      '  worktree_dir: /tmp/wt',
      '  custom_pieces_dir: /tmp/pieces',
      '  task_upload_max_size_mb: 25',
      '  trash_retention_days: 14',
    ].join('\n'));
    cm.reloadFromFile();
    const res = await request(app).get('/api/config');
    expect(res.status).toBe(200);
    expect(res.body.config.storage).toEqual({
      worktreeDir: '/tmp/wt',
      customPiecesDir: '/tmp/pieces',
      taskUploadMaxSizeMb: 25,
      trashRetentionDays: 14,
    });
    // Legacy tools.taskUploadMaxSizeMb / tools.trashRetentionDays must not
    // appear under tools.* in v2 output.
    expect(res.body.config.tools?.taskUploadMaxSizeMb).toBeUndefined();
    expect(res.body.config.tools?.trashRetentionDays).toBeUndefined();
  });

  it('PUT /api/config updates llm.workers and round-trips through YAML', async () => {
    const getRes = await request(app).get('/api/config');
    const etag = getRes.headers.etag;

    const res = await request(app)
      .put('/api/config')
      .set('If-Match', etag)
      .send({
        llm: {
          workers: [{
            id: 'w1',
            connectionType: 'direct',
            endpoint: 'http://localhost:11434/v1',
            model: 'updated-model',
            roles: ['auto', 'fast', 'quality'],
            maxConcurrency: 1,
            enabled: true,
          }],
        },
      });
    expect(res.status).toBe(200);
    expect(res.body.ok).toBe(true);
    expect(cm.getConfig().llm?.workers[0]?.model).toBe('updated-model');

    // YAML on disk: snake_case + config_version: 2 stamped + no legacy provider block
    const yaml = readFileSync(join(tempDir, 'config.yaml'), 'utf-8');
    expect(yaml).toContain('updated-model');
    expect(yaml).toContain('config_version: 2');
    expect(yaml).toContain('llm:');
    expect(yaml).not.toMatch(/^provider:/m);
    expect(yaml).not.toMatch(/connectionType/);
  });

  it('PUT /api/config force-stamps config_version=2 when omitted', async () => {
    const getRes = await request(app).get('/api/config');
    const etag = getRes.headers.etag;

    const res = await request(app)
      .put('/api/config')
      .set('If-Match', etag)
      .send({ concurrency: 4 });
    expect(res.status).toBe(200);

    const yaml = readFileSync(join(tempDir, 'config.yaml'), 'utf-8');
    expect(yaml).toContain('config_version: 2');
  });

  it('PUT /api/config rejects body with legacy provider block (400)', async () => {
    const res = await request(app)
      .put('/api/config')
      .send({ provider: { model: 'x' } });
    expect(res.status).toBe(400);
    expect(res.body.rejectedKey).toBe('provider');
    expect(res.body.error).toMatch(/llm\.\*/);
  });

  it('PUT /api/config rejects body with flat worktreeDir key (400)', async () => {
    const res = await request(app)
      .put('/api/config')
      .send({ worktreeDir: '/tmp/wt' });
    expect(res.status).toBe(400);
    expect(res.body.rejectedKey).toBe('worktreeDir');
    expect(res.body.error).toMatch(/storage\.worktreeDir/);
  });

  it('PUT /api/config rejects body with flat customPiecesDir key (400)', async () => {
    const res = await request(app)
      .put('/api/config')
      .send({ customPiecesDir: '/tmp/p' });
    expect(res.status).toBe(400);
    expect(res.body.rejectedKey).toBe('customPiecesDir');
  });

  it('PUT /api/config rejects body with flat userFolderRoot key (400)', async () => {
    const res = await request(app)
      .put('/api/config')
      .send({ userFolderRoot: '/tmp/users' });
    expect(res.status).toBe(400);
    expect(res.body.rejectedKey).toBe('userFolderRoot');
  });

  it('PUT /api/config rejects body with tools.taskUploadMaxSizeMb (400)', async () => {
    const res = await request(app)
      .put('/api/config')
      .send({ tools: { taskUploadMaxSizeMb: 50 } });
    expect(res.status).toBe(400);
    expect(res.body.rejectedKey).toBe('tools.taskUploadMaxSizeMb');
    expect(res.body.error).toMatch(/storage\.taskUploadMaxSizeMb/);
  });

  it('PUT /api/config rejects body with tools.trashRetentionDays (400)', async () => {
    const res = await request(app)
      .put('/api/config')
      .send({ tools: { trashRetentionDays: 7 } });
    expect(res.status).toBe(400);
    expect(res.body.rejectedKey).toBe('tools.trashRetentionDays');
  });

  it('PUT /api/config writes storage.* round-trip cleanly', async () => {
    const res = await request(app)
      .put('/api/config')
      .send({
        storage: {
          worktreeDir: '/var/lib/aao',
          customPiecesDir: '/etc/aao/pieces',
        },
      });
    expect(res.status).toBe(200);

    const yaml = readFileSync(join(tempDir, 'config.yaml'), 'utf-8');
    expect(yaml).toContain('storage:');
    expect(yaml).toContain('worktree_dir: /var/lib/aao');
    expect(yaml).toContain('custom_pieces_dir: /etc/aao/pieces');
    // No flat legacy storage keys should appear at top level
    expect(yaml).not.toMatch(/^worktree_dir:/m);
    expect(yaml).not.toMatch(/^custom_pieces_dir:/m);
  });

  it('PUT /api/config returns 409 on stale etag', async () => {
    const res = await request(app)
      .put('/api/config')
      .set('If-Match', 'stale-etag')
      .send({ concurrency: 2 });
    expect(res.status).toBe(409);
  });

  it('POST /api/config/reload reloads from file', async () => {
    const res = await request(app).post('/api/config/reload');
    expect(res.status).toBe(200);
    expect(res.body.ok).toBe(true);
  });

  describe('GET /api/workers', () => {
    it('returns the synthesized default worker when no workers are configured', async () => {
      // Override the beforeEach v2 fixture with a v1-style empty provider
      // to exercise loadConfig's "no workers" auto-gen path.
      writeFileSync(join(tempDir, 'config.yaml'), [
        'provider:',
        '  model: test-model',
      ].join('\n'));
      cm.reloadFromFile();
      const res = await request(app).get('/api/workers');
      expect(res.status).toBe(200);
      expect(res.body.workers).toHaveLength(1);
      expect(res.body.workers[0].id).toBe('default');
    });

    it('returns workers from config with allowlisted fields only', async () => {
      writeFileSync(join(tempDir, 'config.yaml'), [
        'provider:',
        '  model: shared-model',
        '  workers:',
        '    - id: gpu1',
        '      endpoint: http://10.0.0.10:11434/v1',
        '      model: qwen3:8b',
        '      roles: [auto, fast]',
        '      enabled: true',
        '      api_key: super-secret-do-not-leak',
        '    - id: gpu2',
        '      endpoint: http://10.0.0.10:11434/v1',
        '      enabled: false',
        '  retry:',
        '    max_attempts: 1',
      ].join('\n'));
      cm.reloadFromFile();

      const res = await request(app).get('/api/workers');
      expect(res.status).toBe(200);
      expect(res.body.workers).toHaveLength(2);

      const [w1, w2] = res.body.workers;
      expect(w1).toEqual({
        id: 'gpu1',
        endpoint: 'http://10.0.0.10:11434/v1',
        model: 'qwen3:8b',
        roles: ['auto', 'fast'],
        enabled: true,
        proxy: false,
      });
      expect(w2.id).toBe('gpu2');
      expect(w2.enabled).toBe(false);
      // sensitive fields must not leak
      const serialized = JSON.stringify(res.body);
      expect(serialized).not.toContain('api_key');
      expect(serialized).not.toContain('super-secret');
    });

    it('exposes proxy + proxyType on proxy workers (no api_key leak)', async () => {
      writeFileSync(join(tempDir, 'config.yaml'), [
        'provider:',
        '  model: shared-model',
        '  workers:',
        '    - id: team-pool',
        '      endpoint: http://litellm:4000/v1',
        '      proxy: true',
        '      api_key: team-tok-do-not-leak',
        '  retry:',
        '    max_attempts: 1',
      ].join('\n'));
      cm.reloadFromFile();

      const res = await request(app).get('/api/workers');
      expect(res.status).toBe(200);
      expect(res.body.workers[0]).toEqual({
        id: 'team-pool',
        endpoint: 'http://litellm:4000/v1',
        model: null,
        roles: ['auto', 'fast', 'quality'],
        enabled: true,
        proxy: true,
        proxyType: 'litellm',
      });
      expect(JSON.stringify(res.body)).not.toContain('team-tok-do-not-leak');
    });
  });

  describe('GET /api/workers/:workerId/backends', () => {
    beforeEach(() => {
      __clearWorkerBackendsCache();
    });

    afterEach(() => {
      vi.restoreAllMocks();
      vi.unstubAllGlobals();
      __clearWorkerBackendsCache();
    });

    it('returns 404 for an unknown worker', async () => {
      const res = await request(app).get('/api/workers/nope/backends');
      expect(res.status).toBe(404);
    });

    it('returns source=direct with empty backends for non-proxy workers', async () => {
      // beforeEach now seeds a v2 worker named w1, so probe its id.
      const res = await request(app).get('/api/workers/w1/backends');
      expect(res.status).toBe(200);
      expect(res.body).toEqual({ source: 'direct', backends: [] });
    });

    it('fetches /v1/models from a proxy worker and returns the deployment list', async () => {
      writeFileSync(join(tempDir, 'config.yaml'), [
        'provider:',
        '  workers:',
        '    - id: team-pool',
        '      endpoint: http://litellm:4000/v1',
        '      proxy: true',
        '      api_key: tok-xyz',
      ].join('\n'));
      cm.reloadFromFile();

      const fetchMock = vi.fn().mockResolvedValue(
        new Response(
          JSON.stringify({
            data: [
              { id: 'gpu-rtx-a', object: 'model' },
              { id: 'gpu-h100-b', object: 'model' },
            ],
          }),
          { status: 200, headers: { 'Content-Type': 'application/json' } },
        ),
      );
      vi.stubGlobal('fetch', fetchMock);

      const res = await request(app).get('/api/workers/team-pool/backends');
      expect(res.status).toBe(200);
      expect(res.body).toEqual({
        source: 'proxy',
        proxyType: 'litellm',
        backends: [
          { id: 'gpu-rtx-a', model: 'gpu-rtx-a', online: true },
          { id: 'gpu-h100-b', model: 'gpu-h100-b', online: true },
        ],
      });

      // Should have called the upstream with the worker's api_key
      expect(fetchMock).toHaveBeenCalledTimes(1);
      const call = fetchMock.mock.calls[0]!;
      expect(call[0]).toBe('http://litellm:4000/v1/models');
      expect((call[1] as RequestInit).headers).toMatchObject({
        Authorization: 'Bearer tok-xyz',
      });
    });

    it('caches successful proxy results across calls (60s TTL)', async () => {
      writeFileSync(join(tempDir, 'config.yaml'), [
        'provider:',
        '  workers:',
        '    - id: team-pool',
        '      endpoint: http://litellm:4000/v1',
        '      proxy: true',
      ].join('\n'));
      cm.reloadFromFile();

      const fetchMock = vi.fn().mockResolvedValue(
        new Response(JSON.stringify({ data: [{ id: 'gpu-x' }] }), { status: 200 }),
      );
      vi.stubGlobal('fetch', fetchMock);

      const r1 = await request(app).get('/api/workers/team-pool/backends');
      const r2 = await request(app).get('/api/workers/team-pool/backends');
      expect(r1.body).toEqual(r2.body);
      expect(fetchMock).toHaveBeenCalledTimes(1);
    });

    it('rejects file:// endpoint scheme without leaking apiKey to fetch', async () => {
      writeFileSync(join(tempDir, 'config.yaml'), [
        'provider:',
        '  workers:',
        '    - id: team-pool',
        '      endpoint: file:///etc/passwd',
        '      proxy: true',
        '      api_key: tok-leak-target',
      ].join('\n'));
      cm.reloadFromFile();

      const fetchMock = vi.fn();
      vi.stubGlobal('fetch', fetchMock);

      const res = await request(app).get('/api/workers/team-pool/backends');
      expect(res.status).toBe(502);
      expect(res.body.error).toMatch(/unsupported endpoint scheme/i);
      expect(fetchMock).not.toHaveBeenCalled();
    });

    it('rejects data: endpoint scheme without leaking apiKey to fetch', async () => {
      writeFileSync(join(tempDir, 'config.yaml'), [
        'provider:',
        '  workers:',
        '    - id: team-pool',
        '      endpoint: "data:text/plain,hi"',
        '      proxy: true',
        '      api_key: tok-leak-target',
      ].join('\n'));
      cm.reloadFromFile();

      const fetchMock = vi.fn();
      vi.stubGlobal('fetch', fetchMock);

      const res = await request(app).get('/api/workers/team-pool/backends');
      expect(res.status).toBe(502);
      expect(res.body.error).toMatch(/unsupported endpoint scheme/i);
      expect(fetchMock).not.toHaveBeenCalled();
    });

    it('rejects malformed endpoint URLs without calling fetch', async () => {
      writeFileSync(join(tempDir, 'config.yaml'), [
        'provider:',
        '  workers:',
        '    - id: team-pool',
        '      endpoint: "not-a-url"',
        '      proxy: true',
        '      api_key: tok-leak-target',
      ].join('\n'));
      cm.reloadFromFile();

      const fetchMock = vi.fn();
      vi.stubGlobal('fetch', fetchMock);

      const res = await request(app).get('/api/workers/team-pool/backends');
      expect(res.status).toBe(502);
      expect(res.body.error).toMatch(/invalid endpoint URL/i);
      expect(fetchMock).not.toHaveBeenCalled();
    });

    it('returns 502 with error payload when the upstream proxy fails', async () => {
      writeFileSync(join(tempDir, 'config.yaml'), [
        'provider:',
        '  workers:',
        '    - id: team-pool',
        '      endpoint: http://litellm:4000/v1',
        '      proxy: true',
      ].join('\n'));
      cm.reloadFromFile();

      const fetchMock = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
      vi.stubGlobal('fetch', fetchMock);

      const res = await request(app).get('/api/workers/team-pool/backends');
      expect(res.status).toBe(502);
      expect(res.body.source).toBe('proxy');
      expect(res.body.backends).toEqual([]);
      expect(res.body.error).toContain('ECONNREFUSED');
    });
  });

  describe('/api/workers auth guard', () => {
    // server.ts wires `app.use('/api/workers', requireAuth)` when auth is
    // active. config-api.ts itself doesn't mount the middleware (separation
    // of concerns), so this suite asserts the same middleware shape works
    // when callers mount it the way server.ts does.
    let guardedApp: express.Application;
    let isAuthed: boolean;
    const fakeRequireAuth: express.RequestHandler = (_req, res, next) => {
      if (isAuthed) {
        next();
      } else {
        res.status(401).json({ error: 'unauthenticated' });
      }
    };

    beforeEach(() => {
      isAuthed = true;
      guardedApp = express();
      guardedApp.use(express.json());
      guardedApp.use('/api/workers', fakeRequireAuth);
      mountConfigApi(guardedApp, cm);
    });

    it('returns 401 for unauthenticated GET /api/workers', async () => {
      isAuthed = false;
      const res = await request(guardedApp).get('/api/workers');
      expect(res.status).toBe(401);
    });

    it('returns 401 for unauthenticated GET /api/workers/:id/backends', async () => {
      isAuthed = false;
      const res = await request(guardedApp).get('/api/workers/default/backends');
      expect(res.status).toBe(401);
    });

    it('returns 200 for authenticated GET /api/workers', async () => {
      isAuthed = true;
      const res = await request(guardedApp).get('/api/workers');
      expect(res.status).toBe(200);
      expect(Array.isArray(res.body.workers)).toBe(true);
    });
  });
});
