/**
 * End-to-end 2FA flow check against the real routers, mounted on a real Express
 * app, backed by an in-memory model layer. No database, no network.
 */
process.env.JWT_SECRET = 'flow-check-jwt-secret';
process.env.ENCRYPTION_KEY = 'flow-check-encryption-key-32-chars-plus!!';
process.env.NODE_ENV = 'test';

import path from 'path';

const ROOT = path.resolve(__dirname, '../../../../..');
const BASE = `${ROOT}/src/backend/src`;
/* eslint-disable @typescript-eslint/no-var-requires */
const express = require(`${ROOT}/node_modules/express`);
const bcrypt = require(`${ROOT}/node_modules/bcryptjs`);

let fail = 0;
const check = (ok: boolean, label: string) => { if (!ok) fail++; console.log(`${ok ? 'PASS' : 'FAIL'}  ${label}`); };

// ── In-memory model layer ────────────────────────────────────────────────────
let seq = 1;
const nextId = () => `id${seq++}`;
const store: Record<string, any[]> = { users: [], companies: [], userTwoFactors: [], trustedDevices: [], twoFactorChallenges: [], twoFactorPolicies: [], auditlogs: [] };

const matches = (doc: any, q: any): boolean => Object.entries(q || {}).every(([k, v]: any) => {
  const actual = k === '_id' ? doc._id : doc[k];
  if (v && typeof v === 'object' && !(v instanceof Date)) {
    if ('$in' in v) return (v.$in || []).map(String).includes(String(actual));
    if ('$ne' in v) return String(actual) !== String(v.$ne);
    if ('$regex' in v) return new RegExp(v.$regex).test(String(actual ?? ''));
  }
  if (v === null) return actual === null || actual === undefined;
  return String(actual) === String(v);
});

/**
 * Apply an update with the SAME semantics as the project's in-memory mock model
 * (models/index.ts): `$set` is a flat `Object.assign`, with no dotted-path
 * support whatsoever.
 *
 * This fidelity matters. An earlier version of this harness resolved dotted
 * paths like `recoveryCodes.3.usedAt` into the nested value — which the real
 * mock does not do. That made product code using a dotted `$set` pass here while
 * silently failing on a mock-mode dev run (settings never saved, recovery codes
 * never burned). Matching the mock exactly means such a write now fails loudly.
 */
const applyUpdate = (doc: any, update: any) => {
  const set = update.$set || (!update.$inc && !update.$unset ? update : {});
  for (const [k, v] of Object.entries(set)) {
    // Deliberately assigned verbatim, dots and all — see the note above.
    doc[k] = v;
  }
  for (const [k, v] of Object.entries(update.$inc || {})) doc[k] = (doc[k] || 0) + (v as number);
};

/** Fail loudly if any product write leaks a dotted key onto a document. */
const assertNoDottedKeys = (label: string) => {
  for (const [coll, rows] of Object.entries(store)) {
    for (const row of rows as any[]) {
      const bad = Object.keys(row).find(k => k.includes('.'));
      if (bad) {
        console.log(`FAIL  ${label}: dotted key "${bad}" written to ${coll} — this silently no-ops in mock mode`);
        fail++;
        return;
      }
    }
  }
  console.log(`PASS  ${label}`);
};

