// Integration test for the TOTP two-step login + recovery codes.
//
// Mounts the real auth router with a session store on the throwaway test DB.
// Drives: enroll (setup → enable) → logout → password login returns mfa_required
// → complete with a generated code → and the recovery-code single-use path.
// Skips when TEST_DATABASE_URL is unset.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import session from 'express-session';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import { hashPassword } from '../../src/auth/passwords.js';
import { generateToken } from '../../src/auth/totp.js';

const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';

async function appWithAuth(pool) {
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
  process.env.AUTH_ENABLED = 'true';
  process.env.SESSION_SECRET = 'test';
  const ConnectPg = (await import('connect-pg-simple')).default(session);
  const { default: authRouter } = await import('../../src/routes/auth.js?v=' + Date.now());
  const app = express();
  app.use(express.json());
  app.use(session({
    store: new ConnectPg({ pool, tableName: 'sessions' }),
    secret: 'test', name: 'dragonflight.sid',
    cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
    rolling: false, resave: false, saveUninitialized: false,
  }));
  app.use('/api/v1/auth', authRouter);
  return new Promise(r => {
    const srv = app.listen(0, '127.0.0.1', () =>
      r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
  });
}

const J = (cookie, body) => ({
  method: 'POST',
  headers: { 'Content-Type': 'application/json', ...(cookie ? { cookie } : {}) },
  body: JSON.stringify(body),
});

async function loginPassword(baseUrl, username, password) {
  const r = await fetch(baseUrl + '/api/v1/auth/login', J(null, { username, password }));
  const cookie = (r.headers.get('set-cookie') || '').split(';')[0];
  return { r, body: await r.json().catch(() => ({})), cookie };
}

test('enable TOTP, then password login requires a second factor', { skip: SKIP }, async () => {
  const pool = await setupTestDb();
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
  try {
    await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]);
    const { baseUrl, close } = await appWithAuth(pool);

    // 1. Password login (no TOTP yet) → 200 with a session cookie.
    const first = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
    assert.equal(first.r.status, 200);
    assert.ok(!first.body.mfa_required);
    const cookie = first.cookie;

    // 2. Enroll: setup returns a secret; enable confirms with a live code.
    const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(cookie, {}))).json();
    assert.match(setup.secret, /^[A-Z2-7]+$/);
    const enableRes = await fetch(baseUrl + '/api/v1/auth/totp/enable', J(cookie, { code: generateToken(setup.secret) }));
    assert.equal(enableRes.status, 200);
    const enableBody = await enableRes.json();
    assert.equal(enableBody.enabled, true);
    assert.equal(enableBody.recovery_codes.length, 10);

    // 3. Fresh password login now returns mfa_required + a ticket, NO session cookie.
    const second = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
    assert.equal(second.r.status, 200);
    assert.equal(second.body.mfa_required, true);
    assert.ok(second.body.ticket);
    assert.ok(!second.cookie, 'no session cookie should be set before the second factor');

    // 4. Wrong code → 401; the ticket is now spent.
    const bad = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: second.body.ticket, code: '000000' }));
    assert.equal(bad.status, 401);

    // 5. New login + correct code → 200 with a session cookie.
    const third = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
    const ok = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: third.body.ticket, code: generateToken(setup.secret) }));
    assert.equal(ok.status, 200);
    assert.match(ok.headers.get('set-cookie') || '', /dragonflight\.sid=/);

    await close();
  } finally { await pool.end(); }
});

test('a recovery code logs in once and cannot be reused', { skip: SKIP }, async () => {
  const pool = await setupTestDb();
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
  try {
    await pool.query(`INSERT INTO users (username, password_hash) VALUES ('bob', $1)`, [await hashPassword('correct-horse-battery')]);
    const { baseUrl, close } = await appWithAuth(pool);

    const first = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
    const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json();
    const enableBody = await (await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) }))).json();
    const recovery = enableBody.recovery_codes[0];

    // Use a recovery code to complete a fresh login.
    const login1 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
    const use1 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login1.body.ticket, code: recovery }));
    assert.equal(use1.status, 200, 'recovery code should complete login once');

    // The same recovery code must NOT work a second time.
    const login2 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
    const use2 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login2.body.ticket, code: recovery }));
    assert.equal(use2.status, 401, 'a spent recovery code must be rejected');

    await close();
  } finally { await pool.end(); }
});

test('disable TOTP returns login to single-factor', { skip: SKIP }, async () => {
  const pool = await setupTestDb();
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
  try {
    await pool.query(`INSERT INTO users (username, password_hash) VALUES ('carol', $1)`, [await hashPassword('correct-horse-battery')]);
    const { baseUrl, close } = await appWithAuth(pool);

    const first = await loginPassword(baseUrl, 'carol', 'correct-horse-battery');
    const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json();
    await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) }));

    // Disabling requires the password.
    const wrongPw = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'nope' }));
    assert.equal(wrongPw.status, 400);
    const disabled = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'correct-horse-battery' }));
    assert.equal(disabled.status, 204);

    // Password login is single-factor again.
    const relog = await loginPassword(baseUrl, 'carol', 'correct-horse-battery');
    assert.equal(relog.r.status, 200);
    assert.ok(!relog.body.mfa_required);

    await close();
  } finally { await pool.end(); }
});
