// src/lib/billing/seat-unblock.test.ts — seat flow S3 (unblock on upgrade).
// Pure tests (selectInviteesToUnblock) always run; the webhook-driven unblock
// is 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 { describe, expect, mock, test } from "bun:test";
import type Stripe from "stripe";

mock.module("server-only", () => ({}));
// unblockSeatInvitations calls getOrgQuotaLimits (quotas.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";

// ── selectInviteesToUnblock (pure) ───────────────────────────────────────────

function invitee(id: string, blockedAt: string) {
  return {
    id,
    email: `${id}@test.dev`,
    seatBlockNotifiedAt: new Date(blockedAt),
  };
}

describe("selectInviteesToUnblock (pure)", () => {
  test("caps at freeSeats, oldest stamp first", async () => {
    const { selectInviteesToUnblock } = await import("./seat-unblock");
    const blocked = [
      invitee("newer", "2026-06-10T00:00:00Z"),
      invitee("oldest", "2026-06-01T00:00:00Z"),
      invitee("middle", "2026-06-05T00:00:00Z"),
    ];
    expect(selectInviteesToUnblock(2, blocked).map((b) => b.id)).toEqual([
      "oldest",
      "middle",
    ]);
    // Input order untouched (no in-place sort).
    expect(blocked[0].id).toBe("newer");
  });

  test("freeSeats >= blocked → all, oldest first", async () => {
    const { selectInviteesToUnblock } = await import("./seat-unblock");
    const blocked = [
      invitee("b", "2026-06-10T00:00:00Z"),
      invitee("a", "2026-06-01T00:00:00Z"),
    ];
    expect(selectInviteesToUnblock(5, blocked).map((x) => x.id)).toEqual([
      "a",
      "b",
    ]);
  });

  test("0 or negative free seats → nobody", async () => {
    const { selectInviteesToUnblock } = await import("./seat-unblock");
    const blocked = [invitee("a", "2026-06-01T00:00:00Z")];
    expect(selectInviteesToUnblock(0, blocked)).toEqual([]);
    expect(selectInviteesToUnblock(-3, blocked)).toEqual([]);
  });

  test("-1 / Infinity (unlimited) → everyone, oldest first", async () => {
    const { selectInviteesToUnblock } = await import("./seat-unblock");
    const blocked = [
      invitee("b", "2026-06-10T00:00:00Z"),
      invitee("a", "2026-06-01T00:00:00Z"),
    ];
    expect(selectInviteesToUnblock(-1, blocked).map((x) => x.id)).toEqual([
      "a",
      "b",
    ]);
    expect(
      selectInviteesToUnblock(Number.POSITIVE_INFINITY, blocked).map(
        (x) => x.id,
      ),
    ).toEqual(["a", "b"]);
  });

  test("no blocked invitations → nobody", async () => {
    const { selectInviteesToUnblock } = await import("./seat-unblock");
    expect(selectInviteesToUnblock(3, [])).toEqual([]);
  });
});

// ── DB-gated: webhook-driven unblock ─────────────────────────────────────────

/** Minimal fake customer.subscription.* event (cast — we only read type,
 *  data.object.id and data.object.metadata). */
function fakeSubEvent(over: {
  type?: string;
  subscriptionId?: string;
  metadata?: Record<string, string>;
}): Stripe.Event {
  return {
    type: over.type ?? "customer.subscription.updated",
    data: {
      object: {
        id: over.subscriptionId ?? "sub_test_sunb",
        metadata: over.metadata ?? {},
      },
    },
  } as unknown as Stripe.Event;
}

type SentArgs = { to: string; orgName: string; invitationId: string };