function makeModel(coll: string) {
  const rows = store[coll];
  const hydrate = (d: any) => ({ ...d, id: d._id, save: async () => d, comparePassword: async (p: string) => bcrypt.compare(p, d.passwordHash) });
  const chain = (result: any) => ({ select: () => chain(result), sort: () => chain(result), lean: async () => result, then: (r: any) => Promise.resolve(result).then(r) });
  return {
    findOne: (q: any) => chain(rows.filter(d => matches(d, q)).map(hydrate)[0] || null),
    findById: (id: any) => chain(rows.filter(d => String(d._id) === String(id)).map(hydrate)[0] || null),
    find: (q: any) => chain(rows.filter(d => matches(d, q)).map(hydrate)),
    create: async (data: any) => { const d = { _id: nextId(), createdAt: new Date(), ...data }; rows.push(d); return hydrate(d); },
    findByIdAndUpdate: async (id: any, u: any) => { const d = rows.find(r => String(r._id) === String(id)); if (d) applyUpdate(d, u); return d ? hydrate(d) : null; },
    findOneAndUpdate: async (q: any, u: any, o: any = {}) => {
      let d = rows.find(r => matches(r, q));
      if (!d && o.upsert) { d = { _id: nextId(), createdAt: new Date() }; rows.push(d); }
      if (d) applyUpdate(d, u);
      return d ? hydrate(d) : null;
    },
    findOneAndDelete: async (q: any) => { const i = rows.findIndex(r => matches(r, q)); return i >= 0 ? hydrate(rows.splice(i, 1)[0]) : null; },
    deleteMany: async (q: any) => { const before = rows.length; for (let i = rows.length - 1; i >= 0; i--) if (matches(rows[i], q)) rows.splice(i, 1); return { deletedCount: before - rows.length }; },
  };
}

const modelsPath = require.resolve(`${BASE}/models`);
require.cache[modelsPath] = { id: modelsPath, filename: modelsPath, loaded: true, exports: {
  getModels: () => ({
    User: makeModel('users'), Company: makeModel('companies'),
    UserTwoFactor: makeModel('userTwoFactors'), TrustedDevice: makeModel('trustedDevices'),
    TwoFactorChallenge: makeModel('twoFactorChallenges'), TwoFactorPolicy: makeModel('twoFactorPolicies'), AuditLog: makeModel('auditlogs'),
    Role: makeModel('users'), UserAccessOverride: makeModel('users'),
  }),
} } as any;

// Rate limiters would reject the rapid-fire calls below; this check is about
// flow correctness, and the limiter itself is exercised separately.
const rlPath = require.resolve(`${BASE}/middleware/rateLimiter`);
const passthrough = (_req: any, _res: any, next: any) => next();
require.cache[rlPath] = { id: rlPath, filename: rlPath, loaded: true, exports: {
  rateLimiter: passthrough, authRateLimiter: passthrough, twoFactorRateLimiter: passthrough,
  otpSendRateLimiter: passthrough, claudeRateLimiter: passthrough,
  // routes/auth.ts imports these two as well; a missing name here reaches
  // express as an undefined middleware and the whole suite fails to load.
  registerRateLimiter: passthrough, passwordResetRateLimiter: passthrough,
} } as any;

const { getTwoFactorSettings, invalidateTwoFactorSettingsCache } = require(`${BASE}/services/auth/twoFactorSettings`);
const { generateTotp } = require(`${BASE}/services/auth/totp`);
const { openSecret } = require(`${BASE}/services/auth/secretBox`);

const app = express();
app.use(express.json());
app.use((req: any, res: any, next: any) => { res.locals = res.locals || {}; next(); });
app.use('/api/auth/2fa', require(`${BASE}/routes/twoFactor`).default);
app.use('/api/auth', require(`${BASE}/routes/auth`).default);

// ── Minimal HTTP client ──────────────────────────────────────────────────────
let server: any;
const PORT = 4599;
async function call(method: string, path: string, body?: any, token?: string) {
  const res = await fetch(`http://127.0.0.1:${PORT}${path}`, {
    method,
    headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
    body: body ? JSON.stringify(body) : undefined,
  });
  return { status: res.status, body: await res.json().catch(() => ({})) };
}

const setPolicy = (twoFactor: any) => {
  store.users.find(u => u.role === 'super-admin')!.panelSettings = { twoFactor };
  invalidateTwoFactorSettingsCache();
  require(`${BASE}/services/auth/policyResolution`).invalidatePolicyCache();
};

