import { prisma } from "@nexus/db";
import { authRateLimiter } from "@nexus/api/middleware/rate-limit";
import { createAuditEntry } from "@nexus/api/lib/audit";
import { logger } from "@nexus/api/lib/logger";
import { redeemBackupCode } from "@nexus/api/lib/mfa-backup-code-redeem";
import { consumeTotpWindow } from "@nexus/api/lib/totp-consume";
import NextAuth, { type NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { CredentialsSignin } from "next-auth";
import { verify } from "@node-rs/argon2";
import { z } from "zod";
import { assertSecureRuntimeEnv } from "./runtime-env";
import { authConfig } from "./auth.config.js";

assertSecureRuntimeEnv();

// Precomputed argon2id hash of a random string we do not retain. Used to run a
// dummy verify() when the user does not exist (or has no password hash) so the
// code path takes the same wall-clock time as a real failed-login for a
// known user. Without this, an attacker can enumerate valid accounts by
// measuring how fast "email not found" returns vs. "password wrong"
// (EAPPS 3.2.7.05 / OWASP ASVS 2.2.1).
const DUMMY_ARGON2_HASH =
  "$argon2id$v=19$m=65536,t=3,p=4$dFRrYlpCaTMzd1lHeFMwTw$wZcMWHRxxOy2trvRfOjjKzYP/VQ2k+D01FA54zUlfUw";

// Auth.js v5: throw CredentialsSignin subclasses so the `code` is forwarded
// to the client via SignInResponse.code — plain Error throws become
// CallbackRouteError and the message is never visible to the client.
export class MfaRequiredError extends CredentialsSignin {
  code = "MFA_REQUIRED" as const;
}
export class MfaRequiredSetupError extends CredentialsSignin {
  code = "MFA_REQUIRED_SETUP" as const;
}
export class InvalidTotpError extends CredentialsSignin {
  code = "INVALID_TOTP" as const;
}

const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1).max(128),
  totp: z.string().max(16).optional(),
  // Backup codes are the second-factor fallback when the user has lost
  // their TOTP device. Max 32 covers the 10-char code with dashes and
  // accidental whitespace; anything longer is rejected before argon2.
  backupCode: z.string().max(32).optional(),
});

function extractClientIp(request: Request | undefined): string | null {
  if (!request) return null;
  const forwarded = request.headers.get("x-forwarded-for");
  if (forwarded) {
    const first = forwarded.split(",")[0]?.trim();
    if (first) return first;
  }
  const realIp = request.headers.get("x-real-ip");
  if (realIp) return realIp.trim();
  return null;
}

