/**
 * Two-Factor Authentication Routes — /api/auth/2fa
 *
 * Two groups of endpoints:
 *
 *   • Self-service (authenticated) — a signed-in user setting up, disabling or
 *     managing their own second factor.
 *   • Challenge (public) — the half-authenticated step between a correct
 *     password and a session token. These carry a challenge token instead of a
 *     session token, which `authenticate` explicitly refuses.
 *
 * The challenge endpoints deliberately return the *same* body shape as
 * `POST /auth/login` on success, so the frontend can reuse its existing
 * post-login handling verbatim.
 */

import express, { Request, Response } from 'express';
import { body, param, validationResult } from 'express-validator';
import { authenticate } from '../middleware/auth';
import { authRateLimiter, twoFactorRateLimiter, otpSendRateLimiter } from '../middleware/rateLimiter';
import { getModels } from '../models';
import {
  startSetup,
  confirmSetup,
  disableTwoFactor,
  regenerateRecoveryCodes,
  getTwoFactorStatus,
  verifyCodeForUser,
  resolveChallenge,
  consumeChallenge,
  recordChallengeAttempt,
  createChallenge,
  sendChallengeOtp,
  skipEnrolment,
  TWO_FACTOR_AUDIT_ACTIONS,
} from '../services/auth/twoFactorService';
import {
  listTrustedDevices,
  revokeTrustedDevice,
  revokeAllTrustedDevices,
  trustDevice,
} from '../services/auth/trustedDeviceService';
import { effectivePolicyForUser } from '../services/auth/policyResolution';
import { buildLoginResponse } from '../services/auth/loginResponse';
import { logAudit } from '../utils/auditLogger';

const router = express.Router();

/** Collect express-validator errors into a single message. Returns true if handled. */
function rejectInvalid(req: Request, res: Response): boolean {
  const errors = validationResult(req);
  if (errors.isEmpty()) return false;
  res.status(400).json({ error: errors.array()[0]?.msg || 'Invalid request' });
  return true;
}

/**
 * Resolve an optional challenge token to its id, for the self-service routes
 * where an emailed code may or may not be involved.
 *
 * Returns undefined for a missing or unusable token rather than failing: the
 * caller still has TOTP and recovery codes to fall back on, and an invalid
 * token should not turn a valid authenticator code into an error.
 */
async function resolveChallengeJti(token?: string): Promise<string | undefined> {
  if (!token) return undefined;

  for (const purpose of ['login', 'enrollment'] as const) {
    const resolved = await resolveChallenge(token, purpose);
    if (resolved.ok) return resolved.jti;
  }
  return undefined;
}

// ============================================================================
// SELF-SERVICE — requires an authenticated session
// ============================================================================

/** GET /api/auth/2fa/status — current state for the security settings screen. */
router.get('/status', authenticate, async (req: Request, res: Response) => {
  try {
    const status = await getTwoFactorStatus(req.user!);
    res.json(status);
  } catch (error: any) {
    console.error('[2FA] Status error:', error?.message);
    res.status(500).json({ error: 'Failed to load two-factor status' });
  }
});

/**
 * POST /api/auth/2fa/setup — begin enrolment.
 *
 * `method` is optional: with a single-method policy there is nothing to choose,
 * and under `both` an unspecified method defaults to the stronger one (TOTP).
 * Returns a QR for TOTP, or a sent-code confirmation for email.
 */
router.post('/setup', authenticate, twoFactorRateLimiter, async (req: Request, res: Response) => {
  try {
    const result = await startSetup(req.user!, req, req.body?.method);
    res.json(result);
  } catch (error: any) {
    // startSetup throws for "already enabled" and "no encryption key" — both are
    // actionable messages the user or admin needs to see verbatim.
    res.status(400).json({ error: error?.message || 'Failed to start two-factor setup' });
  }
});

/** POST /api/auth/2fa/enable — confirm setup with the first code. */
router.post(
  '/enable',
  authenticate,
  twoFactorRateLimiter,
  [body('code').trim().notEmpty().withMessage('Enter the code from your authenticator app')],
  async (req: Request, res: Response) => {
    if (rejectInvalid(req, res)) return;

    try {
      // An email enrolment carries the challenge its code was issued against.
      const challengeJti = await resolveChallengeJti(req.body.challengeToken);

      const result = await confirmSetup(req.user!, req.body.code, req, challengeJti);
      if (!result.success) {
        res.status(400).json({ error: result.error });
        return;
      }
      // The only time these are ever readable. The UI must make the user
      // acknowledge saving them before it moves on.
      res.json({ success: true, recoveryCodes: result.recoveryCodes || [] });
    } catch (error: any) {
      console.error('[2FA] Enable error:', error?.message);
      res.status(500).json({ error: 'Failed to enable two-factor authentication' });
    }
  },
);

