// src/lib/auth/seat-guard.test.ts — accept-time seat guard (seat flow S2)
// against REAL data in Postgres. Gated on DB_TESTS=1, test DB only:
// DB_TESTS=1 DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test \
//   BETTER_AUTH_SECRET=... bun --conditions=react-server test <file>
// Email sends are injected (recorder fn) — no real Resend call.
import { beforeEach, describe, expect, mock, test } from "bun:test";

mock.module("server-only", () => ({}));
// checkInvitationSeatBlock → getOrgBillingInfo (status.ts), now
// unstable_cache'd (QW2-A) — the real next/cache throws outside a Next
// request scope (no incrementalCache), so stub it as a pass-through.
mock.module("next/cache", () => ({
  revalidatePath: () => {},
  revalidateTag: () => {},
  updateTag: () => {},
  unstable_cache: (fn: unknown) => fn,
}));

const RUN_DB = process.env.DB_TESTS === "1";

const ORG = "test-seatg-org";
const OWNER = "test-seatg-owner";
const ADMIN = "test-seatg-admin";
const STAFF = "test-seatg-staff";
const INV = "test-seatg-inv";
const NOW = new Date();
const IN_7_DAYS = new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1000);

type SendArgs = {
  to: string;
  orgName: string;
  plan: string | null;
  attemptEmail: string;
};