/**
 * A code the replay guard will accept.
 *
 * The guard requires each accepted TOTP step to be strictly newer than the last
 * one used — correct behaviour, but it means a test firing several logins inside
 * one 30-second window would legitimately be refused for reusing a code. Real
 * users simply read the next code off their phone; here we clear the stored step
 * to stand in for that passage of time. The guard itself is asserted separately
 * and explicitly below, so nothing is being papered over.
 */
function freshCode(secret: string, userId: string): string {
  const rec = store.userTwoFactors.find((t: any) => String(t.userId) === userId);
  if (rec) rec.lastUsedTimeStep = 0;
  return generateTotp(secret);
}

(async () => {
  server = app.listen(PORT);
  await new Promise(r => setTimeout(r, 300));

  const pwHash = await bcrypt.hash('Str0ng!Pass', 10);
  store.companies.push({ _id: 'c1', name: 'Acme', isActive: true });
  store.users.push({ _id: 'sa1', email: 'sa@x.com', name: 'Root', role: 'super-admin', passwordHash: pwHash, companyIds: ['c1'], activeCompanyId: 'c1', panelSettings: {} });
  store.users.push({ _id: 'u1', email: 'manager@x.com', name: 'Ann', role: 'manager', passwordHash: pwHash, companyIds: ['c1'], activeCompanyId: 'c1', isOrgAdmin: true });
  store.users.push({ _id: 'e1', email: 'editor@x.com', name: 'Ed', role: 'editor', passwordHash: pwHash, companyIds: ['c1'], activeCompanyId: 'c1' });
  store.users.push({ _id: 'v1', email: 'viewer@x.com', name: 'Vic', role: 'viewer', passwordHash: pwHash, companyIds: ['c1'], activeCompanyId: 'c1' });

  // ══ 1. 2FA OFF — baseline must be untouched ═══════════════════════════════
  console.log('\n-- 2FA disabled (default) --');
  invalidateTwoFactorSettingsCache();
  let r = await call('POST', '/api/auth/login', { email: 'manager@x.com', password: 'Str0ng!Pass' });
  check(r.status === 200 && !!r.body.token, 'login returns a token');
  check(!!r.body.user && Array.isArray(r.body.companies), 'login returns user + companies');
  check(!r.body.twoFactorRequired && !r.body.challengeToken, 'no 2FA fields leak into the normal response');
  const baselineKeys = Object.keys(r.body).sort().join(',');
  check(baselineKeys === 'companies,token,user', `response keys unchanged -> ${baselineKeys}`);
  check((await call('POST', '/api/auth/login', { email: 'manager@x.com', password: 'wrong' })).status === 401, 'wrong password still 401');
  const sessionToken = r.body.token;
  check((await call('GET', '/api/auth/me', undefined, sessionToken)).status === 200, 'session token works on /auth/me');

  // ══ 2. Enrolment ═══════════════════════════════════════════════════════════
  console.log('\n-- enrolment --');
  setPolicy({ enabled: true, mode: 'optional', enforceRoles: ['manager', 'editor'] });

  r = await call('GET', '/api/auth/2fa/status', undefined, sessionToken);
  check(r.status === 200 && r.body.status === 'not_configured' && r.body.policy.applies === true, 'status: applies, not configured');

  r = await call('POST', '/api/auth/2fa/setup', {}, sessionToken);
  check(r.status === 200 && r.body.qrDataUri.startsWith('data:image/png;base64,'), 'setup returns a QR data-uri');
  check(/^otpauth:\/\/totp\//.test(r.body.otpauthUri), 'setup returns an otpauth uri');
  check(/^[A-Z2-7]{4}( [A-Z2-7]{4}){7}$/.test(r.body.manualEntryKey), `manual key grouped -> ${r.body.manualEntryKey}`);

  const secret = openSecret(store.userTwoFactors[0].secretCiphertext);
  check(store.userTwoFactors[0].status === 'pending', 'enrolment stored as pending');
  check(!store.userTwoFactors[0].secretCiphertext.includes(secret), 'secret is encrypted at rest');

  r = await call('POST', '/api/auth/2fa/enable', { code: '000000' }, sessionToken);
  check(r.status === 400, 'wrong code does not enable');
  check(store.userTwoFactors[0].status === 'pending', 'still pending after a wrong code');

  r = await call('POST', '/api/auth/2fa/enable', { code: generateTotp(secret) }, sessionToken);
  check(r.status === 200 && r.body.recoveryCodes.length === 10, 'correct code enables and returns 10 recovery codes');
  const recoveryCodes: string[] = r.body.recoveryCodes;
  check(store.userTwoFactors[0].status === 'enabled', 'record marked enabled');

  r = await call('GET', '/api/auth/2fa/status', undefined, sessionToken);
  check(r.body.enabled === true && r.body.recoveryCodesRemaining === 10, 'status reflects enabled + 10 codes');

  // ══ 3. Login with 2FA ══════════════════════════════════════════════════════
  console.log('\n-- login with 2FA --');
  r = await call('POST', '/api/auth/login', { email: 'manager@x.com', password: 'Str0ng!Pass' });
  check(r.status === 200 && r.body.twoFactorRequired === true, 'login now demands a second factor');
  check(r.body.token === undefined, 'NO session token in the challenge response');
  check(!!r.body.challengeToken && r.body.expiresIn === 300, 'challenge token + ttl returned');
  let challengeToken = r.body.challengeToken;

  check((await call('GET', '/api/auth/me', undefined, challengeToken)).status === 401, 'challenge token REJECTED as a session (R-1)');

  r = await call('POST', '/api/auth/2fa/verify', { challengeToken, code: '000000' });
  check(r.status === 401, 'wrong code rejected at verify');
  // Held so the replay probe below can resubmit this exact code. Generating a
  // second one there would be non-deterministic: if the clock rolled into the
  // next 30-second window the new code belongs to a NEWER step, which the guard
  // is right to accept — and the check would fail for a reason that has nothing
  // to do with replay.
  const acceptedCode = freshCode(secret, 'u1');
  r = await call('POST', '/api/auth/2fa/verify', { challengeToken, code: acceptedCode });
  check(r.status === 200 && !!r.body.token, 'correct code returns a session token');
  check(Object.keys(r.body).sort().join(',') === 'companies,token,user', 'verify body shape matches login exactly');
  check((await call('GET', '/api/auth/me', undefined, r.body.token)).status === 200, 'issued token authenticates');

  // The step just accepted must have been recorded — that record IS the replay
  // guard, so assert it before anything else touches the enrolment.
  const recordedStep = store.userTwoFactors.find((t: any) => String(t.userId) === 'u1')!.lastUsedTimeStep;
  check(recordedStep > 0, `time step recorded after a successful verify -> ${recordedStep}`);

  // Replay: a fresh challenge, but the exact code just consumed — what an
  // attacker who observed a code would send. Its step can never exceed
  // lastUsedTimeStep, so this holds whatever the clock does.
  r = await call('POST', '/api/auth/login', { email: 'manager@x.com', password: 'Str0ng!Pass' });
  check((await call('POST', '/api/auth/2fa/verify', { challengeToken: r.body.challengeToken, code: acceptedCode })).status === 401,
    'already-used TOTP code rejected (replay guard)');

  // Replay of a spent challenge: valid code, but the challenge is already used.
  check((await call('POST', '/api/auth/2fa/verify', { challengeToken, code: freshCode(secret, 'u1') })).status === 401,
    'spent challenge cannot be reused');

  // ══ 4. Recovery codes ══════════════════════════════════════════════════════
  console.log('\n-- recovery codes --');
  r = await call('POST', '/api/auth/login', { email: 'manager@x.com', password: 'Str0ng!Pass' });
  r = await call('POST', '/api/auth/2fa/verify', { challengeToken: r.body.challengeToken, code: recoveryCodes[0] });
  check(r.status === 200 && r.body.usedRecoveryCode === true, 'recovery code logs in');
  check(r.body.recoveryCodesRemaining === 9, `remaining count decremented -> ${r.body.recoveryCodesRemaining}`);

  r = await call('POST', '/api/auth/login', { email: 'manager@x.com', password: 'Str0ng!Pass' });
  check((await call('POST', '/api/auth/2fa/verify', { challengeToken: r.body.challengeToken, code: recoveryCodes[0] })).status === 401,
    'burned recovery code cannot be reused');

  // The burn must land in the array itself, not on a dotted key that the mock
  // model would ignore — otherwise the code above only "fails" by accident.
  const burned = store.userTwoFactors.find((t: any) => String(t.userId) === 'u1');
  check(Array.isArray(burned?.recoveryCodes) && burned.recoveryCodes.filter((c: any) => c.usedAt).length === 1,
    `recovery code burn persisted into the array (${burned?.recoveryCodes?.filter((c: any) => c.usedAt).length} marked used)`);
  assertNoDottedKeys('no dotted keys written to any document');

  // ══ 5. Trusted devices ═════════════════════════════════════════════════════
  console.log('\n-- trusted devices --');
  r = await call('POST', '/api/auth/login', { email: 'manager@x.com', password: 'Str0ng!Pass' });
  r = await call('POST', '/api/auth/2fa/verify', { challengeToken: r.body.challengeToken, code: freshCode(secret, 'u1'), trustDevice: true });
  check(r.status === 200 && !!r.body.trustedDeviceToken, 'trustDevice returns a device token');
  const deviceToken = r.body.trustedDeviceToken;

  r = await call('POST', '/api/auth/login', { email: 'manager@x.com', password: 'Str0ng!Pass', deviceToken });
  check(r.status === 200 && !!r.body.token && !r.body.twoFactorRequired, 'trusted device SKIPS the second factor');

  r = await call('POST', '/api/auth/login', { email: 'manager@x.com', password: 'Str0ng!Pass', deviceToken: 'a'.repeat(64) });
  check(r.body.twoFactorRequired === true, 'unknown device token still challenges');

  const freshSession = (await call('POST', '/api/auth/login', { email: 'manager@x.com', password: 'Str0ng!Pass', deviceToken })).body.token;
  r = await call('GET', '/api/auth/2fa/trusted-devices', undefined, freshSession);
  check(r.status === 200 && r.body.devices.length === 1, 'device appears in the list');
  check(r.body.devices[0].tokenHash === undefined, 'device token hash never exposed');

  r = await call('DELETE', `/api/auth/2fa/trusted-devices/${r.body.devices[0].id}`, undefined, freshSession);
  check(r.status === 200, 'device revoked');
  check((await call('POST', '/api/auth/login', { email: 'manager@x.com', password: 'Str0ng!Pass', deviceToken })).body.twoFactorRequired === true,
    'revoked device challenges again');

  // ══ 6. Role scoping ════════════════════════════════════════════════════════
  console.log('\n-- role scoping --');
  r = await call('POST', '/api/auth/login', { email: 'viewer@x.com', password: 'Str0ng!Pass' });
  check(r.status === 200 && !!r.body.token, 'viewer (outside enforceRoles) logs in without 2FA');

  // ══ 7. Mandatory mode ══════════════════════════════════════════════════════
  console.log('\n-- mandatory mode --');
  setPolicy({ enabled: true, mode: 'mandatory', enforceRoles: ['manager', 'editor'] });
  r = await call('POST', '/api/auth/login', { email: 'editor@x.com', password: 'Str0ng!Pass' });
  check(r.status === 200 && r.body.twoFactorSetupRequired === true, 'unenrolled user forced into setup');
  check(r.body.token === undefined, 'no session token during forced enrolment');
  const enrollChallenge = r.body.challengeToken;

  r = await call('GET', `/api/auth/2fa/enroll/setup?challengeToken=${encodeURIComponent(enrollChallenge)}`);
  check(r.status === 200 && r.body.qrDataUri.startsWith('data:image/png'), 'forced-enrolment setup returns a QR');
  const saSecret = openSecret(store.userTwoFactors.find((t: any) => String(t.userId) === 'e1')!.secretCiphertext);

  r = await call('POST', '/api/auth/2fa/enroll', { challengeToken: enrollChallenge, code: freshCode(saSecret, 'e1') });
  check(r.status === 200 && !!r.body.token && r.body.recoveryCodes.length === 10, 'forced enrolment completes and issues a session');

  r = await call('POST', '/api/auth/2fa/disable', { password: 'Str0ng!Pass', code: freshCode(secret, 'u1') }, freshSession);
  check(r.status === 400 && /required by your administrator/i.test(r.body.error), 'disable refused in mandatory mode');

  // ══ 8. Optional mode disable ═══════════════════════════════════════════════
  console.log('\n-- disable (optional mode) --');
  setPolicy({ enabled: true, mode: 'optional', enforceRoles: ['manager', 'editor'] });
  check((await call('POST', '/api/auth/2fa/disable', { password: 'wrong', code: freshCode(secret, 'u1') }, freshSession)).status === 400,
    'disable refused with a wrong password');

  // Snapshot the enrolled record so the credential-free paths below can be run
  // from the same starting state as the password + code path.
  const enrolled = { ...store.userTwoFactors[0] };

  r = await call('POST', '/api/auth/2fa/disable', { password: 'Str0ng!Pass', code: freshCode(secret, 'u1') }, freshSession);
  check(r.status === 200, 'disable succeeds with password + code');
  check(store.userTwoFactors[0].status === 'disabled' && store.userTwoFactors[0].recoveryCodes.length === 0, 'secret + codes cleared on disable');

  /*
   * Confirmation is optional by product decision — the security page turns 2FA
   * off directly. Two things must remain true alongside that, and both are
   * asserted here rather than assumed:
   *   • a WRONG password is still rejected (checked above), so "sent nothing"
   *     and "sent something bad" stay distinguishable;
   *   • administrator-mandated 2FA still cannot be turned off this way.
   */
  Object.assign(store.userTwoFactors[0], enrolled);
  r = await call('POST', '/api/auth/2fa/disable', {}, freshSession);
  check(r.status === 200, 'disable succeeds with no password and no code');
  check(store.userTwoFactors[0].status === 'disabled' && store.userTwoFactors[0].recoveryCodes.length === 0,
    'secret + codes cleared on credential-free disable');

  Object.assign(store.userTwoFactors[0], enrolled);
  setPolicy({ enabled: true, mode: 'mandatory', enforceRoles: ['manager', 'editor'] });
  r = await call('POST', '/api/auth/2fa/disable', {}, freshSession);
  check(r.status === 400 && /required by your administrator/i.test(r.body.error),
    'credential-free disable still refused in mandatory mode');
  check(store.userTwoFactors[0].status === 'enabled', 'mandatory refusal leaves the factor intact');

  setPolicy({ enabled: true, mode: 'optional', enforceRoles: ['manager', 'editor'] });
  r = await call('POST', '/api/auth/2fa/disable', {}, freshSession);
  check(r.status === 200, 'disable succeeds again once policy drops back to optional');
  r = await call('POST', '/api/auth/login', { email: 'manager@x.com', password: 'Str0ng!Pass' });
  check(r.status === 200 && !!r.body.token, 'login back to normal after disable');

  // ══ 9. Master switch off ═══════════════════════════════════════════════════
  console.log('\n-- master switch off --');
  setPolicy({ enabled: false });
  r = await call('POST', '/api/auth/login', { email: 'editor@x.com', password: 'Str0ng!Pass' });
  check(r.status === 200 && !!r.body.token, 'enrolled user logs straight in when 2FA is globally off');

  console.log(fail === 0 ? '\nAll checks passed.' : `\n${fail} check(s) FAILED.`);
  server.close();
  process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); server?.close(); process.exit(1); });