/**
 * POST /api/auth/2fa/disable
 *
 * Password and code are optional — turning 2FA off is a direct control on the
 * user's own security page. Anything that IS sent still gets verified by the
 * service, and administrator-mandated 2FA still cannot be turned off.
 */
router.post(
  '/disable',
  authenticate,
  twoFactorRateLimiter,
  [
    body('password').optional({ values: 'falsy' }).isString(),
    body('code').optional({ values: 'falsy' }).trim().isString(),
  ],
  async (req: Request, res: Response) => {
    if (rejectInvalid(req, res)) return;

    try {
      const result = await disableTwoFactor(
        req.user!,
        {
          password: req.body.password,
          code: req.body.code,
          challengeJti: await resolveChallengeJti(req.body.challengeToken),
        },
        req,
      );
      if (!result.success) {
        res.status(400).json({ error: result.error });
        return;
      }
      res.json({ success: true, message: 'Two-factor authentication has been turned off.' });
    } catch (error: any) {
      console.error('[2FA] Disable error:', error?.message);
      res.status(500).json({ error: 'Failed to disable two-factor authentication' });
    }
  },
);

/** POST /api/auth/2fa/recovery-codes/regenerate — replaces every existing code. */
router.post(
  '/recovery-codes/regenerate',
  authenticate,
  twoFactorRateLimiter,
  [
    body('password').notEmpty().withMessage('Your password is required'),
    body('code').trim().notEmpty().withMessage('A code from your authenticator app is required'),
  ],
  async (req: Request, res: Response) => {
    if (rejectInvalid(req, res)) return;

    try {
      const result = await regenerateRecoveryCodes(
        req.user!,
        {
          password: req.body.password,
          code: req.body.code,
          challengeJti: await resolveChallengeJti(req.body.challengeToken),
        },
        req,
      );
      if (!result.success) {
        res.status(400).json({ error: result.error });
        return;
      }
      res.json({ success: true, recoveryCodes: result.recoveryCodes || [] });
    } catch (error: any) {
      console.error('[2FA] Recovery code regeneration error:', error?.message);
      res.status(500).json({ error: 'Failed to regenerate recovery codes' });
    }
  },
);

/** GET /api/auth/2fa/trusted-devices */
router.get('/trusted-devices', authenticate, async (req: Request, res: Response) => {
  try {
    // The client passes its own token so the list can mark "This device".
    const currentToken = (req.query.deviceToken as string) || null;
    const devices = await listTrustedDevices(String(req.user!._id || req.user!.id), currentToken);
    res.json({ devices });
  } catch (error: any) {
    console.error('[2FA] Trusted device list error:', error?.message);
    res.status(500).json({ error: 'Failed to load trusted devices' });
  }
});

/** DELETE /api/auth/2fa/trusted-devices/:id */
router.delete(
  '/trusted-devices/:id',
  authenticate,
  [param('id').notEmpty().withMessage('Device id is required')],
  async (req: Request, res: Response) => {
    if (rejectInvalid(req, res)) return;

    try {
      const userId = String(req.user!._id || req.user!.id);
      const removed = await revokeTrustedDevice(userId, req.params.id);

      if (!removed) {
        res.status(404).json({ error: 'Device not found' });
        return;
      }

      void logAudit({
        userId,
        userEmail: req.user!.email,
        action: TWO_FACTOR_AUDIT_ACTIONS.deviceRevoked,
        resource: 'Auth',
        resourceId: req.params.id,
        req,
      });

      res.json({ success: true });
    } catch (error: any) {
      console.error('[2FA] Trusted device revoke error:', error?.message);
      res.status(500).json({ error: 'Failed to revoke device' });
    }
  },
);

