/**
 * Integration tests for routes/auth/totp.js — the full TOTP auth flow.
 *
 * Covers the BACKLOG.md DC-006 acceptance criteria:
 *   - no code      → 400 (ValidationError)
 *   - wrong code   → 401 (AuthenticationError)
 *   - valid TOTP   → 200 + session cookie + CSRF token
 *   - check-session with valid session → 200 { authenticated: true }
 *   - check-session without session    → 401 (AuthenticationError)
 *
 * Uses real otplib for code generation (so we exercise the actual TOTP math)
 * but mocks credentialManager, session, totpConfig, and saveTotpConfig —
 * because those modules own their own state machines (disk, cookies, file)
 * that don't belong in a routes-level test.
 *
 * NOTE: this test exercises the src/ refactored module layout (DC-005).
 * It depends on routes/auth/totp.js requiring ../../src/utilities/errors and
 * ../../src/utils/responses — fix the relative paths in totp.js if they
 * regress (see commit log for DC-006).
 */

const express = require('express');
const request = require('supertest');
const { authenticator } = require('otplib');

// Quiet otplib's "Unescaped left brace" warning on Node 20+
const origWarn = console.warn;
beforeAll(() => {
  console.warn = (...args) => {
    const msg = args.join(' ');
    if (msg.includes('Unescaped left brace')) return;
    origWarn.apply(console, args);
  };
});
afterAll(() => {
  console.warn = origWarn;
});

// Minimal asyncHandler that catches errors into the express error chain
function asyncHandler(fn) {
  return (req, res, _next) => Promise.resolve(fn(req, res, _next)).catch(_next);
}

function createApp(depsOverride = {}) {
  // In-memory secret store so credentialManager stays deterministic
  const storedSecrets = new Map();
  const credentialManager = {
    store: jest.fn((key, value) => {
      storedSecrets.set(key, value);
      return Promise.resolve(true);
    }),
    retrieve: jest.fn((key) => Promise.resolve(storedSecrets.has(key) ? storedSecrets.get(key) : null)),
    delete: jest.fn((key) => {
      storedSecrets.delete(key);
      return Promise.resolve(true);
    }),
    list: jest.fn(() => Promise.resolve(Array.from(storedSecrets.keys()))),
  };

  // Mutable TOTP config — tests mutate this to model setup → enable → disable
  const totpConfig = {
    enabled: false,
    isSetUp: false,
    sessionDuration: '24h',
    secret: null, // matches main's optional backup-secret field
  };

  // Mock session context mirroring src/context/session.js
  // isValid() is the knob — toggle it to test the auth-gate behavior
  const sessionStore = new Map(); // ip → { expiresAt }
  const session = {
    create: jest.fn((req, duration) => {
      const ip = session.getClientIP(req);
      sessionStore.set(ip, { expiresAt: Date.now() + (duration === 'never' ? Number.MAX_SAFE_INTEGER : 3600000) });
    }),
    setCookie: jest.fn(),
    clear: jest.fn((req) => {
      const ip = session.getClientIP(req);
      sessionStore.delete(ip);
    }),
    clearCookie: jest.fn(),
    isValid: jest.fn((req) => {
      const ip = session.getClientIP(req);
      const entry = sessionStore.get(ip);
      if (!entry) return false;
      return entry.expiresAt > Date.now();
    }),
    // Test helper — pretend an IP has a valid session, regardless of req.ip
    _grantSession: (ip = '127.0.0.1') => sessionStore.set(ip, { expiresAt: Date.now() + 3600000 }),
    getClientIP: jest.fn((req) => req.ip || req.connection?.remoteAddress || '127.0.0.1'),
    ipSessions: sessionStore,
    durations: { '1h': 3600000, '24h': 86400000, '7d': 604800000, 'never': 0 },
  };

  const saveTotpConfig = jest.fn(() => Promise.resolve(true));
  const renewCSRFToken = jest.fn(() => 'mock-csrf-token');
  const log = {
    info: jest.fn(),
    warn: jest.fn(),
    error: jest.fn(),
    debug: jest.fn(),
  };

  const deps = {
    authManager: {}, // unused by totp.js but required by the factory signature
    credentialManager,
    totpConfig,
    saveTotpConfig,
    session,
    asyncHandler,
    errorResponse: jest.fn(),
    log,
    renewCSRFToken,
    ...depsOverride,
  };

  // Clear store between tests
  deps._resetStore = () => {
    storedSecrets.clear();
    sessionStore.clear();
    totpConfig.enabled = false;
    totpConfig.isSetUp = false;
    totpConfig.sessionDuration = '24h';
    delete totpConfig.secret;
  };

  const totpRoutes = require('../../routes/auth/totp');
  const app = express();
  app.set('trust proxy', true); // so req.ip populates from X-Forwarded-For
  app.use(express.json());
  app.use('/api', totpRoutes(deps));
  // Express error handler — surface status from thrown AppError
  app.use((err, req, res, _next) => {
    const status = err.statusCode || 500;
    res.status(status).json({ success: false, error: err.message });
  });

  return { app, deps };
}

