// src/lib/sites/actions.test.ts
// Integration tests — require Postgres + the test DB migrated.
// Skipped unless DB_TESTS=1. Run with: bun run test:db
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { eq } from "drizzle-orm";
import { __clearTestCaller, __setTestCaller } from "./access";

// next/cache (revalidatePath) throws outside the Next.js runtime — stub it out.
mock.module("next/cache", () => ({
  revalidatePath: () => {},
  revalidateTag: () => {},
  updateTag: () => {},
  unstable_cache: (fn: unknown) => fn,
}));

// createSiteAction resolves the active org/user from the Better Auth session;
// stub them to the demo org + seed owner so the action runs without a request.
mock.module("@/lib/active-org", () => ({
  getActiveOrgId: async () => "demo-org",
  getActiveUserId: async () => "seed-owner",
}));

/** Noms de cookies effacés par l'action, dans l'ordre. Vidé à chaque test. */
const deletedCookies: string[] = [];
// `cookies()` hors runtime Next n'a pas de magasin, et le mock d'un fichier
// voisin (mock.module fuit d'un fichier à l'autre sous bun) n'exposait qu'un
// `get`. On fournit ici un magasin complet — surensemble compatible avec ce que
// les voisins attendent — et il sert de sonde au nettoyage du cookie de report.
mock.module("next/headers", () => ({
  headers: async () => new Headers(),
  cookies: async () => ({
    get: () => undefined,
    set: () => {},
    delete: (arg: string | { name: string }) =>
      deletedCookies.push(typeof arg === "string" ? arg : arg.name),
  }),
}));

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