/** DELETE /api/auth/2fa/trusted-devices — revoke all. */
router.delete('/trusted-devices', authenticate, async (req: Request, res: Response) => {
  try {
    const userId = String(req.user!._id || req.user!.id);
    const revoked = await revokeAllTrustedDevices(userId);

    void logAudit({
      userId,
      userEmail: req.user!.email,
      action: TWO_FACTOR_AUDIT_ACTIONS.deviceRevoked,
      resource: 'Auth',
      details: { revokedAll: true, count: revoked },
      req,
    });

    res.json({ success: true, revoked });
  } catch (error: any) {
    console.error('[2FA] Trusted device revoke-all error:', error?.message);
    res.status(500).json({ error: 'Failed to revoke devices' });
  }
});

// ============================================================================
// CHALLENGE — public, carries a challenge token rather than a session
// ============================================================================

/**
 * POST /api/auth/2fa/verify
 *
 * Exchange a challenge token plus a valid code for a real session.
 * On success the body is exactly what `POST /auth/login` returns, plus an
 * optional `trustedDeviceToken` when the user asked to be remembered.
 */
router.post(
  '/verify',
  authRateLimiter,
  twoFactorRateLimiter,
  [
    body('challengeToken').notEmpty().withMessage('Verification session is required'),
    body('code').trim().notEmpty().withMessage('Enter your verification code'),
  ],
  async (req: Request, res: Response) => {
    if (rejectInvalid(req, res)) return;

    try {
      const resolved = await resolveChallenge(req.body.challengeToken, 'login');
      if (!resolved.ok) {
        res.status(resolved.status).json({ error: resolved.error });
        return;
      }

      const { User } = getModels();
      const user = await User.findById(resolved.userId);
      if (!user) {
        res.status(401).json({ error: 'Invalid verification session. Please sign in again.' });
        return;
      }

      const verification = await verifyCodeForUser(user, req.body.code, {
        allowRecovery: true,
        // Lets the emailed code for THIS attempt be checked alongside TOTP and
        // recovery, so one endpoint serves every method.
        challengeJti: resolved.jti,
        req,
      });

      if (!verification.valid) {
        // The challenge is NOT consumed on a wrong code — the user gets to
        // retry within the same window rather than restarting the whole login.
        await recordChallengeAttempt(resolved.jti);
        res.status(401).json({
          error: verification.error || 'That code is not valid.',
          lockedForSeconds: verification.lockedForSeconds,
        });
        return;
      }

      // Spend the challenge only once the code is confirmed good. The atomic
      // update also settles a race between two concurrent correct submissions.
      const consumed = await consumeChallenge(resolved.jti);
      if (!consumed) {
        res.status(401).json({ error: 'That verification session has already been used. Please sign in again.' });
        return;
      }

      const settings = await effectivePolicyForUser(user);

      // A recovery code means the authenticator is presumed lost, so previously
      // trusted devices should not keep bypassing the second factor.
      if (verification.usedRecoveryCode) {
        await revokeAllTrustedDevices(resolved.userId);
      }

      let trustedDeviceToken: string | undefined;
      if (req.body.trustDevice === true && !verification.usedRecoveryCode) {
        const trusted = await trustDevice({
          userId: resolved.userId,
          settings,
          userAgent: req.headers['user-agent'],
          ipAddress: req.ip,
          label: typeof req.body.deviceLabel === 'string' ? req.body.deviceLabel : undefined,
        });

        if (trusted) {
          trustedDeviceToken = trusted.token;
          void logAudit({
            userId: resolved.userId,
            userEmail: user.email,
            action: TWO_FACTOR_AUDIT_ACTIONS.deviceTrusted,
            resource: 'Auth',
            details: { expiresAt: trusted.expiresAt },
            req,
          });
        }
      }

      const payload = await buildLoginResponse(user, res);

      res.json({
        ...payload,
        ...(trustedDeviceToken ? { trustedDeviceToken } : {}),
        ...(verification.usedRecoveryCode
          ? {
            usedRecoveryCode: true,
            recoveryCodesRemaining: verification.recoveryCodesRemaining ?? 0,
          }
          : {}),
      });
    } catch (error: any) {
      console.error('[2FA] Verify error:', error?.message);
      res.status(500).json({ error: 'Verification failed. Please try again.' });
    }
  },
);

/**
 * POST /api/auth/2fa/send-otp
 *
 * Send (or resend) the emailed code for an in-progress challenge — the login
 * flow when the user picks Email OTP, and the resend button.
 *
 * Authorised by the challenge token, not a session: the caller has proved their
 * password but is not signed in yet.
 */