describe.skipIf(!RUN_DB)("accept-time seat guard (postgres)", () => {
  let db: typeof import("@/lib/db/client").db;
  let auth: typeof import("@/lib/db/auth-schema");
  let guard: typeof import("./seat-guard");

  // Injected email recorder — notifySeatBlock(block, send) never hits Resend.
  let sent: SendArgs[];
  const send = async (opts: SendArgs) => {
    sent.push(opts);
  };

  async function seedUser(id: string, email: string) {
    await db.insert(auth.user).values({
      id,
      name: id,
      email,
      emailVerified: true,
      createdAt: NOW,
      updatedAt: NOW,
    });
  }

  async function seedMember(userId: string, role: string) {
    await db.insert(auth.member).values({
      id: `${userId}-m`,
      organizationId: ORG,
      userId,
      role,
      createdAt: NOW,
    });
  }

  async function seedInvitation(status = "pending") {
    await db.insert(auth.invitation).values({
      id: INV,
      organizationId: ORG,
      email: "seatg-invitee@test.dev",
      role: "member",
      status,
      expiresAt: IN_7_DAYS,
      inviterId: OWNER,
    });
  }

  async function seedSubscription(plan: string) {
    await db.insert(auth.subscription).values({
      id: "test-seatg-sub",
      plan,
      referenceId: ORG,
      status: "active",
      periodStart: NOW,
    });
  }

  beforeEach(async () => {
    ({ db } = await import("@/lib/db/client"));
    auth = await import("@/lib/db/auth-schema");
    guard = await import("./seat-guard");
    const { assertTestDb } = await import("@/lib/db/assert-test-db");
    const { eq, inArray } = await import("drizzle-orm");
    await assertTestDb();
    sent = [];

    // Org cascades to members + invitations; subscriptions and users are
    // deleted explicitly (no FK cascade on referenceId).
    await db
      .delete(auth.subscription)
      .where(eq(auth.subscription.referenceId, ORG));
    await db.delete(auth.organization).where(eq(auth.organization.id, ORG));
    await db
      .delete(auth.user)
      .where(inArray(auth.user.id, [OWNER, ADMIN, STAFF]));

    await seedUser(OWNER, "seatg-owner@test.dev");
    await db.insert(auth.organization).values({
      id: ORG,
      name: "Seat Guard Org",
      slug: "test-seat-guard",
      createdAt: NOW,
    });
  });

  test("solo org at capacity: blocked, admins emailed once, dedup stamp survives a second attempt", async () => {
    const { eq } = await import("drizzle-orm");
    // Active 'solo' (1 seat) + owner member + the pending invitation → 2 > 1.
    await seedSubscription("solo");
    await seedMember(OWNER, "owner");
    await seedInvitation();

    const check = await guard.checkInvitationSeatBlock(INV);
    expect(check.blocked).toBe(true);
    if (!check.blocked) throw new Error("unreachable");
    expect(check.orgId).toBe(ORG);
    expect(check.orgName).toBe("Seat Guard Org");
    expect(check.plan).toBe("solo");
    expect(check.attemptEmail).toBe("seatg-invitee@test.dev");
    expect(check.alreadyNotified).toBe(false);

    // First notification: stamps + sends to the owner.
    expect(await guard.notifySeatBlock(check, send)).toBe(true);
    expect(sent).toEqual([
      {
        to: "seatg-owner@test.dev",
        orgName: "Seat Guard Org",
        plan: "solo",
        attemptEmail: "seatg-invitee@test.dev",
      },
    ]);
    const [afterFirst] = await db
      .select({
        status: auth.invitation.status,
        stamp: auth.invitation.seatBlockNotifiedAt,
      })
      .from(auth.invitation)
      .where(eq(auth.invitation.id, INV));
    // Still pending (never consumed) + dedup stamp set.
    expect(afterFirst.status).toBe("pending");
    expect(afterFirst.stamp).not.toBeNull();

    // Second attempt: still blocked, but flagged as already notified and the
    // notifier no-ops — no second email, stamp unchanged.
    const again = await guard.checkInvitationSeatBlock(INV);
    expect(again.blocked).toBe(true);
    if (!again.blocked) throw new Error("unreachable");
    expect(again.alreadyNotified).toBe(true);
    expect(await guard.notifySeatBlock(again, send)).toBe(false);
    expect(sent).toHaveLength(1);
    const [afterSecond] = await db
      .select({ stamp: auth.invitation.seatBlockNotifiedAt })
      .from(auth.invitation)
      .where(eq(auth.invitation.id, INV));
    expect(afterSecond.stamp?.getTime()).toBe(
      (afterFirst.stamp as Date).getTime(),
    );
  });

  test("notification goes to owner + admins, not staff", async () => {
    await seedSubscription("solo");
    await seedUser(ADMIN, "seatg-admin@test.dev");
    await seedUser(STAFF, "seatg-staff@test.dev");
    await seedMember(OWNER, "owner");
    await seedMember(ADMIN, "admin");
    await seedMember(STAFF, "member");
    await seedInvitation();

    const check = await guard.checkInvitationSeatBlock(INV);
    expect(check.blocked).toBe(true);
    if (!check.blocked) throw new Error("unreachable");
    await guard.notifySeatBlock(check, send);
    expect(sent.map((s) => s.to).sort()).toEqual([
      "seatg-admin@test.dev",
      "seatg-owner@test.dev",
    ]);
  });

  test("expired org (trialEndsAt past, no subscription) blocks too", async () => {
    const { eq } = await import("drizzle-orm");
    await db
      .update(auth.organization)
      .set({ trialEndsAt: new Date(NOW.getTime() - 24 * 60 * 60 * 1000) })
      .where(eq(auth.organization.id, ORG));
    await seedMember(OWNER, "owner");
    await seedInvitation();

    const check = await guard.checkInvitationSeatBlock(INV);
    expect(check.blocked).toBe(true);
    if (!check.blocked) throw new Error("unreachable");
    expect(check.plan).toBeNull();
  });

  test("org with a free seat is not blocked", async () => {
    // 'pro' (3 seats): owner member + this pending invitation = 2 ≤ 3.
    await seedSubscription("pro");
    await seedMember(OWNER, "owner");
    await seedInvitation();
    expect(await guard.checkInvitationSeatBlock(INV)).toEqual({
      blocked: false,
    });
  });

  test("the invite that reserved the LAST seat still gets in (net seat change is zero)", async () => {
    // 'pro' (3 seats): 2 members + this pending invitation = 3 = limit.
    // Accepting converts the reservation into a membership — must NOT block.
    await seedSubscription("pro");
    await seedUser(ADMIN, "seatg-admin@test.dev");
    await seedMember(OWNER, "owner");
    await seedMember(ADMIN, "admin");
    await seedInvitation();
    expect(await guard.checkInvitationSeatBlock(INV)).toEqual({
      blocked: false,
    });
  });

  test("unknown or non-pending invitations are left to Better Auth (not blocked)", async () => {
    await seedSubscription("solo");
    await seedMember(OWNER, "owner");
    await seedInvitation("accepted");
    expect(await guard.checkInvitationSeatBlock(INV)).toEqual({
      blocked: false,
    });
    expect(await guard.checkInvitationSeatBlock("test-seatg-missing")).toEqual({
      blocked: false,
    });
  });
});