describe.skipIf(!RUN_DB)("unblockSeatInvitations (postgres)", () => {
  const NOW = new Date();
  const IN_7_DAYS = new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1000);
  const OLDER = new Date("2026-06-01T00:00:00Z");
  const NEWER = new Date("2026-06-10T00:00:00Z");

  async function setup(orgSuffix: string) {
    const { db } = await import("@/lib/db/client");
    const auth = await import("@/lib/db/auth-schema");
    const { assertTestDb } = await import("@/lib/db/assert-test-db");
    const { eq } = await import("drizzle-orm");
    await assertTestDb();

    const ORG = `test-sunb-org-${orgSuffix}`;
    const OWNER = `test-sunb-owner-${orgSuffix}`;
    const STRIPE_SUB = `sub_test_sunb_${orgSuffix}`;

    // Org cascades to members + invitations; subscription and user 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(eq(auth.user.id, OWNER));

    await db.insert(auth.user).values({
      id: OWNER,
      name: OWNER,
      email: `${OWNER}@test.dev`,
      emailVerified: true,
      createdAt: NOW,
      updatedAt: NOW,
    });
    await db.insert(auth.organization).values({
      id: ORG,
      name: `Seat Unblock Org ${orgSuffix}`,
      slug: `test-seat-unblock-${orgSuffix}`,
      createdAt: NOW,
    });

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

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

    async function seedBlockedInvitation(id: string, blockedAt: Date) {
      await db.insert(auth.invitation).values({
        id,
        organizationId: ORG,
        email: `${id}@test.dev`,
        role: "member",
        status: "pending",
        expiresAt: IN_7_DAYS,
        inviterId: OWNER,
        seatBlockNotifiedAt: blockedAt,
      });
    }

    async function getStamps(): Promise<Map<string, Date | null>> {
      const rows = await db
        .select({
          id: auth.invitation.id,
          stamp: auth.invitation.seatBlockNotifiedAt,
        })
        .from(auth.invitation)
        .where(eq(auth.invitation.organizationId, ORG));
      return new Map(rows.map((r) => [r.id, r.stamp]));
    }

    return {
      ORG,
      STRIPE_SUB,
      seedSubscription,
      seedMember,
      seedBlockedInvitation,
      getStamps,
    };
  }

  test("1 free seat → exactly the oldest blocked invitee is emailed and unstamped", async () => {
    const t = await setup("free1");
    // 'pro' (3 seats), no members, 2 pending invitations → seats used = 2,
    // free = 3 - 2 = 1 → only the OLDEST blocked invitee gets the email.
    await t.seedSubscription("pro");
    await t.seedBlockedInvitation("test-sunb-inv-old-free1", OLDER);
    await t.seedBlockedInvitation("test-sunb-inv-new-free1", NEWER);

    const { unblockSeatInvitations } = await import("./seat-unblock");
    const sent: SentArgs[] = [];
    await unblockSeatInvitations(
      fakeSubEvent({ subscriptionId: t.STRIPE_SUB }),
      async (opts) => {
        sent.push(opts);
      },
    );

    expect(sent).toEqual([
      {
        to: "test-sunb-inv-old-free1@test.dev",
        orgName: "Seat Unblock Org free1",
        invitationId: "test-sunb-inv-old-free1",
      },
    ]);
    const stamps = await t.getStamps();
    // Oldest: stamp reset (a later re-block can re-notify the admin).
    expect(stamps.get("test-sunb-inv-old-free1")).toBeNull();
    // Newest: untouched, still waiting.
    expect(stamps.get("test-sunb-inv-new-free1")?.getTime()).toBe(
      NEWER.getTime(),
    );
  });

  test("seats still full → no email, stamps untouched", async () => {
    const t = await setup("full");
    // 'solo' (1 seat) + owner member + 2 pending invitations → free = -2.
    await t.seedSubscription("solo");
    await t.seedMember("owner");
    await t.seedBlockedInvitation("test-sunb-inv-old-full", OLDER);
    await t.seedBlockedInvitation("test-sunb-inv-new-full", NEWER);

    const { unblockSeatInvitations } = await import("./seat-unblock");
    const sent: SentArgs[] = [];
    await unblockSeatInvitations(
      fakeSubEvent({ subscriptionId: t.STRIPE_SUB }),
      async (opts) => {
        sent.push(opts);
      },
    );

    expect(sent).toEqual([]);
    const stamps = await t.getStamps();
    expect(stamps.get("test-sunb-inv-old-full")?.getTime()).toBe(
      OLDER.getTime(),
    );
    expect(stamps.get("test-sunb-inv-new-full")?.getTime()).toBe(
      NEWER.getTime(),
    );
  });

  test("non-subscription events and unknown subscriptions are ignored", async () => {
    const t = await setup("ignore");
    await t.seedSubscription("pro");
    await t.seedBlockedInvitation("test-sunb-inv-old-ignore", OLDER);

    const { unblockSeatInvitations } = await import("./seat-unblock");
    const sent: SentArgs[] = [];
    const send = async (opts: SentArgs) => {
      sent.push(opts);
    };

    // Wrong event type.
    await unblockSeatInvitations(
      fakeSubEvent({ type: "invoice.paid", subscriptionId: t.STRIPE_SUB }),
      send,
    );
    // Subscription id matching no row and no metadata fallback.
    await unblockSeatInvitations(
      fakeSubEvent({ subscriptionId: "sub_test_sunb_unknown" }),
      send,
    );

    expect(sent).toEqual([]);
    const stamps = await t.getStamps();
    expect(stamps.get("test-sunb-inv-old-ignore")?.getTime()).toBe(
      OLDER.getTime(),
    );
  });
});