router.post(
  '/send-otp',
  authRateLimiter,
  otpSendRateLimiter,
  [body('challengeToken').notEmpty().withMessage('Verification session is required')],
  async (req: Request, res: Response) => {
    if (rejectInvalid(req, res)) return;

    try {
      // Accepts either purpose: a login challenge (verifying) or an enrolment
      // challenge (forced setup by email).
      let resolved = await resolveChallenge(req.body.challengeToken, 'login');
      if (!resolved.ok) {
        const asEnrolment = await resolveChallenge(req.body.challengeToken, 'enrollment');
        if (asEnrolment.ok) resolved = asEnrolment;
      }

      if (!resolved.ok) {
        res.status(resolved.status).json({ error: resolved.error });
        return;
      }

      // The user is loaded before the policy is read: the effective policy is
      // per-user (global floor combined with their company's), so there is no
      // meaningful policy to consult until we know who this is.
      const { User } = getModels();
      const user = await User.findById(resolved.userId);
      if (!user) {
        res.status(401).json({ error: 'Invalid verification session. Please sign in again.' });
        return;
      }

      const settings = await effectivePolicyForUser(user);
      if (!settings.verificationMethod || settings.verificationMethod === 'totp') {
        res.status(400).json({ error: 'Email verification is not enabled for this platform.' });
        return;
      }

      const outcome = await sendChallengeOtp({
        jti: resolved.jti,
        user,
        settings,
        // A code already on the challenge means this is a resend.
        isResend: !!req.body.resend,
        req,
      });

      if (!outcome.sent) {
        res.status(429).json({ error: outcome.error, retryAfterSeconds: outcome.retryAfterSeconds });
        return;
      }

      res.json({
        sent: true,
        maskedEmail: outcome.maskedEmail,
        otpExpiresIn: outcome.otpExpiresIn,
        resendsRemaining: outcome.resendsRemaining,
      });
    } catch (error: any) {
      console.error('[2FA] Send OTP error:', error?.message);
      res.status(500).json({ error: 'Could not send your verification code. Please try again.' });
    }
  },
);

/**
 * POST /api/auth/2fa/setup/send-otp
 *
 * The signed-in counterpart: issues a challenge and emails a code so an
 * already-authenticated user can complete an email enrolment, or step up before
 * disabling 2FA / regenerating recovery codes when they have no authenticator.
 *
 * Returns the challenge token, which the client then passes to `/enable`,
 * `/disable` or `/recovery-codes/regenerate` alongside the code.
 */
router.post(
  '/setup/send-otp',
  authenticate,
  otpSendRateLimiter,
  async (req: Request, res: Response) => {
    try {
      const settings = await effectivePolicyForUser(req.user!);

      if (settings.verificationMethod === 'totp') {
        res.status(400).json({ error: 'Email verification is not enabled for this platform.' });
        return;
      }

      const userId = String(req.user!._id || req.user!.id);
      const { UserTwoFactor } = getModels();
      const record = await UserTwoFactor.findOne({ userId }).lean();

      const challenge = await createChallenge({
        userId,
        purpose: record?.status === 'enabled' ? 'login' : 'enrollment',
        settings,
        req,
      });

      const outcome = await sendChallengeOtp({
        jti: challenge.jti,
        user: req.user!,
        settings,
        req,
      });

      if (!outcome.sent) {
        res.status(502).json({ error: outcome.error });
        return;
      }

      res.json({
        sent: true,
        challengeToken: challenge.challengeToken,
        maskedEmail: outcome.maskedEmail,
        otpExpiresIn: outcome.otpExpiresIn,
        resendsRemaining: outcome.resendsRemaining,
      });
    } catch (error: any) {
      console.error('[2FA] Self-service send OTP error:', error?.message);
      res.status(500).json({ error: 'Could not send your verification code. Please try again.' });
    }
  },
);

/**
 * POST /api/auth/2fa/skip
 *
 * Defer enrolment and issue the session.
 *
 * Whether this is allowed is decided server-side from the effective policy —
 * the `canSkip` flag on the challenge is only a UI hint, and a client that sets
 * it itself gets a 403. Returns the same body shape as login on success.
 */
