// src/lib/billing/seats-limit.ts — keeps organization.seatsLimit (auth-schema.ts)
// in sync with the resolved seats quota (sec audit A2 backstop, ELEVA-112 S4).
//
// WHY a materialized column at all: `checkInvitationSeatBlock` (seat-guard.ts)
// and the invite-member hook (auth.ts) are read-then-decide with no lock —
// two concurrent accept-invitation requests for the SAME last seat can both
// read the same pre-insert count and both pass. The actual member insert is
// 100% owned by Better Auth (accept-invitation, add-member), entirely outside
// any transaction we control, so an app-level advisory lock (the fix used for
// P8 imports — quotas.ts `lockOrgForQuota`) cannot wrap it: the lock and the
// insert would be in different transactions. The only place a check-then-write
// on `member` can be made atomic is a `BEFORE INSERT ON member` DB trigger
// (migration SQL, next to this file's column), which locks the organization
// row (`FOR UPDATE`) and counts members in the SAME statement as the insert.
// That trigger reads `organization.seats_limit` — a plain integer column,
// nothing async/cached — so this module's only job is to keep it correct.
//
// WRITE-PATH ONLY: every resolver here reads plan/trial/override DIRECTLY
// from Postgres, deliberately bypassing `getOrgBillingInfo`/`getOrgQuotaLimits`
// (both unstable_cache'd, up to 30-60s stale) — writing a cached, possibly
// stale limit would defeat the point of a backstop meant to be at least as
// fresh as the write that triggered the sync. Read-path quota DECISIONS
// (should this invite be allowed right now?) still go through the cached
// helpers as before; this column is never consulted from application code.
import "server-only";
import { eq } from "drizzle-orm";
import { orgLimitOverrides } from "@/lib/db/admin-schema";
import { organization, subscription } from "@/lib/db/auth-schema";
import { db } from "@/lib/db/client";
import {
  applyLimitOverrides,
  type OrgLimitOverride,
  type QuotaTx,
  resolveQuotaLimits,
} from "./quotas";
import { computeBillingStatus, pickRelevantSubscription } from "./status";

/** Same executor duality as usage.ts's `Executor` — a `tx` from
 *  `db.transaction`, or the module `db` (default). Lets `org-bootstrap.ts`
 *  sync the brand-new org's row on the SAME connection as the rest of its
 *  creation transaction. */
type Executor = QuotaTx | typeof db;

/**
 * Resolve the org's CURRENT effective seats limit straight from Postgres (no
 * cache layer) — the value written to `organization.seats_limit`.
 *
 * Floored at 1 for any org that isn't truly unlimited: `createOrgForUser`
 * inserts the founding owner unconditionally (today, with NO seat check at
 * all — it's a direct DB insert, not routed through Better Auth), including
 * for a user's SECOND+ org, which starts with no trial and no subscription
 * (`status: "expired"` → plan-resolved seats = 0). A trigger synced to the
 * raw resolved value would therefore reject the very insert that creates the
 * org. Flooring to 1 preserves "an org always gets to keep its own owner"
 * (the existing, unchanged behavior) while still closing the race for every
 * org that actually has a seat-bearing plan/trial — the scenario the audit
 * finding is about. The one theoretical gap this floor opens (an org whose
 * TRUE resolved limit is exactly 0 could still take ONE extra member via a
 * concurrent race) is already blocked at the application layer today
 * (`assertWithinQuota`/`checkInvitationSeatBlock` reject any invite/accept
 * once a real limit of 0 is in effect) — the DB trigger only needs to
 * backstop the concurrency window, not re-implement that decision.
 */
export async function resolveFreshSeatsLimit(
  orgId: string,
  executor: Executor = db,
): Promise<number | null> {
  const [org] = await executor
    .select({ trialEndsAt: organization.trialEndsAt })
    .from(organization)
    .where(eq(organization.id, orgId))
    .limit(1);
  const subs = await executor
    .select({
      status: subscription.status,
      periodStart: subscription.periodStart,
      plan: subscription.plan,
    })
    .from(subscription)
    .where(eq(subscription.referenceId, orgId));
  const sub = pickRelevantSubscription(subs);
  const status = computeBillingStatus(
    {
      trialEndsAt: org?.trialEndsAt ?? null,
      subscriptionStatus: sub?.status ?? null,
    },
    new Date(),
  );
  const planLimits = resolveQuotaLimits({ status, plan: sub?.plan ?? null });

  const [overrideRow] = await executor
    .select({
      keywords: orgLimitOverrides.keywords,
      projects: orgLimitOverrides.projects,
      seats: orgLimitOverrides.seats,
      integrations: orgLimitOverrides.integrations,
      backlinkRows: orgLimitOverrides.backlinkRows,
      mcpMonthly: orgLimitOverrides.mcpMonthly,
      expiresAt: orgLimitOverrides.expiresAt,
    })
    .from(orgLimitOverrides)
    .where(eq(orgLimitOverrides.organizationId, orgId))
    .limit(1);
  const override: OrgLimitOverride | null = overrideRow ?? null;
  const limits = applyLimitOverrides(planLimits, override, new Date());

  if (limits.seats === -1) return null; // unlimited
  return Math.max(limits.seats, 1);
}

/** Recompute and persist `organization.seats_limit` — call at every point the
 *  resolved seats limit can change: org creation (org-bootstrap.ts), a Stripe
 *  subscription event (subscription-sync.ts), or a super-admin limit-override
 *  write/clear (org-actions.ts) / trial-extension / complimentary-access
 *  grant (billing-actions.ts). Idempotent — safe to call more than once for
 *  the same change (e.g. `grantComplimentaryAction` triggers it both via the
 *  override it writes and its own revalidation pass). */
export async function syncOrgSeatsLimit(
  orgId: string,
  executor: Executor = db,
): Promise<void> {
  const seatsLimit = await resolveFreshSeatsLimit(orgId, executor);
  await executor
    .update(organization)
    .set({ seatsLimit })
    .where(eq(organization.id, orgId));
}