describe('TOTP Auth Routes — DC-006 Integration Test', () => {
  let app;
  let deps;

  beforeEach(() => {
    jest.clearAllMocks();
    ({ app, deps } = createApp());
    authenticator.options = { window: 1 };
  });

  // Helper: derive a fresh secret + a valid current TOTP code for it
  function freshSecret() {
    const secret = authenticator.generateSecret();
    const token = authenticator.generate(secret);
    return { secret, token };
  }

  // ────────────────────────────────────────────────────────────────────
  //  GET /api/totp/config
  // ────────────────────────────────────────────────────────────────────
  describe('GET /api/totp/config', () => {
    it('returns current config (enabled=false, isSetUp=false by default)', async () => {
      const res = await request(app).get('/api/totp/config');
      expect(res.status).toBe(200);
      expect(res.body.success).toBe(true);
      expect(res.body.config).toEqual({
        enabled: false,
        sessionDuration: '24h',
        isSetUp: false,
      });
    });

    it('reflects state changes after setup completes', async () => {
      deps.totpConfig.isSetUp = true;
      deps.totpConfig.enabled = true;
      const res = await request(app).get('/api/totp/config');
      expect(res.status).toBe(200);
      expect(res.body.config.isSetUp).toBe(true);
      expect(res.body.config.enabled).toBe(true);
    });
  });

  // ────────────────────────────────────────────────────────────────────
  //  POST /api/totp/setup
  // ────────────────────────────────────────────────────────────────────
  describe('POST /api/totp/setup', () => {
    it('generates a fresh secret + QR code when none is provided', async () => {
      const res = await request(app).post('/api/totp/setup').send({});
      expect(res.status).toBe(200);
      expect(res.body.success).toBe(true);
      expect(res.body.qrCode).toMatch(/^data:image\/png;base64,/);
      expect(res.body.manualKey).toMatch(/^[A-Z2-7]{16,}$/);
      expect(res.body.issuer).toBe('DashCaddy');
      expect(res.body.imported).toBe(false);
      // pending_secret should be stashed but totp.secret should NOT be active yet
      expect(deps.credentialManager.store).toHaveBeenCalledWith('totp.pending_secret', res.body.manualKey);
      expect(await deps.credentialManager.retrieve('totp.secret')).toBeNull();
    });

    it('accepts and normalizes a user-provided Base32 secret (0→O, 1→L, 8→B, lowercase→uppercase)', async () => {
      const raw = 'JBSWY3DPEHPK3PXP'; // canonical example
      const userInput = ' jbswy3dpehpk3pxp '; // spaces + lowercase
      const res = await request(app).post('/api/totp/setup').send({ secret: userInput });
      expect(res.status).toBe(200);
      expect(res.body.manualKey).toBe(raw);
      expect(res.body.imported).toBe(true);
    });

    it('rejects an obviously invalid secret (wrong alphabet)', async () => {
      const res = await request(app).post('/api/totp/setup').send({ secret: 'NOT-VALID-BASE32!' });
      expect(res.status).toBe(400);
      expect(res.body.success).toBe(false);
      expect(res.body.error).toMatch(/Invalid secret key format/);
    });
  });

  // ────────────────────────────────────────────────────────────────────
  //  POST /api/totp/verify-setup  (activates TOTP after setup)
  // ────────────────────────────────────────────────────────────────────
  describe('POST /api/totp/verify-setup', () => {
    it('returns 400 when code is missing or malformed', async () => {
      const res = await request(app).post('/api/totp/verify-setup').send({});
      expect(res.status).toBe(400);
      expect(res.body.error).toMatch(/Invalid code format/);
    });

    it('returns 400 when no pending setup exists', async () => {
      const { token } = freshSecret();
      const res = await request(app).post('/api/totp/verify-setup').send({ code: token });
      expect(res.status).toBe(400);
      expect(res.body.error).toMatch(/No pending TOTP setup/);
    });

    it('returns 401 when code is wrong', async () => {
      const { secret } = freshSecret();
      await request(app).post('/api/totp/setup').send({ secret });
      const res = await request(app).post('/api/totp/verify-setup').send({ code: '000000' });
      expect(res.status).toBe(401);
      expect(res.body.error).toMatch(/DC-111/);
    });

    it('returns 200 + activates TOTP + creates session on valid code', async () => {
      const { secret, token } = freshSecret();
      await request(app).post('/api/totp/setup').send({ secret });
      const res = await request(app).post('/api/totp/verify-setup').send({ code: token });
      expect(res.status).toBe(200);
      expect(res.body.success).toBe(true);
      expect(res.body.message).toMatch(/TOTP enabled successfully/);

      // TOTP config activated + persisted
      expect(deps.totpConfig.isSetUp).toBe(true);
      expect(deps.totpConfig.enabled).toBe(true);
      expect(deps.saveTotpConfig).toHaveBeenCalled();

      // pending_secret → totp.secret promotion, pending cleared
      expect(await deps.credentialManager.retrieve('totp.secret')).toBe(secret);
      expect(await deps.credentialManager.retrieve('totp.pending_secret')).toBeNull();

      // Session established
      expect(deps.session.create).toHaveBeenCalled();
      expect(deps.session.setCookie).toHaveBeenCalled();
      // Note: renewCSRFToken is only called on /totp/verify (login), not /totp/verify-setup
    });
  });

  // ────────────────────────────────────────────────────────────────────
  //  POST /api/totp/verify  (login flow — TOTP already configured)
  // ────────────────────────────────────────────────────────────────────
  describe('POST /api/totp/verify (login)', () => {
    async function setupTOTP() {
      const { secret, token } = freshSecret();
      await request(app).post('/api/totp/setup').send({ secret });
      await request(app).post('/api/totp/verify-setup').send({ code: token });
      // Reset mocks but keep config/secret state for the test
      jest.clearAllMocks();
      return secret;
    }

    it('returns 400 when code is missing', async () => {
      const res = await request(app).post('/api/totp/verify').send({});
      expect(res.status).toBe(400);
      expect(res.body.error).toMatch(/Invalid code format/);
    });

    it('returns 400 when TOTP is not enabled', async () => {
      const res = await request(app).post('/api/totp/verify').send({ code: '123456' });
      expect(res.status).toBe(400);
      expect(res.body.error).toMatch(/TOTP is not enabled/);
    });

    it('returns 401 when code is wrong (TOTP active)', async () => {
      await setupTOTP();
      const res = await request(app).post('/api/totp/verify').send({ code: '000000' });
      expect(res.status).toBe(401);
      expect(res.body.error).toMatch(/DC-111/);
    });

    it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => {
      const secret = await setupTOTP();
      const token = authenticator.generate(secret);
      const res = await request(app).post('/api/totp/verify').send({ code: token });
      expect(res.status).toBe(200);
      expect(res.body.success).toBe(true);
      expect(res.body.message).toMatch(/Authenticated successfully/);
      expect(res.body.csrfToken).toBe('mock-csrf-token');
      expect(deps.session.create).toHaveBeenCalled();
      expect(deps.session.setCookie).toHaveBeenCalled();
      expect(deps.renewCSRFToken).toHaveBeenCalled();
    });
  });

  // ────────────────────────────────────────────────────────────────────
  //  GET /api/totp/check-session  (the auth gate Caddy calls)
  // ────────────────────────────────────────────────────────────────────
  describe('GET /api/totp/check-session', () => {
    it('always returns 200 when TOTP is not enabled (passthrough)', async () => {
      const res = await request(app).get('/api/totp/check-session');
      expect(res.status).toBe(200);
      expect(res.body).toEqual({ authenticated: true });
    });

    it('always returns 200 when sessionDuration is "never" (passthrough)', async () => {
      deps.totpConfig.enabled = true;
      deps.totpConfig.isSetUp = true;
      deps.totpConfig.sessionDuration = 'never';
      const res = await request(app).get('/api/totp/check-session');
      expect(res.status).toBe(200);
      expect(res.body).toEqual({ authenticated: true });
    });

    it('returns 401 when no session exists (BACKLOG: "no token → 401")', async () => {
      deps.totpConfig.enabled = true;
      deps.totpConfig.isSetUp = true;
      deps.totpConfig.sessionDuration = '24h';
      // session.isValid returns false because sessionStore is empty
      const res = await request(app).get('/api/totp/check-session');
      expect(res.status).toBe(401);
      expect(res.body.error).toMatch(/Session expired or invalid/);
      // Cache-control headers must be set to avoid Caddy auth loops
      expect(res.headers['cache-control']).toMatch(/no-store/);
    });

    it('returns 200 { authenticated: true } when session is valid (BACKLOG: "authenticated request succeeds")', async () => {
      deps.totpConfig.enabled = true;
      deps.totpConfig.isSetUp = true;
      deps.totpConfig.sessionDuration = '24h';
      // Pre-populate the session store as if verify already ran
      deps.session._grantSession('127.0.0.1');
      const res = await request(app).get('/api/totp/check-session').set('X-Forwarded-For', '127.0.0.1');
      expect(res.status).toBe(200);
      expect(res.body).toEqual({ authenticated: true });
    });
  });

  // ────────────────────────────────────────────────────────────────────
  //  POST /api/totp/disable
  // ────────────────────────────────────────────────────────────────────
  describe('POST /api/totp/disable', () => {
    async function setupTOTP() {
      const { secret, token } = freshSecret();
      await request(app).post('/api/totp/setup').send({ secret });
      await request(app).post('/api/totp/verify-setup').send({ code: token });
      jest.clearAllMocks();
      return secret;
    }

    it('returns 400 when TOTP is active but no code is provided', async () => {
      await setupTOTP();
      const res = await request(app).post('/api/totp/disable').send({});
      expect(res.status).toBe(400);
      expect(res.body.error).toMatch(/valid TOTP code is required/);
    });

    it('returns 401 when code is wrong', async () => {
      await setupTOTP();
      const res = await request(app).post('/api/totp/disable').send({ code: '000000' });
      expect(res.status).toBe(401);
      expect(res.body.error).toMatch(/DC-111/);
    });

    it('returns 200 + clears TOTP state on valid code', async () => {
      const secret = await setupTOTP();
      const code = authenticator.generate(secret);
      const res = await request(app).post('/api/totp/disable').send({ code });
      expect(res.status).toBe(200);
      expect(res.body.success).toBe(true);

      // TOTP disabled, secrets cleared, session cleared
      expect(deps.totpConfig.enabled).toBe(false);
      expect(deps.totpConfig.isSetUp).toBe(false);
      expect(deps.totpConfig.sessionDuration).toBe('never');
      expect(await deps.credentialManager.retrieve('totp.secret')).toBeNull();
      expect(await deps.credentialManager.retrieve('totp.pending_secret')).toBeNull();
      expect(deps.session.clear).toHaveBeenCalled();
      expect(deps.session.clearCookie).toHaveBeenCalled();
      expect(deps.saveTotpConfig).toHaveBeenCalled();
    });
  });

  // ────────────────────────────────────────────────────────────────────
  //  POST /api/totp/config  (session duration change)
  // ────────────────────────────────────────────────────────────────────
  describe('POST /api/totp/config (update settings)', () => {
    it('updates sessionDuration with a valid value', async () => {
      const res = await request(app).post('/api/totp/config').send({ sessionDuration: '7d' });
      expect(res.status).toBe(200);
      expect(res.body.config.sessionDuration).toBe('7d');
      expect(deps.saveTotpConfig).toHaveBeenCalled();
    });

    it('rejects an invalid sessionDuration', async () => {
      const res = await request(app).post('/api/totp/config').send({ sessionDuration: '99y' });
      expect(res.status).toBe(400);
      expect(res.body.error).toMatch(/Invalid session duration/);
    });

    it('setting sessionDuration to "never" disables TOTP', async () => {
      deps.totpConfig.enabled = true;
      deps.totpConfig.isSetUp = true;
      const res = await request(app).post('/api/totp/config').send({ sessionDuration: 'never' });
      expect(res.status).toBe(200);
      expect(deps.totpConfig.sessionDuration).toBe('never');
      expect(deps.totpConfig.enabled).toBe(false);
    });
  });

  // ────────────────────────────────────────────────────────────────────
  //  End-to-end flow (BACKLOG: "Cover the full /api/auth/check → session → endpoint flow")
  // ────────────────────────────────────────────────────────────────────
  describe('End-to-end: setup → login → check-session → disable', () => {
    it('walks the full BACKLOG DC-006 flow', async () => {
      // 1. Setup — generate a fresh secret
      const setupRes = await request(app).post('/api/totp/setup').send({});
      expect(setupRes.status).toBe(200);
      const secret = setupRes.body.manualKey;
      const setupCode = authenticator.generate(secret);

      // 2. Verify-setup — activate TOTP
      const verifySetupRes = await request(app).post('/api/totp/verify-setup').send({ code: setupCode });
      expect(verifySetupRes.status).toBe(200);
      expect(deps.totpConfig.isSetUp).toBe(true);

      // 3. Simulate session expiry by clearing the store
      deps.session.ipSessions.clear();

      // 4. Re-login via /totp/verify (the "login" path)
      const loginCode = authenticator.generate(secret);
      const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode });
      expect(loginRes.status).toBe(200);
      expect(loginRes.body.csrfToken).toBeDefined();

      // 5. Check-session — should now be authenticated (the BACKLOG "→ endpoint succeeds" step)
      const checkRes = await request(app).get('/api/totp/check-session');
      expect(checkRes.status).toBe(200);
      expect(checkRes.body).toEqual({ authenticated: true });

      // 6. Logout / disable
      const disableCode = authenticator.generate(secret);
      const disableRes = await request(app).post('/api/totp/disable').send({ code: disableCode });
      expect(disableRes.status).toBe(200);

      // 7. After disable, check-session should be passthrough (TOTP off)
      const afterRes = await request(app).get('/api/totp/check-session');
      expect(afterRes.status).toBe(200);
      expect(afterRes.body).toEqual({ authenticated: true });
    });

    it('proves otplib is real (not stubbed) by using a totally bogus code', async () => {
      // Sanity check that the test harness is using real otplib, not a stub.
      // otplib 12.0.1's authenticator.generate(secret) does not accept a {time} option
      // (the signature is fixed to current-time TOTP), so a "stale code" test isn't
      // reproducible across runs. Instead, we verify otplib rejects a code that is
      // syntactically valid (6 digits) but doesn't match the live TOTP slot.
      const secret = authenticator.generateSecret();
      await request(app).post('/api/totp/setup').send({ secret });
      // Generate the real current code, then mutate it — must be rejected
      const realCode = authenticator.generate(secret);
      const tampered = realCode === '000000' ? '111111' : '000000';
      const res = await request(app).post('/api/totp/verify-setup').send({ code: tampered });
      expect(res.status).toBe(401);
    });
  });
});