router.post(
  '/skip',
  authRateLimiter,
  [body('challengeToken').notEmpty().withMessage('Verification session is required')],
  async (req: Request, res: Response) => {
    if (rejectInvalid(req, res)) return;

    try {
      const resolved = await resolveChallenge(req.body.challengeToken, 'enrollment');
      if (!resolved.ok) {
        res.status(resolved.status).json({ error: resolved.error });
        return;
      }

      const { User } = getModels();
      const user = await User.findById(resolved.userId);
      if (!user) {
        res.status(401).json({ error: 'Invalid verification session. Please sign in again.' });
        return;
      }

      const outcome = await skipEnrolment({ user, challengeJti: resolved.jti, req });
      if (!outcome.ok) {
        res.status(outcome.status).json({ error: outcome.error });
        return;
      }

      const payload = await buildLoginResponse(user, res);
      res.json({ ...payload, skipped: true, skipsRemaining: outcome.skipsRemaining });
    } catch (error: any) {
      console.error('[2FA] Skip error:', error?.message);
      res.status(500).json({ error: 'Could not skip two-factor setup. Please try again.' });
    }
  },
);

/**
 * GET /api/auth/2fa/enroll/setup?challengeToken=…
 *
 * The forced-enrolment counterpart of `/setup`, for mandatory mode where the
 * user has no session yet. Authorised by the challenge token instead.
 */
router.get('/enroll/setup', authRateLimiter, async (req: Request, res: Response) => {
  try {
    const token = String(req.query.challengeToken || '');
    if (!token) {
      res.status(400).json({ error: 'Verification session is required' });
      return;
    }

    const resolved = await resolveChallenge(token, 'enrollment');
    if (!resolved.ok) {
      res.status(resolved.status).json({ error: resolved.error });
      return;
    }

    const { User } = getModels();
    const user = await User.findById(resolved.userId);
    if (!user) {
      res.status(401).json({ error: 'Invalid verification session. Please sign in again.' });
      return;
    }

    // Delegates to the same method-aware path the signed-in route uses, so a
    // forced enrolment under an email policy gets an emailed code rather than a
    // QR for a factor the platform no longer accepts. Passing the existing
    // challenge keeps the client on the one token it already holds.
    const result = await startSetup(
      user,
      req,
      typeof req.query.method === 'string' ? req.query.method : undefined,
      resolved.jti,
    );

    res.json(result);
  } catch (error: any) {
    console.error('[2FA] Forced enrolment setup error:', error?.message);
    res.status(400).json({ error: error?.message || 'Failed to start two-factor setup' });
  }
});

/**
 * POST /api/auth/2fa/enroll
 *
 * Complete forced enrolment: confirm the code, then issue the session. Returns
 * the recovery codes alongside the login payload — the only chance to show them.
 */
router.post(
  '/enroll',
  authRateLimiter,
  twoFactorRateLimiter,
  [
    body('challengeToken').notEmpty().withMessage('Verification session is required'),
    body('code').trim().notEmpty().withMessage('Enter the code from your authenticator app'),
  ],
  async (req: Request, res: Response) => {
    if (rejectInvalid(req, res)) return;

    try {
      const resolved = await resolveChallenge(req.body.challengeToken, 'enrollment');
      if (!resolved.ok) {
        res.status(resolved.status).json({ error: resolved.error });
        return;
      }

      const { User } = getModels();
      const user = await User.findById(resolved.userId);
      if (!user) {
        res.status(401).json({ error: 'Invalid verification session. Please sign in again.' });
        return;
      }

      // The forced-enrolment challenge is also where an emailed code lives.
      const result = await confirmSetup(user, req.body.code, req, resolved.jti);
      if (!result.success) {
        await recordChallengeAttempt(resolved.jti);
        res.status(400).json({ error: result.error });
        return;
      }

      const consumed = await consumeChallenge(resolved.jti);
      if (!consumed) {
        res.status(401).json({ error: 'That verification session has already been used. Please sign in again.' });
        return;
      }

      const payload = await buildLoginResponse(user, res);

      res.json({
        ...payload,
        recoveryCodes: result.recoveryCodes || [],
      });
    } catch (error: any) {
      console.error('[2FA] Enrolment error:', error?.message);
      res.status(500).json({ error: 'Failed to complete two-factor setup' });
    }
  },
);

export default router;