const config = {
  ...authConfig,
  trustHost: true,
  providers: [
    Credentials({
      name: "credentials",
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
        totp: { label: "TOTP", type: "text" },
      },
      async authorize(credentials, request) {
        const parsed = LoginSchema.safeParse(credentials);
        if (!parsed.success) return null;

        const { email, password, totp, backupCode } = parsed.data;
        const isE2eTestMode = process.env["E2E_TEST_MODE"] === "true";

        // Rate limit: 5 attempts per 15 min, keyed on BOTH email and
        // source IP. Keying on email alone permits per-email lockout DoS
        // and lets a single IP brute-force unlimited emails; keying on
        // IP alone lets a botnet bypass the limit. Both buckets must be
        // within budget for the attempt to proceed (CWE-307).
        const ip = extractClientIp(request);
        const rateLimitKeys = ip
          ? [`email:${email.toLowerCase()}`, `ip:${ip}`]
          : [`email:${email.toLowerCase()}`];
        const rateLimitResult = isE2eTestMode
          ? { allowed: true }
          : await authRateLimiter(rateLimitKeys);
        if (!rateLimitResult.allowed) {
          // Audit failed login (rate limited)
          await createAuditEntry({
            db: prisma,
            entityType: "Auth",
            entityId: email.toLowerCase(),
            entityName: email,
            action: "CREATE",
            summary: "Login blocked — rate limit exceeded",
            source: "ui",
          });
          throw new Error("Too many login attempts. Please try again later.");
        }

        const user = await prisma.user.findUnique({ where: { email } });

        // Always run argon2.verify — even when the user doesn't exist or is
        // deactivated — so all failing branches incur the same CPU cost. The
        // result from the dummy path is discarded; only the shape of the
        // audit log / return value changes. Summaries are kept uniform
        // ("Login failed") so audit-log contents cannot be used to
        // enumerate accounts either; the reason stays in the server-only
        // logger.warn.
        if (!user?.passwordHash) {
          await verify(DUMMY_ARGON2_HASH, password).catch(() => false);
          logger.warn({ email, reason: "user_not_found" }, "Failed login attempt");
          await createAuditEntry({
            db: prisma,
            entityType: "Auth",
            entityId: email.toLowerCase(),
            entityName: email,
            action: "CREATE",
            summary: "Login failed",
            source: "ui",
          });
          return null;
        }

        if (!user.isActive) {
          await verify(DUMMY_ARGON2_HASH, password).catch(() => false);
          logger.warn(
            { email, userId: user.id, reason: "account_deactivated" },
            "Login blocked — account deactivated",
          );
          await createAuditEntry({
            db: prisma,
            entityType: "Auth",
            entityId: user.id,
            entityName: user.email,
            action: "CREATE",
            userId: user.id,
            summary: "Login failed",
            source: "ui",
          });
          return null;
        }

        const isValid = await verify(user.passwordHash, password);
        if (!isValid) {
          logger.warn({ email, reason: "invalid_password" }, "Failed login attempt");
          await createAuditEntry({
            db: prisma,
            entityType: "Auth",
            entityId: user.id,
            entityName: user.email,
            action: "CREATE",
            userId: user.id,
            summary: "Login failed",
            source: "ui",
          });
          return null;
        }

        // MFA check: if TOTP is enabled, require a valid TOTP *or* a
        // one-shot backup code. Backup codes are the last-resort credential
        // when the user has lost their TOTP device; their redemption
        // deletes the row atomically (see redeemBackupCode) so replay is
        // physically impossible.
        if (user.totpEnabled && user.totpSecret) {
          if (!totp && !backupCode) {
            throw new MfaRequiredError();
          }

          if (backupCode) {
            const result = await redeemBackupCode(prisma, user.id, backupCode);
            if (!result.accepted) {
              logger.warn(
                { email, reason: "invalid_backup_code" },
                "Failed MFA verification — backup code",
              );
              await createAuditEntry({
                db: prisma,
                entityType: "Auth",
                entityId: user.id,
                entityName: user.email,
                action: "CREATE",
                userId: user.id,
                summary: "Login failed — invalid backup code",
                source: "ui",
              });
              throw new InvalidTotpError();
            }
            await createAuditEntry({
              db: prisma,
              entityType: "Auth",
              entityId: user.id,
              entityName: user.email,
              action: "UPDATE",
              userId: user.id,
              summary: `Backup code redeemed (${result.remaining} remaining)`,
              source: "ui",
            });
            // Successful backup-code auth skips TOTP replay-window checks
            // entirely — the code itself is the nonce.
          } else {
            const { TOTP, Secret } = await import("otpauth");
            const totpInstance = new TOTP({
              issuer: "Nexus",
              label: user.email,
              algorithm: "SHA1",
              digits: 6,
              period: 30,
              secret: Secret.fromBase32(user.totpSecret),
            });

            const delta = totpInstance.validate({ token: totp!, window: 1 });
            if (delta === null) {
              logger.warn({ email, reason: "invalid_totp" }, "Failed MFA verification");
              await createAuditEntry({
                db: prisma,
                entityType: "Auth",
                entityId: user.id,
                entityName: user.email,
                action: "CREATE",
                userId: user.id,
                summary: "Login failed — invalid TOTP token",
                source: "ui",
              });
              throw new InvalidTotpError();
            }

            // Atomic replay-guard: a single UPDATE ... WHERE lastTotpAt is null
            // OR older than 30 s both serialises concurrent logins (row lock)
            // and expresses the "unused window" precondition in SQL. count=0
            // means another request consumed this window first → replay.
            const accepted = await consumeTotpWindow(prisma, user.id);
            if (!accepted) {
              logger.warn({ email, reason: "totp_replay" }, "TOTP replay attack blocked");
              await createAuditEntry({
                db: prisma,
                entityType: "Auth",
                entityId: user.id,
                entityName: user.email,
                action: "CREATE",
                userId: user.id,
                summary: "Login failed — TOTP replay detected",
                source: "ui",
              });
              throw new InvalidTotpError();
            }
          }
        }

        // MFA enforcement: if the user's role is in requireMfaForRoles but they
        // haven't set up TOTP yet, block login and signal setup requirement.
        if (!user.totpEnabled) {
          const settings = await prisma.systemSettings.findUnique({
            where: { id: "singleton" },
            select: { requireMfaForRoles: true },
          });
          const requireMfaForRoles = (settings?.requireMfaForRoles as string[] | null) ?? [];
          if (requireMfaForRoles.includes(user.systemRole)) {
            throw new MfaRequiredSetupError();
          }
        }

        // Track last login time
        await prisma.user.update({
          where: { id: user.id },
          data: { lastLoginAt: new Date() },
        });

        logger.info({ email, userId: user.id }, "Successful login");
        // Audit successful login. Awaited (not fire-and-forget) so the entry
        // is durable before we return a session — forensic completeness
        // matters even if it adds a few ms to the login path.
        await createAuditEntry({
          db: prisma,
          entityType: "Auth",
          entityId: user.id,
          entityName: user.email,
          action: "CREATE",
          userId: user.id,
          summary: "User logged in",
          source: "ui",
        });

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.systemRole,
        };
      },
    }),
  ],
  callbacks: {
    async session({ session, token }) {
      if (token.sub) {
        session.user.id = token.sub;
      }
      if (token.role) {
        (session.user as typeof session.user & { role: string }).role = token.role as string;
      }
      // Do NOT expose token.sid on session.user — the JTI is an internal
      // session-revocation token and must stay inside the encrypted JWT.
      // Server-side handlers that need it decode the JWT via getToken().
      return session;
    },
    async jwt({ token, user }) {
      if (user) {
        token.role = (user as typeof user & { role: string }).role;

        // Generate a unique session ID for tracking.
        // We use token.sid (not token.jti) because Auth.js manages token.jti
        // internally and may overwrite it after the jwt callback returns.
        const jti = crypto.randomUUID();
        token.sid = jti;

        // Skip active-session registration in E2E test mode.
        // Test logins must not pollute the active_sessions table — doing so
        // kicks real user sessions when the concurrent-session limit is reached.
        const isE2eTestMode = process.env["E2E_TEST_MODE"] === "true";
        if (isE2eTestMode) return token;

        // Enforce concurrent session limit (kick-oldest strategy).
        // This MUST fail-closed: if session-registry writes fail we cannot
        // honour the configured session cap, so we must refuse to mint a
        // session. Previously this path swallowed errors and logged-only,
        // which let a DB-degradation scenario bypass the session cap.
        try {
          const settings = await prisma.systemSettings.findUnique({
            where: { id: "singleton" },
            select: { maxConcurrentSessions: true },
          });
          const maxSessions = settings?.maxConcurrentSessions ?? 3;

          await prisma.activeSession.create({
            data: { userId: user.id!, jti },
          });

          const activeSessions = await prisma.activeSession.findMany({
            where: { userId: user.id! },
            orderBy: { createdAt: "asc" },
            select: { id: true },
          });

          if (activeSessions.length > maxSessions) {
            const toDelete = activeSessions.slice(0, activeSessions.length - maxSessions);
            await prisma.activeSession.deleteMany({
              where: { id: { in: toDelete.map((s) => s.id) } },
            });
            logger.info(
              { userId: user.id, kicked: toDelete.length, maxSessions },
              "Kicked oldest sessions",
            );
          }
        } catch (err) {
          logger.error(
            { err, userId: user.id },
            "Failed to register active session — refusing to mint JWT",
          );
          throw new Error("Session registration failed");
        }
      }
      return token;
    },
  },
  events: {
    async signOut(message) {
      // Auth.js fires this event on sign-out; extract userId from the JWT token
      const token = "token" in message ? message.token : null;
      const userId = token?.sub ?? null;
      const email = token?.email ?? "unknown";
      const jti = (token?.sid ?? token?.jti) as string | undefined;

      // Remove from active session registry
      if (jti) {
        void prisma.activeSession.delete({ where: { jti } }).catch(() => {
          /* already gone */
        });
      }

      await createAuditEntry({
        db: prisma,
        entityType: "Auth",
        entityId: userId ?? email,
        entityName: email,
        action: "DELETE",
        ...(userId ? { userId } : {}),
        summary: "User logged out",
        source: "ui",
      });
    },
  },
} satisfies NextAuthConfig;

export const { handlers, auth } = NextAuth(config);
