import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, existsSync, readFileSync, statSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { createServer, type Server } from 'http';
import { ConfigManager } from '../config-manager.js';
import {
  mountSetupApi,
  buildNoAuthConfigGate,
  newNoAuthConfigGateState,
  type NoAuthConfigGateState,
  ensureSetupToken,
  readSetupToken,
  clearSetupToken,
  setupTokenPath,
  tokensMatch,
} from './setup-api.js';
import { mountConfigApi } from './config-api.js';

const UNCONFIGURED = 'config_version: 2\n';
const CONFIGURED = [
  'config_version: 2',
  'llm:',
  '  workers:',
  '    - id: w1',
  '      connection_type: direct',
  '      endpoint: http://localhost:11434/v1',
  '      model: llama3',
  '      roles: [auto, fast, quality, title, reflection]',
  '      max_concurrency: 1',
  '      enabled: true',
  '',
].join('\n');

function makeApp(
  yaml: string,
  opts: { authActive?: boolean; withConfigApi?: boolean; gateMode?: NoAuthConfigGateState['mode'] } = {},
) {
  const dir = mkdtempSync(join(tmpdir(), 'setup-api-'));
  writeFileSync(join(dir, 'config.yaml'), yaml);
  const cm = new ConfigManager(join(dir, 'config.yaml'));
  const dataDir = join(dir, 'data');
  const authActive = opts.authActive === true;
  const gateState = newNoAuthConfigGateState();
  gateState.mode = opts.gateMode ?? 'token-required';
  const app = express();
  if (opts.withConfigApi) {
    app.use('/api/config', express.json());
    if (!authActive) app.use('/api/config', buildNoAuthConfigGate(dataDir, gateState));
  }
  mountSetupApi(app, cm, { authActive, listenPort: 9876, dataDir, deployHint: 'docker' });
  if (opts.withConfigApi) mountConfigApi(app, cm);
  return { app, cm, dir, dataDir, gateState };
}

describe('setup-api: token helpers', () => {
  let dataDir: string;
  beforeEach(() => {
    dataDir = mkdtempSync(join(tmpdir(), 'setup-tok-'));
  });

  it('ensureSetupToken creates a 0600 file and reuses it on second call', () => {
    const t1 = ensureSetupToken(dataDir);
    expect(t1).toMatch(/^[0-9a-f]{64}$/);
    const mode = statSync(setupTokenPath(dataDir)).mode & 0o777;
    expect(mode).toBe(0o600);
    const t2 = ensureSetupToken(dataDir);
    expect(t2).toBe(t1); // reuse, no regenerate race
    expect(readSetupToken(dataDir)).toBe(t1);
  });

  it('readSetupToken returns null when absent and clearSetupToken is idempotent', () => {
    expect(readSetupToken(dataDir)).toBeNull();
    expect(() => clearSetupToken(dataDir)).not.toThrow();
    ensureSetupToken(dataDir);
    expect(existsSync(setupTokenPath(dataDir))).toBe(true);
    clearSetupToken(dataDir);
    expect(existsSync(setupTokenPath(dataDir))).toBe(false);
    expect(() => clearSetupToken(dataDir)).not.toThrow();
  });

  it('tokensMatch is true only for an exact match (length-normalized)', () => {
    const t = 'a'.repeat(64);
    expect(tokensMatch(t, t)).toBe(true);
    expect(tokensMatch('a'.repeat(63), t)).toBe(false); // length mismatch
    expect(tokensMatch('b'.repeat(64), t)).toBe(false); // same length, wrong
    expect(tokensMatch('', t)).toBe(false);
  });
});

describe('setup-api: GET /api/setup/status', () => {
  it('reports needsSetup=true with tokenRequired when fresh + token present', async () => {
    const { app, dataDir } = makeApp(UNCONFIGURED);
    ensureSetupToken(dataDir);
    const res = await request(app).get('/api/setup/status');
    expect(res.status).toBe(200);
    expect(res.body).toMatchObject({
      needsSetup: true,
      authActive: false,
      port: 9876,
      deployHint: 'docker',
      tokenRequired: true,
    });
  });

  it('reports needsSetup=false when an LLM worker is configured', async () => {
    const { app } = makeApp(CONFIGURED);
    const res = await request(app).get('/api/setup/status');
    expect(res.status).toBe(200);
    expect(res.body.needsSetup).toBe(false);
  });

  it('tokenRequired=false when auth is active', async () => {
    const { app } = makeApp(UNCONFIGURED, { authActive: true });
    const res = await request(app).get('/api/setup/status');
    expect(res.body.authActive).toBe(true);
    expect(res.body.tokenRequired).toBe(false);
  });
});

