// src/lib/backlink/planning.test.ts — integration, gated on DB_TESTS=1.
import { beforeEach, describe, expect, mock, test } from "bun:test";

// server-only throws outside Next.js; stub it for the test runner.
mock.module("server-only", () => ({}));
// actions call revalidatePath, which throws outside a Next request scope.
mock.module("next/cache", () => ({
  revalidatePath: () => {},
  revalidateTag: () => {},
  updateTag: () => {},
  unstable_cache: (fn: unknown) => fn,
}));
// the tenancy guard needs a session (headers()) — stub it; site-scoping is
// covered at the SQL level by these tests and by assert-site.test.ts.
mock.module("@/lib/sites/assert-site", () => ({
  siteBelongsToOrg: async () => true,
  assertSiteInActiveOrg: async () => {},
}));

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

describe.skipIf(!RUN_DB)("backlink planning board + cadence (postgres)", () => {
  let db: typeof import("@/lib/db/client").db;
  let schema: typeof import("@/lib/db/domain-schema");
  const ORG = "demo-org"; // guaranteed by db:seed / test DB setup
  const SITE_A = "wt-planning-a";
  const SITE_B = "wt-planning-b";

  async function insertSource(
    id: string,
    overrides: Record<string, unknown> = {},
  ) {
    await db.insert(schema.linkSources).values({
      id,
      orgId: ORG,
      platform: id,
      url: `https://${id.toLowerCase()}.com`,
      domainNorm: `${id.toLowerCase()}.com`,
      kind: "blog",
      da: 40,
      effort: "low",
      linkType: "dofollow",
      language: "en",
      ...overrides,
    } as never);
  }

  beforeEach(async () => {
    ({ db } = await import("@/lib/db/client"));
    schema = await import("@/lib/db/domain-schema");
    const { assertTestDb } = await import("@/lib/db/assert-test-db");
    const { inArray } = await import("drizzle-orm");
    await assertTestDb();
    await db.delete(schema.backlinkOutreach);
    await db.delete(schema.backlinks);
    await db.delete(schema.linkSources);
    await db.delete(schema.domainRatings);
    await db
      .delete(schema.sites)
      .where(inArray(schema.sites.id, [SITE_A, SITE_B]));
    await db.insert(schema.sites).values([
      {
        id: SITE_A,
        orgId: ORG,
        ownerUserId: "seed-owner",
        name: "WT Planning A",
        url: "https://wt-planning-a.com",
        primaryLanguage: "fr",
        languages: [],
        targetCountries: ["FR"],
      },
      {
        id: SITE_B,
        orgId: ORG,
        ownerUserId: "seed-owner",
        name: "WT Planning B",
        url: "https://wt-planning-b.com",
        primaryLanguage: "fr",
        languages: [],
        targetCountries: ["FR"],
      },
    ]);
  });

  test("board scopé au site : un backlink d'un autre site n'apparaît pas", async () => {
    await insertSource("S1", { domainNorm: "s1.com" });
    await db
      .insert(schema.domainRatings)
      .values({ domainNorm: "s1.com", dr: 90 });
    await db.insert(schema.backlinks).values([
      { id: "b1", linkSourceId: "S1", siteId: SITE_A, status: "planned" },
      { id: "b2", linkSourceId: "S1", siteId: SITE_B, status: "live" },
    ]);

    const { getPlanningBoard } = await import("./planning");
    const cards = await getPlanningBoard(ORG, SITE_A);
    expect(cards).toHaveLength(1);
    expect(cards[0]?.backlinkId).toBe("b1");
    expect(cards[0]?.status).toBe("planned");
    expect(cards[0]?.footprint).toBe(2); // les deux sites comptent
    expect(cards[0]?.score).toBe(27); // en vs fr → 0.3 × dr 90 = 27
  });

  test("board joint l'outreach", async () => {
    await insertSource("S1");
    await db
      .insert(schema.backlinks)
      .values([
        { id: "b1", linkSourceId: "S1", siteId: SITE_A, status: "planned" },
      ]);
    await db.insert(schema.backlinkOutreach).values({
      backlinkId: "b1",
      contact: "bob",
      channel: "email",
      attempts: 2,
    });

    const { getPlanningBoard } = await import("./planning");
    const card = (await getPlanningBoard(ORG, SITE_A))[0];
    expect(card?.outreach?.contact).toBe("bob");
    expect(card?.outreach?.attempts).toBe(2);
  });

  test("getCadence compte les firstSeen par mois", async () => {
    await insertSource("S1");
    await insertSource("S2");
    await insertSource("S3");
    const { monthBounds } = await import("./planning-logic");
    const { thisMonthStart, lastMonthStart } = monthBounds(new Date());
    const thisMonth = new Date(`${thisMonthStart}T12:00:00Z`);
    const lastMonth = new Date(`${lastMonthStart}T12:00:00Z`);
    await db.insert(schema.backlinks).values([
      {
        id: "b1",
        linkSourceId: "S1",
        siteId: SITE_A,
        status: "live",
        firstSeen: thisMonth,
      },
      {
        id: "b2",
        linkSourceId: "S2",
        siteId: SITE_A,
        status: "live",
        firstSeen: thisMonth,
      },
      {
        id: "b3",
        linkSourceId: "S3",
        siteId: SITE_A,
        status: "live",
        firstSeen: lastMonth,
      },
    ]);

    const { getCadence } = await import("./planning");
    const c = await getCadence(SITE_A);
    expect(c.thisMonth).toBe(2);
    expect(c.lastMonth).toBe(1);
  });

  const B1 = "b1";

  async function readB1() {
    const { eq } = await import("drizzle-orm");
    const [row] = await db
      .select()
      .from(schema.backlinks)
      .where(eq(schema.backlinks.id, B1));
    if (!row) throw new Error("b1 absent");
    return row;
  }

  test("moveBacklinkAction pose firstSeen sur live (sans l'écraser) + scope site", async () => {
    await insertSource("S1");
    await db
      .insert(schema.backlinks)
      .values([
        { id: B1, linkSourceId: "S1", siteId: SITE_A, status: "planned" },
      ]);

    const { moveBacklinkAction } = await import("./planning-actions");
    await moveBacklinkAction(SITE_A, B1, "live");
    let row = await readB1();
    expect(row.status).toBe("live");
    expect(row.firstSeen).not.toBeNull();
    const t1 = row.firstSeen?.getTime();

    await moveBacklinkAction(SITE_A, B1, "lost");
    await moveBacklinkAction(SITE_A, B1, "live");
    row = await readB1();
    expect(row.firstSeen?.getTime()).toBe(t1 as number); // pas écrasé

    const wrong = await moveBacklinkAction(SITE_B, B1, "prospect");
    expect(wrong.ok).toBe(false);
    row = await readB1();
    expect(row.status).toBe("live");
  });

  test("upsertOutreachAction crée puis met à jour partiellement ; scope site", async () => {
    await insertSource("S1");
    await db
      .insert(schema.backlinks)
      .values([
        { id: B1, linkSourceId: "S1", siteId: SITE_A, status: "planned" },
      ]);

    const { upsertOutreachAction } = await import("./planning-actions");
    await upsertOutreachAction(SITE_A, B1, { contact: "alice", attempts: 1 });
    await upsertOutreachAction(SITE_A, B1, { attempts: 2, notes: "relance" });

    const rows = await db.select().from(schema.backlinkOutreach);
    expect(rows).toHaveLength(1);
    expect(rows[0]?.contact).toBe("alice"); // conservé au patch partiel
    expect(rows[0]?.attempts).toBe(2);
    expect(rows[0]?.notes).toBe("relance");

    const wrong = await upsertOutreachAction(SITE_B, B1, {
      contact: "mallory",
    });
    expect(wrong.ok).toBe(false);
    const [after] = await db.select().from(schema.backlinkOutreach);
    expect(after?.contact).toBe("alice");
  });

  test("setBacklinkFlagsAction + fetchDisavowDomainsAction", async () => {
    await insertSource("S1", { domainNorm: "s1.com" });
    await db
      .insert(schema.backlinks)
      .values([
        { id: B1, linkSourceId: "S1", siteId: SITE_A, status: "planned" },
      ]);

    const { setBacklinkFlagsAction, fetchDisavowDomainsAction } = await import(
      "./planning-actions"
    );
    const r = await setBacklinkFlagsAction(SITE_A, B1, {
      toxic: true,
      disavow: true,
    });
    expect(r.ok).toBe(true);
    const row = await readB1();
    expect(row.toxic).toBe(true);
    expect(row.disavow).toBe(true);
    expect(await fetchDisavowDomainsAction(SITE_A)).toEqual(["s1.com"]);
    expect(await fetchDisavowDomainsAction(SITE_B)).toEqual([]); // scope
  });
});
