// src/lib/auth.ts — Better Auth server instance.
// BETTER_AUTH_SECRET / BETTER_AUTH_URL are read from the environment automatically.
import { stripe } from "@better-auth/stripe";
import { type BetterAuthOptions, betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import {
  APIError,
  createAuthMiddleware,
  getSessionFromCtx,
} from "better-auth/api";
import { nextCookies } from "better-auth/next-js";
// Aliased: `admin` is already taken by the ORG role import below (./permissions).
// This is the global admin PLUGIN, a different concept.
import { admin as adminPlugin } from "better-auth/plugins/admin";
import { magicLink } from "better-auth/plugins/magic-link";
import { organization } from "better-auth/plugins/organization";
import { twoFactor } from "better-auth/plugins/two-factor";
import { username } from "better-auth/plugins/username";
import { and, eq } from "drizzle-orm";
import { after } from "next/server";
import Stripe from "stripe";
import { env } from "@/env";
import { logAccountDeletionOwnership } from "./auth/account-delete-guard";
import { checkEmail, normalizeEmail } from "./auth/email-guard";
import { isBlockedDuringImpersonation } from "./auth/impersonation-blocked-paths";
import { invitedSignupOverrides } from "./auth/invited-user";
import { createOrgForUser } from "./auth/org-bootstrap";
import {
  MAX_PASSWORD_LENGTH,
  MIN_PASSWORD_LENGTH,
  validatePassword,
} from "./auth/password-policy";
import { checkInvitationSeatBlock, notifySeatBlock } from "./auth/seat-guard";
import { shouldBootstrapOrg } from "./auth/sys-account";
import {
  isValidUsername,
  MAX_USERNAME_LENGTH,
  MIN_USERNAME_LENGTH,
} from "./auth/username-policy";
import { isOrgBillingManager } from "./billing/authorize";
import { PLANS } from "./billing/plans";
import { assertWithinQuota, QuotaError } from "./billing/quotas";
import { handleStripeEvent } from "./billing/stripe-events";
import * as authSchema from "./db/auth-schema";
import { db } from "./db/client";
import {
  sendChangeEmailEmail,
  sendDeleteAccountEmail,
  sendInvitationEmail,
  sendMagicLinkEmail,
  sendResetPasswordEmail,
  sendVerificationEmail,
} from "./email";
import { resolveDefaultOrgId } from "./org/default-org";
import { ac, admin, member, owner } from "./permissions";

const APP_URL = process.env.BETTER_AUTH_URL || "http://localhost:3000";
// Account-creation endpoints: block disposable/relay + normalize the email.
const EMAIL_BLOCK_PATHS = new Set(["/sign-up/email", "/sign-in/magic-link"]);
// Existing-account endpoints: only normalize so lookups match what was stored.
// Invitations are normalized too so an invited address matches the signup email.
const EMAIL_NORMALIZE_PATHS = new Set([
  "/sign-in/email",
  "/request-password-reset",
  "/forget-password",
  "/organization/invite-member",
]);
// Endpoints that SET a password → enforce the strong-password policy on the
// named body field. /sign-in/email is deliberately absent: existing users with
// old (8-char era) passwords must still be able to log in.
const PASSWORD_POLICY_PATHS: Record<string, string> = {
  "/sign-up/email": "password",
  "/reset-password": "newPassword",
  "/change-password": "newPassword",
};

// Better Auth CSRF / callback allow-list — the ONLY accepted origins, resolved
// per environment. We never trust the bare *.vercel.app suffix (public,
// attacker-registrable), and PRODUCTION trusts neither localhost nor the
// preview host. Order matters: a Vercel *preview* build also runs with
// NODE_ENV=production, so check VERCEL_ENV === "preview" BEFORE the prod branch.
// Inlined super-admin email check (mirrors auth/super-admin.ts `emailInCsv`).
// Inlined ON PURPOSE: super-admin.ts imports `auth` from this module, so importing
// it here would create a module cycle. Keep the two in sync if the rule changes.
export function isSuperAdminEmail(email: string | null | undefined): boolean {
  if (!email) return false;
  const target = email.trim().toLowerCase();
  return (env.SUPER_ADMIN_EMAILS ?? "")
    .split(",")
    .map((e) => e.trim().toLowerCase())
    .filter(Boolean)
    .includes(target);
}

function resolveTrustedOrigins(): string[] {
  if (process.env.VERCEL_ENV === "preview") {
    return ["https://seo-insight-web.vercel.app"];
  }
  if (process.env.NODE_ENV === "production") {
    // The app runs on the dash subdomain — the only prod origin that calls this
    // auth API (nginx vhost: docs/project/ops/serveur-prod.md, finding F2). The
    // marketing site (apex seoinsight.app) redirects here for sign-in (no
    // cross-origin auth call, so it needn't be trusted), and www → non-www is
    // handled at the hosting layer. crossSubDomainCookies stays OFF (deliberate
    // cookie isolation between apex and dash).
    return ["https://dash.seoinsight.app"];
  }
  return ["http://localhost:3000"];
}

export const auth = betterAuth({
  // Used by the two-factor plugin as the default TOTP issuer (also surfaced in
  // authenticator apps). Mirrors the app metadata in src/app/layout.tsx.
  appName: "SEO Insight",
  database: drizzleAdapter(db, { provider: "pg", schema: authSchema }),
  // Encrypt stored OAuth tokens at rest (e.g. the Google GSC offline refresh
  // token) — AES-256-GCM keyed off BETTER_AUTH_SECRET.
  account: {
    encryptOAuthTokens: true,
    // Allow linking a Google account (with the webmasters scope) to any user —
    // required so an owner who signed up with email/password can connect GSC.
    // allowDifferentEmails: the org's Search Console often lives on a Google
    // account whose email differs from the user's sign-up email (agency owner
    // connecting a client's GSC). Without it, better-auth's OAuth callback
    // rejects the link with `email_doesn't_match` (silent redirect back to
    // /gsc). Linking still requires an authenticated session plus a fresh
    // Google consent screen, so sign-in security is unchanged.
    accountLinking: {
      enabled: true,
      trustedProviders: ["google"],
      allowDifferentEmails: true,
    },
  },
  // DB-backed rate limiting so counters survive across serverless instances
  // (in-memory is per-instance and resets on cold start). Tighten the abusable
  // auth endpoints.
  rateLimit: {
    enabled: true,
    storage: "database",
    customRules: {
      "/sign-in/email": { window: 60, max: 5 },
      // Username sign-in is just as brute-forceable as email — same cap.
      "/sign-in/username": { window: 60, max: 5 },
      "/sign-up/email": { window: 60, max: 5 },
      "/sign-in/magic-link": { window: 60, max: 3 },
      "/request-password-reset": { window: 60, max: 3 },
      // Token-submission + invite acceptance: tighter than the generic 100/60s.
      "/reset-password": { window: 60, max: 5 },
      "/organization/accept-invitation": { window: 60, max: 10 },
      // 2FA challenge endpoints are brute-forceable (6-digit TOTP / backup
      // codes) and this better-auth version has NO built-in failed-attempt
      // lockout (no failedVerificationCount column), so cap them here.
      "/two-factor/verify-totp": { window: 60, max: 10 },
      "/two-factor/verify-backup-code": { window: 60, max: 5 },
      "/two-factor/enable": { window: 60, max: 5 },
    },
  },
  // Explicit base URL when configured — never infer it from request headers
  // (docs: "Relying on request inference is not recommended. For security and
  // stability, always set baseURL explicitly."). Undefined in local dev → falls
  // back to localhost inference, which is fine there.
  baseURL: process.env.BETTER_AUTH_URL,
  // CSRF/callback allow-list — per-environment, explicit. See
  // resolveTrustedOrigins above (prod: dash.seoinsight.app only; preview: the
  // stable Vercel preview host; dev: localhost — nothing else).
  trustedOrigins: resolveTrustedOrigins(),
  advanced: {
    // Real client IP behind the proxy (rate-limit keys + session.ipAddress).
    // Prefer the proxy-set, single-value header first: client-supplied
    // x-forwarded-for is spoofable and would let an attacker rotate rate-limit
    // keys to dilute per-IP brute-force counters.
    ipAddress: { ipAddressHeaders: ["x-real-ip", "x-forwarded-for"] },
    // Force `secure` + `__Secure-` cookies whenever we're in production OR the
    // base URL is https — don't couple security to NODE_ENV alone, since the
    // self-hosted prod path doesn't guarantee NODE_ENV=production and would
    // otherwise silently emit non-secure cookies over https.
    useSecureCookies:
      process.env.NODE_ENV === "production" ||
      (process.env.BETTER_AUTH_URL?.startsWith("https://") ?? false),
    // Better Auth already defaults to SameSite=Lax, which is what our CSRF
    // protection on route handlers relies on. Declare it explicitly so it
    // can't silently drift with a future Better Auth default change.
    defaultCookieAttributes: { sameSite: "lax" },
    // Run background email sends (verification/reset/magic-link) after the
    // response so serverless doesn't kill the in-flight send; fall back to
    // inline when not in a request scope (e.g. scripts/seed).
    backgroundTasks: {
      handler: (promise) => {
        try {
          after(() => promise);
        } catch {
          void promise;
        }
      },
    },
  },
  hooks: {
    // Normalize email on every email-bearing auth endpoint (so Gmail dots/+tags
    // can't farm duplicate accounts), and reject disposable/relay providers on
    // account creation. Returning { context: { body } } rewrites the request.
    before: createAuthMiddleware(async (ctx) => {
      // Impersonation: the live session is the TARGET's, so any account write
      // would land on THEIR profile — and outside any audit trail, since the
      // SYS guards resolve against the target's email in that context and
      // `recordAdminAction` is never reached. These endpoints are called
      // straight from `authClient.*` and never enter our Server Actions, so
      // `hooks.before` is the only place they can be intercepted. Placed FIRST
      // so it cuts before any other logic. The session is read ONLY when the
      // path is in the set, so a normal sign-in/sign-up/get-session/sign-out
      // costs nothing extra. Exact match: /admin/stop-impersonating is
      // deliberately NOT in the set — blocking it would lock the operator
      // inside the target's session.
      if (isBlockedDuringImpersonation(ctx.path)) {
        // `disableCookieCache` is mandatory here, not an optimization knob.
        // We run `session.cookieCache` (5 min), so a cached read could be
        // served from a `session_data` blob minted BEFORE the impersonation
        // started — and Better Auth never checks that blob against the
        // `session_token` cookie sent alongside it. An operator keeping their
        // pre-impersonation `session_data` while sending the impersonated
        // token would get a guard that sees no impersonation, in front of an
        // endpoint that writes to the target's account for real: six of these
        // paths (`/change-password`, `/change-email`, `/delete-user`, the three
        // revoke-* variants) go through `sensitiveSessionMiddleware`, which
        // drops the memoized session and re-reads it authoritatively
        // (session.mjs, `getAuthoritativeSessionFromCtx`), and both token paths
        // do the same via `resolveUserId`. The guard must read from the same
        // source of truth as the endpoint it protects. Cost is confined to the
        // blocked paths, which were hitting the DB anyway.
        const session = await getSessionFromCtx(ctx, {
          disableCookieCache: true,
        });
        const impersonatedBy = (
          session?.session as { impersonatedBy?: string | null } | undefined
        )?.impersonatedBy;
        if (impersonatedBy) {
          // The whole point of this guard is that these writes escape the audit
          // trail — a blocked ATTEMPT must not escape it too. Never log tokens
          // or the body: just who/what/when.
          console.warn("[impersonation-guard] écriture refusée", {
            path: ctx.path,
            targetUserId: session?.user?.id,
            impersonatedBy,
            at: new Date().toISOString(),
          });
          // English on purpose: server hooks have no next-intl request scope.
          throw new APIError("FORBIDDEN", {
            message: "Not allowed while impersonating a user.",
          });
        }
      }
      // Seat quota: members + pending invitations must stay within the plan.
      // Enforced here (not via the org plugin's membershipLimit/invitationLimit
      // options) so the rejection message is ours — the clients surface
      // `res.error.message` directly (team-management, wizard team-step).
      if (ctx.path === "/organization/invite-member") {
        const bodyOrgId = (ctx.body as { organizationId?: unknown } | undefined)
          ?.organizationId;
        let orgId = typeof bodyOrgId === "string" ? bodyOrgId : undefined;
        if (!orgId) {
          const session = await getSessionFromCtx(ctx);
          orgId =
            (
              session?.session as
                | { activeOrganizationId?: string | null }
                | undefined
            )?.activeOrganizationId ?? undefined;
        }
        if (orgId) {
          try {
            await assertWithinQuota(orgId, "seats", 1);
          } catch (e) {
            if (e instanceof QuotaError) {
              // English on purpose: server hooks have no next-intl request
              // scope; mirrors messages/en.json Quota.seats.
              throw new APIError("FORBIDDEN", {
                message: `All seats are taken (${e.limit}). Upgrade to invite more people.`,
              });
            }
            throw e;
          }
        }
      }
      // Accept-time seat guard (seat flow S2): an invitation sent while a seat
      // was free can be accepted when the org is full (downgrade, seat taken,
      // trial expired). Re-check here; on block, keep the invitation pending,
      // nudge the org's owner/admins (deduped) and reject with a
      // machine-readable token the client maps to localized copy.
      if (ctx.path === "/organization/accept-invitation") {
        // SYS orgless invariant (spec §2.bis): a system operator must never
        // join an org, even via an invitation accepted after promotion
        // (promoteToStaff only guards at promotion time). BOTH system roles
        // are blocked — "staff" (Staff SYS) AND "admin" (Admin SYS). An Admin
        // SYS has NO org/site: its only surface is /admin, no dual access.
        const acceptSession = await getSessionFromCtx(ctx);
        const acceptUserId = acceptSession?.session?.userId;
        if (acceptUserId) {
          const [acceptUser] = await db
            .select({ role: authSchema.user.role })
            .from(authSchema.user)
            .where(eq(authSchema.user.id, acceptUserId))
            .limit(1);
          if (acceptUser?.role === "staff" || acceptUser?.role === "admin") {
            throw new APIError("FORBIDDEN", {
              message:
                "Les opérateurs système ne peuvent pas rejoindre une organisation",
            });
          }
        }
        const invId = (ctx.body as { invitationId?: unknown } | undefined)
          ?.invitationId;
        if (typeof invId === "string") {
          const check = await checkInvitationSeatBlock(invId);
          if (check.blocked) {
            // Fire-and-forget: the admin email must not delay (or be killed
            // by) the throw below — same after()/void pattern as
            // advanced.backgroundTasks.
            const notify = notifySeatBlock(check).catch((e) => {
              console.warn(
                `[seat-guard] admin notify failed: ${e instanceof Error ? e.message : e}`,
              );
            });
            try {
              after(() => notify);
            } catch {
              void notify;
            }
            throw new APIError("FORBIDDEN", {
              message: "SEAT_BLOCKED",
              code: "SEAT_BLOCKED",
            });
          }
        }
      }
      // Strong-password policy on every password-setting endpoint. Better Auth
      // itself only checks min/max length — complexity is enforced here.
      const passwordField = PASSWORD_POLICY_PATHS[ctx.path];
      if (passwordField) {
        const pw = (ctx.body as Record<string, unknown> | undefined)?.[
          passwordField
        ];
        if (typeof pw === "string" && !validatePassword(pw).ok) {
          throw new APIError("BAD_REQUEST", {
            message:
              "Mot de passe trop faible : 14 caractères minimum avec majuscule, minuscule, chiffre et symbole.",
          });
        }
      }
      // Username is REQUIRED at signup — enforced here (not via the DB schema:
      // the column stays NULLABLE for legacy/edge accounts, e.g. magic-link or
      // pre-plugin users). Gating it at the /sign-up/email boundary guarantees
      // the requirement even for callers that bypass our form. The username
      // plugin reads `username` from this same body, so we validate the field
      // it will consume. isValidUsername mirrors the plugin's allowed-character
      // rule (+ reserved list); length is the plugin's min/max, re-checked here
      // so a missing/too-short handle is rejected as "required" with our copy.
      if (ctx.path === "/sign-up/email") {
        const uname = (ctx.body as { username?: unknown } | undefined)
          ?.username;
        const valid =
          typeof uname === "string" &&
          uname.length >= MIN_USERNAME_LENGTH &&
          uname.length <= MAX_USERNAME_LENGTH &&
          isValidUsername(uname);
        if (!valid) {
          // English on purpose: server hooks have no next-intl request scope.
          throw new APIError("BAD_REQUEST", {
            message:
              "A username is required (3–30 characters: letters, numbers, _ and . only).",
          });
        }
      }
      const email = (ctx.body as { email?: unknown } | undefined)?.email;
      if (typeof email !== "string") return;
      if (EMAIL_BLOCK_PATHS.has(ctx.path)) {
        const res = checkEmail(email);
        if (!res.ok) throw new APIError("BAD_REQUEST", { message: res.reason });
        return { context: { body: { ...ctx.body, email: res.email } } };
      }
      if (EMAIL_NORMALIZE_PATHS.has(ctx.path)) {
        return {
          context: { body: { ...ctx.body, email: normalizeEmail(email) } },
        };
      }
    }),
  },
  session: {
    // 5-min signed cookie cache: skip a DB round-trip on every auth check.
    cookieCache: { enabled: true, maxAge: 5 * 60 },
  },
  emailAndPassword: {
    enabled: true,
    minPasswordLength: MIN_PASSWORD_LENGTH,
    maxPasswordLength: MAX_PASSWORD_LENGTH,
    // Block password sign-in until the address is verified — only when email
    // sending is configured (otherwise local dev without a key would lock out).
    requireEmailVerification: env.emailEnabled,
    // Invalidate all sessions on password reset (compromise recovery).
    revokeSessionsOnPasswordReset: true,
    sendResetPassword: async ({ user, url }) => {
      await sendResetPasswordEmail({ to: user.email, url, name: user.name });
    },
  },
  emailVerification: {
    // Fire the verification email automatically on account creation.
    sendOnSignUp: env.emailEnabled,
    autoSignInAfterVerification: true,
    sendVerificationEmail: async ({ user, url }) => {
      // Invited users are created already verified — nothing to confirm.
      if (user.emailVerified) return;
      await sendVerificationEmail({ to: user.email, url, name: user.name });
    },
  },
  user: {
    additionalFields: {
      locale: {
        type: "string",
        required: false,
        defaultValue: "en",
        input: true,
      },
      onboardingCompletedAt: { type: "date", required: false, input: false },
    },
    changeEmail: {
      enabled: true,
      // Send the confirmation link to the NEW address so changing email
      // verifies the new mailbox before it takes effect.
      sendChangeEmailConfirmation: async ({ user, newEmail, url }) => {
        await sendChangeEmailEmail({ to: newEmail, url, name: user.name });
      },
    },
    deleteUser: {
      enabled: true,
      // Require an email click to finalize deletion (don't delete instantly).
      sendDeleteAccountVerification: async ({ user, url }) => {
        await sendDeleteAccountEmail({ to: user.email, url, name: user.name });
      },
      // P0 S1: sites.ownerUserId / support_tickets.user_id used to have no
      // onDelete → deletion 500'd on the FK if the user owned a site. The
      // FKs are now ON DELETE SET NULL (migration 0052), so this never
      // blocks — it only logs the orphaning. See account-delete-guard.ts.
      beforeDelete: async (user) => {
        await logAccountDeletionOwnership(user.id);
      },
    },
  },
  socialProviders: (() => {
    const providers: NonNullable<BetterAuthOptions["socialProviders"]> = {};
    if (env.googleEnabled) {
      providers.google = {
        clientId: env.GOOGLE_CLIENT_ID as string,
        clientSecret: env.GOOGLE_CLIENT_SECRET as string,
        // Request the GSC read scope + an offline refresh token.
        scope: ["https://www.googleapis.com/auth/webmasters.readonly"],
        accessType: "offline",
        // Deux valeurs de `prompt`, deux décisions distinctes — ne pas les
        // confondre en relisant.
        //
        // 1. `consent` reste ÉCARTÉ. L'écran de consentement OAuth est en
        //    Production (refresh tokens durables), donc inutile de re-forcer
        //    le consentement à chaque connexion : Google réutilise
        //    l'autorisation existante, et `accessType: "offline"` délivre le
        //    refresh_token au premier consentement. Décision 2026-07-24 (login
        //    Google était pénible : ré-consentement systématique). Toujours
        //    valable — ce qui suit ne l'annule pas.
        //
        // 2. `select_account` est AJOUTÉ volontairement (2026-07-26). Il
        //    n'impose que le choix du compte, pas le ré-consentement. Sans
        //    lui, un utilisateur n'ayant qu'une session Google ouverte voit
        //    Google auto-sélectionner ce compte et revenir sans rien changer :
        //    le bouton « Lier un autre compte Google » de /gsc est alors muet,
        //    et le cas agence (un consultant détenant les Search Console de
        //    plusieurs clients) devient inatteignable. C'est une option de
        //    PROVIDER — le schéma de `/link-social` ne permet pas de la passer
        //    par appel — donc elle vaut pour TOUTES les connexions Google, y
        //    compris le login. Contrepartie assumée : un clic de choix de
        //    compte à chaque connexion Google, même pour qui n'en a qu'un.
        prompt: "select_account",
      };
    }
    if (env.githubEnabled) {
      providers.github = {
        clientId: env.GITHUB_CLIENT_ID as string,
        clientSecret: env.GITHUB_CLIENT_SECRET as string,
      };
    }
    return Object.keys(providers).length ? providers : undefined;
  })(),
  databaseHooks: {
    user: {
      create: {
        // An invited address is proven by the invite link, so skip email
        // verification AND the new-user onboarding wizard (langue/forfait/équipe):
        // the user joins an org that already has its own plan, so forcing a
        // forfait on them would be wrong. See invitedSignupOverrides.
        //
        // A1: this hook is the ONE choke point every user-creation path runs
        // through, credential signup AND OAuth alike — hooks.before's
        // EMAIL_BLOCK_PATHS/EMAIL_NORMALIZE_PATHS only cover credential/magic-link
        // endpoints, so an OAuth signup used to skip both the disposable-domain
        // blocklist and the Gmail dot/+ normalization (farming vector). Re-run
        // checkEmail here so every path is covered.
        before: async (user) => {
          const emailCheck = checkEmail(user.email);
          if (!emailCheck.ok) {
            throw new APIError("BAD_REQUEST", { message: emailCheck.reason });
          }
          const email = emailCheck.email;
          const [inv] = await db
            .select({ id: authSchema.invitation.id })
            .from(authSchema.invitation)
            .where(
              and(
                eq(authSchema.invitation.email, email),
                eq(authSchema.invitation.status, "pending"),
              ),
            )
            .limit(1);
          // Super-admin bridge: SUPER_ADMIN_EMAILS stays the policy/source of
          // truth (assertSuperAdmin), but the admin plugin recognizes admins via
          // user.role. Stamp role:"admin" at create time for those emails so the
          // plugin's role-based checks line up. (Env check inlined — importing
          // auth/super-admin.ts here would form a cycle: it imports `auth`.) See
          // scripts/backfill-admin-roles.ts for EXISTING super-admins.
          const overrides = isSuperAdminEmail(email) ? { role: "admin" } : {};
          return inv
            ? {
                data: {
                  ...user,
                  email,
                  ...invitedSignupOverrides(new Date()),
                  ...overrides,
                },
              }
            : { data: { ...user, email, ...overrides } };
        },
        // First sign-in: create a personal org + owner membership — SAUF pour
        // les comptes SYS (role "staff", provisionnés depuis /admin/staff) qui
        // sont orgless par construction (spec RBAC §2.bis).
        after: async (user) => {
          if (!shouldBootstrapOrg((user as { role?: string | null }).role))
            return;
          await createOrgForUser(user.id, user.name ?? user.email);
        },
      },
    },
    session: {
      create: {
        // Pick the session's active org: the user's last chosen org (persisted
        // via setActiveOrgAction) when they're still a member of it — so an
        // invited member doesn't reset to their oldest (personal) org each login
        // — else the oldest membership as a deterministic fallback.
        // NOTE : sur un signup one-shot (magic link), ce hook peut courir AVANT
        // que user.create.after ait fini de bootstrapper l'org personnelle → la
        // session naît sans org active. getActiveOrgId() se self-heal dans ce
        // cas (src/lib/active-org.ts) avec la même règle resolveDefaultOrgId.
        before: async (session) => {
          const orgId = await resolveDefaultOrgId(session.userId);
          return orgId
            ? { data: { ...session, activeOrganizationId: orgId } }
            : { data: session };
        },
      },
    },
  },
  plugins: [
    organization({
      ac,
      roles: { owner, admin, member },
      // Cap orgs per user and only let verified users create more (the personal
      // org is still bootstrapped directly on signup, bypassing this gate).
      // SYS operators (spec §2.bis) are orgless by construction: an Admin SYS
      // (SUPER_ADMIN_EMAILS) can never create an org either.
      organizationLimit: 5,
      allowUserToCreateOrganization: async (user) =>
        user.emailVerified && !isSuperAdminEmail(user.email),
      invitationExpiresIn: 60 * 60 * 24 * 7, // 7 days
      sendInvitationEmail: async (data) => {
        await sendInvitationEmail({
          to: data.email,
          url: `${APP_URL}/accept-invitation/${data.id}`,
          orgName: data.organization.name,
          inviterName: data.inviter.user.name,
          role: data.role,
        });
      },
    }),
    magicLink({
      sendMagicLink: async ({ email, url }) => {
        await sendMagicLinkEmail({ to: email, url });
      },
    }),
    // TOTP authenticator-app 2FA + backup codes. `issuer` is what authenticator
    // apps display next to the account; defaults to appName but set explicitly.
    // Enrollment requires a verify step (skipVerificationOnEnable left false) so
    // a mistyped secret can't lock the user out.
    twoFactor({ issuer: "SEO Insight" }),
    // Optional username handles. Keeps the default lowercase normalization for
    // `username` (so lookups are case-insensitive) while `displayUsername`
    // preserves the original casing (displayUsernameNormalization defaults to
    // false). `usernameValidator` REPLACES the plugin default regex, so
    // isValidUsername re-implements the allowed-character rule AND blocks a
    // reserved-handle list (see auth/username-policy.ts). Sign-in respects
    // requireEmailVerification just like /sign-in/email.
    username({
      minUsernameLength: MIN_USERNAME_LENGTH,
      maxUsernameLength: MAX_USERNAME_LENGTH,
      usernameValidator: isValidUsername,
    }),
    // Global admin plugin — mounts /api/auth/admin/* (list-users, ban/unban,
    // set-role, impersonate, …). An admin is any user whose `role` is in
    // `adminRoles`; new users default to `defaultRole`. We DON'T use
    // `adminUserIds` (it needs async email→id resolution at this synchronous
    // config-build time). The super-admin bridge stamps role:"admin" for
    // SUPER_ADMIN_EMAILS (create hook above + backfill script). Impersonation is
    // capped to 30 min; the impersonation UI is deliberately NOT built yet (it
    // interacts with our session.create activeOrganizationId hook — needs review).
    adminPlugin({
      defaultRole: "user",
      adminRoles: ["admin"],
      impersonationSessionDuration: 60 * 30,
    }),
    // Stripe billing — org-scoped subscriptions resolved by lookup keys.
    // Only mounted when all Stripe env vars are set so the app boots without
    // Stripe in local dev / CI.
    ...(env.stripeEnabled
      ? [
          stripe({
            stripeClient: new Stripe(env.STRIPE_SECRET_KEY as string),
            stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET as string,
            // Customers are created lazily at first checkout, not on signup —
            // the 14-day trial is app-level (organization.trialEndsAt).
            createCustomerOnSignUp: false,
            // Generic webhook hook — runs for EVERY verified Stripe event.
            // Dispatches to pack fulfillment + seat unblock (stripe-events.ts).
            onEvent: handleStripeEvent,
            subscription: {
              enabled: true,
              plans: PLANS.map((p) => ({
                name: p.name,
                lookupKey: p.lookupKey,
                annualDiscountLookupKey: p.annualLookupKey,
                limits: { ...p.limits },
                // NOTE: the installed plugin (1.6.17) only supports per-seat
                // billing via `seatPriceId` (a price id, not a lookup key) —
                // wired in a later task once the bootstrap script resolves ids.
              })),
              // Only org owners/admins may act on the org's subscription.
              authorizeReference: async ({ user, referenceId }) =>
                isOrgBillingManager(user.id, referenceId),
            },
            // Give organizations their own Stripe customer id.
            organization: { enabled: true },
          }),
        ]
      : []),
    // MUST stay last (Better Auth Next.js integration): auto-applies the
    // Set-Cookie headers from server-action auth calls (e.g. admin
    // impersonation) via Next's cookies() helper. Client → /api/auth/* flows
    // set cookies in the route handler and don't need this; server actions do.
    nextCookies(),
  ],
});
