import { NextResponse } from "next/server";
import { findUserByEmail, isGoogleOnlyAccount } from "@/lib/auth/accounts";
import { normaliseEmail } from "@/lib/auth/email";
import { equaliseTiming, verifyPassword } from "@/lib/auth/password";
import { clientIp, rateLimit } from "@/lib/auth/rateLimit";
import { createSession, pruneExpiredSessions } from "@/lib/auth/session";
import { authError, rateLimited, requireDatabase } from "@/lib/auth/http";
import { lockout } from "@/lib/auth/lockout";
import { logSecurityEventAsync, SecurityEventType } from "@/lib/auth/securityEvents";
import { requireCsrf } from "@/lib/auth/csrf";
import { isMobileClientRequest } from "@/lib/auth/config";
import { MAX_JSON_BODY_BYTES } from "@/lib/limits";
import { readJsonSized } from "@/lib/http/requestSize";
import { loginDecision, type LoginUserKind } from "@/lib/auth/neutral";
import type { User } from "@/lib/schema/db";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

interface LoginBody {
  email?: string;
  password?: string;
  remember?: boolean;
}

export async function POST(request: Request) {
  const dbDown = await requireDatabase();
  if (dbDown) return dbDown;

  const csrfBlocked = requireCsrf(request);
  if (csrfBlocked) return csrfBlocked;

  const parsed = await readJsonSized<LoginBody>(request, MAX_JSON_BODY_BYTES);
  if (!parsed.ok) {
    // Oversized bodies are rejected with 413 before anything is buffered;
    // parse failures keep the existing invalid_request/400 contract.
    return parsed.response.status === 413
      ? parsed.response
      : authError("invalid_request", 400);
  }
  const body = parsed.body;
  if (!body?.email || !body?.password) return authError("invalid_request", 400);

  const ip = clientIp(request);
  const ipLimit = rateLimit(`login:ip:${ip}`, 20, 15 * 60 * 1000);
  if (!ipLimit.allowed) return rateLimited(ipLimit.retryAfter);

  const emailKey = normaliseEmail(body.email);
  if (!emailKey) {
    await equaliseTiming();
    return authError("invalid_credentials", 401);
  }

  const emailLimit = rateLimit(`login:email:${emailKey}`, 10, 15 * 60 * 1000);
  if (!emailLimit.allowed) return rateLimited(emailLimit.retryAfter);

  // Progressive-delay lockout gate (src/lib/auth/lockout.ts). Independent of
  // the fixed-window limits above: each consecutive failure escalates the wait
  // for this account and this IP, and a success resets it. Delays always
  // expire — this is a throttle, never a permanent lockout that an attacker
  // could abuse as a denial of service. The 429 + Retry-After tells clients
  // exactly when to try again.
  const emailLock = lockout.checkLockout(`lockout:email:${emailKey}`);
  const ipLock = lockout.checkLockout(`lockout:ip:${ip}`);
  if (emailLock.locked || ipLock.locked) {
    const retryAfterSeconds = Math.max(emailLock.retryAfterSeconds, ipLock.retryAfterSeconds);
    const failures = emailLock.locked ? emailLock.failures : ipLock.failures;
    logSecurityEventAsync({
      type: SecurityEventType.LOGIN_LOCKED,
      email: emailKey,
      ip,
      userAgent: request.headers.get("user-agent"),
      metadata: { failures, retryAfterSeconds },
    });
    return rateLimited(retryAfterSeconds);
  }

  try {
    const user = await findUserByEmail(body.email);
    const kind = loginKindFor(user);

    // MFA insertion point: a TOTP/WebAuthn second factor would be verified
    // here — after the rate-limit and lockout gates, before the password
    // check — so throttled requests never reach the factor prompt.
    //
    // Only local (password) accounts have a hash to check. Missing accounts and
    // Google-only accounts count as "no match" up front and pay for the skipped
    // scrypt check with `equaliseTiming` below — same cost, same shape.
    const passwordMatches =
      kind === "local" || kind === "unverified_local"
        ? await verifyPassword(body.password, user?.passwordHash ?? null)
        : false;

    const outcome = loginDecision(kind, passwordMatches);

    if (outcome.kind === "sign_in" && user) {
      // A successful login resets the progressive-delay counters so a real
      // user's failed attempts never carry over into the next session.
      lockout.recordSuccess(`lockout:email:${emailKey}`);
      lockout.recordSuccess(`lockout:ip:${ip}`);
      logSecurityEventAsync({
        type: SecurityEventType.LOGIN_SUCCESS,
        userId: user.id,
        email: emailKey,
        ip,
        userAgent: request.headers.get("user-agent"),
      });

      // Native clients (no cookie jar) identify themselves with `X-Client`;
      // they get the raw token in the body instead of a Set-Cookie, and the
      // cookie write is skipped entirely so the token is never duplicated
      // into a place a web XSS could read it. Every other caller keeps the
      // existing httpOnly-cookie-only behaviour unchanged.
      const isMobile = isMobileClientRequest(request);
      const { token, expiresAt } = await createSession(
        user.id,
        {
          remember: Boolean(body.remember),
          userAgent: request.headers.get("user-agent"),
          ip,
        },
        { issueCookie: !isMobile }
      );

      void pruneExpiredSessions().catch(() => undefined);

      return NextResponse.json({
        status: "signed_in",
        user: {
          id: user.id,
          email: user.email,
          name: user.name,
          plan: user.plan,
          onboardingCompletedAt: user.onboardingCompletedAt?.toISOString() ?? null,
        },
        ...(isMobile ? { token, expiresAt: expiresAt.toISOString() } : {}),
      });
    }

    if (outcome.kind === "email_not_verified") {
      // Correct password, never-confirmed address — no session. Only reachable
      // by someone who already knows the password, so it cannot be used to
      // enumerate addresses. The body is a plain `{ error }`, the same shape as
      // every other failure: no `email` field that would set it apart.
      return authError("email_not_verified", 403);
    }

    // invalid_credentials: identical body and status for a missing account, a
    // Google-only account and a wrong password. Local attempts already paid the
    // scrypt cost inside verifyPassword; the other two pay it here so all three
    // take the same wall-clock time.
    //
    // Every failed attempt — whichever of the three shapes it took — feeds the
    // progressive-delay tracker for this email key and this IP. The 401 body
    // stays byte-identical and never mentions the lock state: revealing it
    // would hand an attacker a live countdown of how close they are to the
    // next escalation.
    lockout.recordFailure(`lockout:ip:${ip}`);
    const emailFailure = lockout.recordFailure(`lockout:email:${emailKey}`);
    logSecurityEventAsync({
      type: SecurityEventType.LOGIN_FAILED,
      email: emailKey,
      ip,
      userAgent: request.headers.get("user-agent"),
      metadata: { failures: emailFailure.failures },
    });
    if (kind === "none" || kind === "google_only") {
      await equaliseTiming();
    }
    return authError("invalid_credentials", 401);
  } catch (error) {
    console.error("[auth] login failed", error);
    return authError("server_error", 500);
  }
}

function loginKindFor(user: User | null): LoginUserKind {
  if (!user) return "none";
  if (isGoogleOnlyAccount(user)) return "google_only";
  if (user.emailVerifiedAt) return "local";
  return "unverified_local";
}