describe('setup-api: token gate on /api/setup/apply', () => {
  it('403 without a token header', async () => {
    const { app, dataDir } = makeApp(UNCONFIGURED);
    ensureSetupToken(dataDir);
    const res = await request(app).post('/api/setup/apply').send({ port: 1234 });
    expect(res.status).toBe(403);
  });

  it('403 with a wrong-length token (timingSafeEqual path)', async () => {
    const { app, dataDir } = makeApp(UNCONFIGURED);
    ensureSetupToken(dataDir);
    const res = await request(app).post('/api/setup/apply').set('X-Setup-Token', 'short').send({ port: 1234 });
    expect(res.status).toBe(403);
  });

  it('410 once an LLM worker is configured (closed)', async () => {
    const { app, dataDir } = makeApp(CONFIGURED);
    ensureSetupToken(dataDir);
    const token = readSetupToken(dataDir)!;
    const res = await request(app).post('/api/setup/apply').set('X-Setup-Token', token).send({ port: 1234 });
    expect(res.status).toBe(410);
  });

  it('410 when auth is active', async () => {
    const { app, dataDir } = makeApp(UNCONFIGURED, { authActive: true });
    ensureSetupToken(dataDir);
    const token = readSetupToken(dataDir)!;
    const res = await request(app).post('/api/setup/apply').set('X-Setup-Token', token).send({ port: 1234 });
    expect(res.status).toBe(410);
  });
});

describe('setup-api: no-auth /api/config auth-write gate (Codex P1 #2/#3)', () => {
  it('blocks an auth.* PUT without a token in token-required mode', async () => {
    const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
    ensureSetupToken(dataDir);
    const res = await request(app).put('/api/config').send({ auth: { local: { enabled: false } } });
    expect(res.status).toBe(403);
  });

  it('allows an auth.* PUT with the correct token', async () => {
    const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
    ensureSetupToken(dataDir);
    const token = readSetupToken(dataDir)!;
    const res = await request(app).put('/api/config').set('X-Setup-Token', token).send({ auth: { local: { enabled: false } } });
    expect(res.status).toBe(200);
  });

  it('does NOT gate non-auth writes (no Settings regression) even in token-required mode', async () => {
    const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
    ensureSetupToken(dataDir);
    const res = await request(app).put('/api/config').send({ server: { port: 5555 } });
    expect(res.status).toBe(200); // no token needed for a non-auth write
  });

  it('fails closed (503) on an auth.* write while the boot lifecycle is pending (boot TOCTOU)', async () => {
    const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'pending' });
    ensureSetupToken(dataDir);
    const res = await request(app).put('/api/config').send({ auth: { local: { enabled: false } } });
    expect(res.status).toBe(503);
  });

  it('refuses an auth.* write after the token is cleared (completing setup cannot reopen the hole)', async () => {
    const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
    // token-required but no token file present (post-apply state)
    expect(readSetupToken(dataDir)).toBeNull();
    const res = await request(app).put('/api/config').send({ auth: { local: { enabled: false } } });
    expect(res.status).toBe(403);
  });

  it("allows auth.* writes in 'open' mode (existing configured no-auth deployment, no regression)", async () => {
    const { app } = makeApp(CONFIGURED, { withConfigApi: true, gateMode: 'open' });
    const res = await request(app).put('/api/config').send({ auth: { local: { enabled: false } } });
    expect(res.status).toBe(200);
  });

  it('leaves GET /api/config open', async () => {
    const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
    ensureSetupToken(dataDir);
    const res = await request(app).get('/api/config');
    expect(res.status).toBe(200);
  });

  it('GET /api/config masks the bootstrap admin password after a local-auth apply (P1 #1)', async () => {
    const { app, dataDir } = makeApp(UNCONFIGURED, { withConfigApi: true, gateMode: 'token-required' });
    const token = ensureSetupToken(dataDir);
    const applied = await request(app)
      .post('/api/setup/apply')
      .set('X-Setup-Token', token)
      .send({
        llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' },
        auth: { mode: 'local', local: { email: 'admin@x.io', password: 'supersecret9' } },
      });
    expect(applied.status).toBe(200);
    const res = await request(app).get('/api/config');
    expect(res.status).toBe(200);
    expect(JSON.stringify(res.body)).not.toContain('supersecret9');
  });
});