describe.skipIf(!RUN_DB)("sites queries & actions (postgres)", () => {
  let db: typeof import("@/lib/db/client").db;
  let schema: typeof import("@/lib/db/domain-schema");
  let authSchema: typeof import("@/lib/db/auth-schema");
  let queries: typeof import("./queries");

  const ORG = "demo-org"; // matches getActiveOrgId() stub
  const OTHER_ORG = "other-org";

  beforeEach(async () => {
    __setTestCaller({ userId: "seed-owner", orgId: "demo-org", role: "owner" });
    ({ db } = await import("@/lib/db/client"));
    schema = await import("@/lib/db/domain-schema");
    authSchema = await import("@/lib/db/auth-schema");
    queries = await import("./queries");
    // Hard guard: never run the destructive ops below against a non-*_test DB.
    const { assertTestDb } = await import("@/lib/db/assert-test-db");
    await assertTestDb();
    // Ensure "other-org" organization exists (FK target for sites.orgId).
    // "demo-org" and "seed-owner" are guaranteed by db:seed / DB setup.
    await db
      .insert(authSchema.organization)
      .values({
        id: OTHER_ORG,
        name: "Other Org",
        slug: "other-org",
        createdAt: new Date(),
      })
      .onConflictDoNothing();
    await db.delete(schema.sites);
  });

  afterEach(() => __clearTestCaller());

  async function insertSite(id: string, orgId: string, name: string) {
    await db.insert(schema.sites).values({
      id,
      orgId,
      ownerUserId: "seed-owner",
      name,
      url: "https://example.com",
      primaryLanguage: "fr",
      languages: ["en"],
      targetCountries: ["FR"],
    });
  }

  test("getProjectsForOrg returns only the org's sites, sorted by name", async () => {
    await insertSite("s-b", ORG, "Bravo");
    await insertSite("s-a", ORG, "Alpha");
    await insertSite("s-x", OTHER_ORG, "Foreign");
    const rows = await queries.getProjectsForOrg(ORG);
    expect(rows.map((r) => r.name)).toEqual(["Alpha", "Bravo"]);
  });

  test("getSiteById is org-scoped (null for foreign org)", async () => {
    await insertSite("s-x", OTHER_ORG, "Foreign");
    expect(await queries.getSiteById("s-x", ORG)).toBeNull();
    expect(await queries.getSiteById("s-x", OTHER_ORG)).not.toBeNull();
  });

  test("updateSiteAction edits an org-owned site", async () => {
    const actions = await import("./actions");
    await insertSite("s-a", ORG, "Alpha");
    const res = await actions.updateSiteAction("s-a", {
      name: "Alpha Renamed",
      url: "https://alpha.dev",
      primaryLanguage: "en",
      languages: ["fr", "de"],
      targetCountries: ["US", "FR"],
    });
    expect(res).toEqual({ ok: true });
    const row = await queries.getSiteById("s-a", ORG);
    expect(row?.name).toBe("Alpha Renamed");
    expect(row?.primaryLanguage).toBe("en");
    expect(row?.languages).toEqual(["fr", "de"]);
  });

  test("updateSiteAction refuses a foreign-org id", async () => {
    const actions = await import("./actions");
    await insertSite("s-x", OTHER_ORG, "Foreign");
    const res = await actions.updateSiteAction("s-x", {
      name: "Hacked",
      url: "https://hacked.dev",
      primaryLanguage: "fr",
      languages: [],
      targetCountries: [],
    });
    expect(res).toEqual({ ok: false, error: "Projet introuvable" });
  });

  test("deleteSiteAction removes an org-owned site and cascades", async () => {
    const actions = await import("./actions");
    await insertSite("s-a", ORG, "Alpha");
    await db.insert(schema.keywords).values({
      id: "k-1",
      siteId: "s-a",
      term: "test",
      theme: "lab-diamonds",
      status: "idea",
      primarySource: "mangools",
    } as typeof schema.keywords.$inferInsert);
    const res = await actions.deleteSiteAction("s-a");
    expect(res).toEqual({ ok: true });
    expect(await queries.getSiteById("s-a", ORG)).toBeNull();
    const kw = await db
      .select()
      .from(schema.keywords)
      .where(eq(schema.keywords.siteId, "s-a"));
    expect(kw).toHaveLength(0); // cascade
  });

  test("deleteSiteAction refuses a foreign-org id", async () => {
    const actions = await import("./actions");
    await insertSite("s-x", OTHER_ORG, "Foreign");
    const res = await actions.deleteSiteAction("s-x");
    expect(res).toEqual({ ok: false, error: "Projet introuvable" });
  });

  test("createSiteAction activates the backlink reservoir for the new site", async () => {
    const actions = await import("./actions");
    // Two global link sources in the active (demo) org reservoir.
    await db.delete(schema.backlinks);
    await db.delete(schema.linkSources);
    await db.insert(schema.linkSources).values([
      {
        id: "ls-1",
        orgId: ORG,
        platform: "Product Hunt",
        url: "https://producthunt.com",
        domainNorm: "producthunt.com",
        kind: "launch-platform",
        effort: "medium",
        linkType: "dofollow",
      },
      {
        id: "ls-2",
        orgId: ORG,
        platform: "Hacker News",
        url: "https://news.ycombinator.com",
        domainNorm: "news.ycombinator.com",
        kind: "community",
        effort: "low",
        linkType: "nofollow",
      },
    ] as (typeof schema.linkSources.$inferInsert)[]);

    const res = await actions.createSiteAction({
      name: "Fresh Project",
      url: "https://fresh.dev",
      primaryLanguage: "fr",
      languages: [],
      targetCountries: ["FR"],
    });
    expect(res.ok).toBe(true);
    if (!res.ok) return;

    const rows = await db
      .select()
      .from(schema.backlinks)
      .where(eq(schema.backlinks.siteId, res.site.id));
    // Every global source is now a prospect for the new site.
    expect(rows).toHaveLength(2);
    expect(new Set(rows.map((r) => r.linkSourceId))).toEqual(
      new Set(["ls-1", "ls-2"]),
    );
    expect(rows.every((r) => r.status === "prospect")).toBe(true);
  });

  test("createSiteAction grants site_access to the creator", async () => {
    const actions = await import("./actions");
    // demo-org has no Stripe subscription in the test DB, so its plan
    // resolves to ZERO_LIMITS (see quotas.ts's fail-closed default) — same
    // root cause as the pre-existing "activates the backlink reservoir"
    // failure above, unrelated to this fix. Unblock just this test with a
    // scoped, self-cleaning quota override rather than touching quotas.ts.
    const admin = await import("@/lib/db/admin-schema");
    await db.insert(admin.orgLimitOverrides).values({
      organizationId: ORG,
      projects: 1,
    });
    try {
      const res = await actions.createSiteAction({
        name: "Access Project",
        url: "https://access.dev",
        primaryLanguage: "fr",
        languages: [],
        targetCountries: ["FR"],
      });
      expect(res.ok).toBe(true);
      if (!res.ok) return;

      const rows = await db
        .select({ userId: schema.siteAccess.userId })
        .from(schema.siteAccess)
        .where(eq(schema.siteAccess.siteId, res.site.id));
      expect(rows).toEqual([{ userId: "seed-owner" }]);
    } finally {
      await db
        .delete(admin.orgLimitOverrides)
        .where(eq(admin.orgLimitOverrides.organizationId, ORG));
    }
  });

  // Créer un site est LE geste qui doit rouvrir le parcours d'entrée : le
  // nouveau site n'a ni Search Console, ni mots-clés, ni plan. Sans cet
  // effacement, un report posé plus tôt dans la session (cookie httpOnly de
  // /onboarding/defer) survivait à la création et le gate laissait passer.
  test("createSiteAction efface le cookie de report du parcours", async () => {
    const actions = await import("./actions");
    const { DEFER_COOKIE } = await import("@/lib/onboarding/wizard-gate");
    const admin = await import("@/lib/db/admin-schema");
    // Même override de quota que le test précédent (demo-org sans abonnement
    // Stripe → ZERO_LIMITS), pour la même raison, avec le même nettoyage.
    await db.insert(admin.orgLimitOverrides).values({
      organizationId: ORG,
      projects: 1,
    });
    deletedCookies.length = 0;
    try {
      const res = await actions.createSiteAction({
        name: "Defer Project",
        url: "https://defer.dev",
        primaryLanguage: "fr",
        languages: [],
        targetCountries: ["FR"],
      });
      expect(res.ok).toBe(true);
      expect(deletedCookies).toContain(DEFER_COOKIE);
    } finally {
      await db
        .delete(admin.orgLimitOverrides)
        .where(eq(admin.orgLimitOverrides.organizationId, ORG));
    }
  });
});