describe('setup-api: POST /api/setup/apply', () => {
  function freshApp() {
    const ctx = makeApp(UNCONFIGURED);
    const token = ensureSetupToken(ctx.dataDir);
    return { ...ctx, token };
  }

  it('LLM-only apply reflects a camelCase worker and clears the token (restart not required)', async () => {
    const { app, cm, dataDir, token } = freshApp();
    const res = await request(app)
      .post('/api/setup/apply')
      .set('X-Setup-Token', token)
      .send({ llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'qwen3' } });
    expect(res.status).toBe(200);
    expect(res.body).toEqual({ ok: true, restartRequired: false });
    const workers = cm.getConfig().provider.workers ?? [];
    const w = workers.find((x) => x.model === 'qwen3');
    // If the camelCase keys had been double-converted (P1 #1 regression), the
    // worker's endpoint/model/roles would not survive into provider.workers.
    expect(w).toBeTruthy();
    expect(w!.endpoint).toBe('http://localhost:11434/v1');
    expect(w!.proxy).not.toBe(true); // connectionType:'direct' → proxy omitted/false
    expect(w!.roles).toEqual(expect.arrayContaining(['auto', 'fast', 'quality', 'title', 'reflection']));
    // window closed: token removed so no-auth /api/config reverts to open
    expect(readSetupToken(dataDir)).toBeNull();
  });

  it('gateway apply requires an apiKey', async () => {
    const { app, token } = freshApp();
    const res = await request(app)
      .post('/api/setup/apply')
      .set('X-Setup-Token', token)
      .send({ llm: { connectionType: 'aao_gateway', endpoint: 'http://gw:4000/v1', model: 'm' } });
    expect(res.status).toBe(400);
  });

  it('port apply sets restartRequired and keeps the token', async () => {
    const { app, cm, dataDir, token } = freshApp();
    const res = await request(app)
      .post('/api/setup/apply')
      .set('X-Setup-Token', token)
      .send({ llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' }, port: 8088 });
    expect(res.status).toBe(200);
    expect(res.body.restartRequired).toBe(true);
    expect(cm.getConfig().server?.port).toBe(8088);
    expect(readSetupToken(dataDir)).not.toBeNull(); // kept until restart
  });

  it('rejects an incomplete local auth block (shared validator)', async () => {
    const { app, token } = freshApp();
    const res = await request(app)
      .post('/api/setup/apply')
      .set('X-Setup-Token', token)
      .send({ auth: { mode: 'local', local: { email: 'a@b.c' } } }); // no password
    expect(res.status).toBe(400);
  });

  it('rejects an incomplete oauth block', async () => {
    const { app, token } = freshApp();
    const res = await request(app)
      .post('/api/setup/apply')
      .set('X-Setup-Token', token)
      .send({ auth: { mode: 'oauth', oauth: { provider: 'gitea', clientId: 'x', clientSecret: 'y', callbackUrl: 'http://h/cb' } } }); // gitea needs baseUrl
    expect(res.status).toBe(400);
  });

  it('rejects oauth without an admin email (would brick: no admin after restart)', async () => {
    const { app, token } = freshApp();
    const res = await request(app)
      .post('/api/setup/apply')
      .set('X-Setup-Token', token)
      .send({
        auth: { mode: 'oauth', oauth: { provider: 'google', clientId: 'x', clientSecret: 'y', callbackUrl: 'http://h/cb' } },
      });
    expect(res.status).toBe(400);
    expect(String(res.body.error)).toMatch(/admin email/i);
  });

  it('applies a complete oauth block and writes auth.adminEmails', async () => {
    const { app, cm, token } = freshApp();
    const res = await request(app)
      .post('/api/setup/apply')
      .set('X-Setup-Token', token)
      .send({
        llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' },
        auth: {
          mode: 'oauth',
          oauth: { provider: 'google', clientId: 'cid', clientSecret: 'sec', callbackUrl: 'http://h/cb', adminEmail: 'boss@x.io' },
        },
      });
    expect(res.status).toBe(200);
    expect(res.body.restartRequired).toBe(true);
    expect(res.body.adminEmail).toBe('boss@x.io');
    expect(JSON.stringify(res.body)).not.toContain('sec'); // client secret not echoed
    const auth = cm.getConfig().auth;
    expect(auth?.adminEmails).toContain('boss@x.io');
    expect(auth?.providers?.google?.clientId).toBe('cid');
  });

  it('applies a complete local auth block, returns adminEmail, never echoes the password', async () => {
    const { app, cm, token } = freshApp();
    const res = await request(app)
      .post('/api/setup/apply')
      .set('X-Setup-Token', token)
      .send({
        llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' },
        auth: { mode: 'local', local: { email: 'admin@x.io', password: 'supersecret1' } },
      });
    expect(res.status).toBe(200);
    expect(res.body.restartRequired).toBe(true);
    expect(res.body.adminEmail).toBe('admin@x.io');
    expect(JSON.stringify(res.body)).not.toContain('supersecret1');
    const auth = cm.getConfig().auth;
    expect(auth?.local?.enabled).toBe(true);
    expect(auth?.local?.bootstrapAdmin?.email).toBe('admin@x.io');
  });

  it('400 when nothing to apply', async () => {
    const { app, token } = freshApp();
    const res = await request(app).post('/api/setup/apply').set('X-Setup-Token', token).send({});
    expect(res.status).toBe(400);
  });

  it('rejects a link-local / metadata LLM endpoint (apply SSRF denylist, parity with probe)', async () => {
    const { app, token } = freshApp();
    const res = await request(app)
      .post('/api/setup/apply')
      .set('X-Setup-Token', token)
      .send({ llm: { connectionType: 'direct', endpoint: 'http://169.254.169.254/v1', model: 'm' } });
    expect(res.status).toBe(400);
    expect(String(res.body.error)).toMatch(/not allowed|blocked/);
  });

  it('rejects an over-long local auth password', async () => {
    const { app, token } = freshApp();
    const res = await request(app)
      .post('/api/setup/apply')
      .set('X-Setup-Token', token)
      .send({
        llm: { connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'm' },
        auth: { mode: 'local', local: { email: 'a@b.c', password: 'x'.repeat(2000) } },
      });
    expect(res.status).toBe(400);
  });
});

describe('setup-api: POST /api/setup/probe', () => {
  function freshApp() {
    const ctx = makeApp(UNCONFIGURED);
    const token = ensureSetupToken(ctx.dataDir);
    return { ...ctx, token };
  }

  it('400 on an invalid connectionType', async () => {
    const { app, token } = freshApp();
    const res = await request(app)
      .post('/api/setup/probe')
      .set('X-Setup-Token', token)
      .send({ connectionType: 'bogus', endpoint: 'http://localhost:11434' });
    expect(res.status).toBe(400);
  });

  it('400 + blocked message for the cloud metadata IP (SSRF denylist)', async () => {
    const { app, token } = freshApp();
    const res = await request(app)
      .post('/api/setup/probe')
      .set('X-Setup-Token', token)
      .send({ connectionType: 'direct', endpoint: 'http://169.254.169.254/v1' });
    expect(res.status).toBe(400);
    expect(String(res.body.error)).toMatch(/not allowed|blocked/);
  });

  describe('against a stub Ollama server', () => {
    let server: Server;
    let url: string;
    beforeEach(async () => {
      server = createServer((req, res) => {
        if (req.url === '/api/tags') {
          res.setHeader('content-type', 'application/json');
          res.end(JSON.stringify({ models: [{ name: 'llama3' }, { name: 'qwen3' }] }));
        } else {
          res.statusCode = 404;
          res.end('no');
        }
      });
      await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
      const addr = server.address();
      const port = typeof addr === 'object' && addr ? addr.port : 0;
      url = `http://127.0.0.1:${port}/v1`;
    });
    afterEach(async () => {
      await new Promise<void>((r) => server.close(() => r()));
    });

    it('returns the model list from the live endpoint', async () => {
      const { app, token } = freshApp();
      const res = await request(app)
        .post('/api/setup/probe')
        .set('X-Setup-Token', token)
        .send({ connectionType: 'direct', endpoint: url });
      expect(res.status).toBe(200);
      expect(res.body.ok).toBe(true);
      expect(res.body.models).toEqual(['llama3', 'qwen3']);
    });
  });
});
