import { beforeEach, expect, mock, test } from "bun:test";
import { eq } from "drizzle-orm";

// syncGscForSite → assertWithinQuota → 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,
}));

import { assertTestDb } from "@/lib/db/assert-test-db";
import { organization, user } from "@/lib/db/auth-schema";
import { db } from "@/lib/db/client";
import {
  keywordExclusions,
  keywords as kw,
  sites,
} from "@/lib/db/domain-schema";
import { syncGscForSite } from "./sync";

const SITE = "gsc-ov-site";
const ORG = "gsc-ov-org";
const USR = "gsc-ov-user";

beforeEach(async () => {
  await assertTestDb();
  await db.delete(keywordExclusions).where(eq(keywordExclusions.siteId, SITE));
  await db.delete(kw).where(eq(kw.siteId, SITE));
  await db.delete(sites).where(eq(sites.id, SITE));
  await db.delete(organization).where(eq(organization.id, ORG));
  await db.delete(user).where(eq(user.id, USR));
  await db.insert(user).values({
    id: USR,
    name: "U",
    email: `gscov-${Date.now()}@example.com`,
    emailVerified: true,
  });
  await db.insert(organization).values({
    id: ORG,
    name: "O",
    slug: `gscov-${Date.now()}`,
    createdAt: new Date(),
    // Active trial: the keyword quota cap in syncGscForSite is billing-gated
    // (an expired org resolves to zero limits and would block auto-import).
    trialEndsAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
  });
  await db.insert(sites).values({
    id: SITE,
    orgId: ORG,
    ownerUserId: USR,
    name: "S",
    url: "https://example.com",
    primaryLanguage: "fr",
    targetCountries: ["FR"],
  });
  await db.insert(kw).values({
    id: "gsc-ov-k1",
    siteId: SITE,
    term: "Lustre Lab",
    lang: "fr",
    country: "FR",
    status: "active",
    primarySource: "semrush",
    sources: ["semrush"],
    metrics: {
      semrush: {
        volume: 500,
        kd: 20,
        cpc: 1,
        growthPct: null,
        updatedAt: "2026-01-01",
      },
    },
  });
});

// NB: do NOT client.end() — the postgres client is a shared singleton; closing
// it breaks sibling DB test files run in the same `bun test src/lib/gsc` pass.

test("overlays a matched keyword (promoting it into research) and creates unmatched queries ranking ≤ 20", async () => {
  const res = await syncGscForSite(SITE, [
    // matched (term equals existing keyword, case-insensitive) → overlay + research stage
    {
      query: "lustre lab",
      clicks: 3,
      impressions: 120,
      ctr: 0.025,
      position: 6.4,
    },
    // unmatched but ranking within reach (≤20) → created as a research keyword
    {
      query: "new ranking term",
      clicks: 9,
      impressions: 999,
      ctr: 0.1,
      position: 2,
    },
    // unmatched and beyond position 20 → ignored (no flood)
    {
      query: "deep long tail",
      clicks: 0,
      impressions: 5,
      ctr: 0,
      position: 47,
    },
  ]);
  expect(res).toEqual({ matched: 1, created: 1, queries: 3 });

  const all = await db.select().from(kw).where(eq(kw.siteId, SITE));
  expect(all).toHaveLength(2); // original + 1 created; the deep one is ignored

  const matched = all.find((k) => k.id === "gsc-ov-k1");
  expect(matched?.primarySource).toBe("semrush"); // untouched
  expect(matched?.sources.sort()).toEqual(["gsc", "semrush"]);
  expect(matched?.gsc?.impressions).toBe(120);
  expect(matched?.gsc?.position).toBe(6.4);
  expect(matched?.stages).toContain("research"); // promoted onto Recherche
  expect(matched?.metrics.semrush?.volume).toBe(500); // SEO metrics untouched

  const created = all.find((k) => k.id !== "gsc-ov-k1");
  expect(created?.term).toBe("new ranking term");
  expect(created?.primarySource).toBe("gsc");
  expect(created?.sources).toEqual(["gsc"]);
  expect(created?.status).toBe("active");
  expect(created?.stages).toContain("research");
  expect(created?.lang).toBe("fr"); // from the site
  expect(created?.country).toBe("FR"); // from the site
  expect(created?.gsc?.position).toBe(2);
});

test("skips operator/quoted GSC queries (no syntax-noise keywords)", async () => {
  const res = await syncGscForSite(SITE, [
    // boolean-operator monitoring query (parens + quotes + OR) → skipped
    {
      query: '(se or siemens) ("contrats de gaz" or "service des douanes")',
      clicks: 1,
      impressions: 50,
      ctr: 0.02,
      position: 3,
    },
    // quoted exact-match query → skipped
    {
      query: '"changer fournisseur électricité pro"',
      clicks: 2,
      impressions: 40,
      ctr: 0.05,
      position: 5,
    },
    // explicit uppercase boolean → skipped
    {
      query: "gaz OR électricité",
      clicks: 0,
      impressions: 9,
      ctr: 0,
      position: 8,
    },
    // a clean query at the same position → created (control)
    {
      query: "fournisseur gaz pro",
      clicks: 4,
      impressions: 70,
      ctr: 0.06,
      position: 7,
    },
  ]);
  expect(res).toEqual({ matched: 0, created: 1, queries: 4 });
  const all = await db.select().from(kw).where(eq(kw.siteId, SITE));
  // original keyword + only the clean import; the 3 syntax queries are skipped
  expect(all.map((k) => k.term).sort()).toEqual([
    "Lustre Lab",
    "fournisseur gaz pro",
  ]);
});

test("ignores unmatched queries beyond position 20 (no flood)", async () => {
  const res = await syncGscForSite(SITE, [
    { query: "far off", clicks: 0, impressions: 3, ctr: 0, position: 21 },
  ]);
  expect(res).toEqual({ matched: 0, created: 0, queries: 1 });
  const all = await db.select().from(kw).where(eq(kw.siteId, SITE));
  expect(all).toHaveLength(1); // nothing created
});

test("skips GSC queries whose slug is excluded for the site", async () => {
  await db
    .insert(keywordExclusions)
    .values({ siteId: SITE, slug: "unwanted-term" });
  const res = await syncGscForSite(SITE, [
    {
      query: "unwanted term",
      clicks: 5,
      impressions: 80,
      ctr: 0.06,
      position: 3,
    },
    {
      query: "wanted term",
      clicks: 4,
      impressions: 70,
      ctr: 0.05,
      position: 7,
    },
  ]);
  expect(res).toEqual({ matched: 0, created: 1, queries: 2 });
  const all = await db.select().from(kw).where(eq(kw.siteId, SITE));
  expect(all.some((k) => k.term === "unwanted term")).toBe(false);
  expect(all.some((k) => k.term === "wanted term")).toBe(true);
});

test("re-sync updates the overlay, no duplicate, keeps the research stage", async () => {
  await syncGscForSite(SITE, [
    { query: "lustre lab", clicks: 1, impressions: 10, ctr: 0.1, position: 9 },
  ]);
  await syncGscForSite(SITE, [
    { query: "lustre lab", clicks: 5, impressions: 80, ctr: 0.06, position: 4 },
  ]);
  const all = await db.select().from(kw).where(eq(kw.siteId, SITE));
  expect(all).toHaveLength(1);
  expect(all[0].gsc?.impressions).toBe(80);
  expect(all[0].stages).toContain("research");
});

test("extras.byCountry: overlay par pays sur les matchés seulement, merge préservant", async () => {
  const row = (position: number) => ({
    query: "lustre lab",
    clicks: 2,
    impressions: 30,
    ctr: 0.05,
    position,
  });
  // 1er sync : FR + BE posés sur le matché ; "inconnu" n'existe pas → rien posé.
  await syncGscForSite(SITE, [row(6)], {
    byCountry: {
      FR: [row(4)],
      BE: [row(9), { ...row(9), query: "inconnu" }],
    },
  });
  let all = await db.select().from(kw).where(eq(kw.siteId, SITE));
  expect(all).toHaveLength(1); // byCountry ne crée jamais de keyword
  let matched = all.find((k) => k.id === "gsc-ov-k1");
  expect(matched?.gscByCountry.FR?.position).toBe(4);
  expect(matched?.gscByCountry.BE?.position).toBe(9);

  // 2e sync FR seul : FR mis à jour, BE préservé (merge jsonb).
  await syncGscForSite(SITE, [row(5)], { byCountry: { FR: [row(3)] } });
  all = await db.select().from(kw).where(eq(kw.siteId, SITE));
  matched = all.find((k) => k.id === "gsc-ov-k1");
  expect(matched?.gscByCountry.FR?.position).toBe(3);
  expect(matched?.gscByCountry.BE?.position).toBe(9);
});

test("extras.topPageByQuery: gsc.page posé sur les matchés, null sans entrée", async () => {
  await db.insert(kw).values({
    id: "gsc-ov-k2",
    siteId: SITE,
    term: "sans page",
    lang: "fr",
    country: "FR",
    status: "active",
    primarySource: "semrush",
    sources: ["semrush"],
  });
  await syncGscForSite(
    SITE,
    [
      {
        query: "lustre lab",
        clicks: 3,
        impressions: 120,
        ctr: 0.025,
        position: 6.4,
      },
      { query: "sans page", clicks: 1, impressions: 10, ctr: 0.1, position: 8 },
    ],
    { topPageByQuery: new Map([["lustre lab", "https://example.com/p"]]) },
  );
  const all = await db.select().from(kw).where(eq(kw.siteId, SITE));
  expect(all.find((k) => k.id === "gsc-ov-k1")?.gsc?.page).toBe(
    "https://example.com/p",
  );
  expect(all.find((k) => k.id === "gsc-ov-k2")?.gsc?.page).toBe(null);

  // Re-sync SANS map (fetch pages échoué) → la page connue est préservée.
  await syncGscForSite(SITE, [
    {
      query: "lustre lab",
      clicks: 4,
      impressions: 130,
      ctr: 0.03,
      position: 6.1,
    },
  ]);
  const after = await db.select().from(kw).where(eq(kw.siteId, SITE));
  expect(after.find((k) => k.id === "gsc-ov-k1")?.gsc?.page).toBe(
    "https://example.com/p",
  );
});

test("batch: overlays MANY matched keywords in one sync (UPDATE FROM VALUES)", async () => {
  // Seed several extra keywords so the matched pass produces a multi-row batch.
  await db.insert(kw).values(
    ["alpha", "bravo", "charlie", "delta"].map((term, i) => ({
      id: `gsc-batch-k${i}`,
      siteId: SITE,
      term,
      lang: "fr",
      country: "FR",
      status: i === 0 ? ("idea" as const) : ("active" as const),
      primarySource: "semrush" as const,
      sources: ["semrush" as const],
    })),
  );
  const mk = (query: string, position: number) => ({
    query,
    clicks: 1,
    impressions: 10 + position,
    ctr: 0.05,
    position,
  });
  const res = await syncGscForSite(
    SITE,
    [mk("alpha", 3), mk("bravo", 5), mk("charlie", 7), mk("delta", 9)],
    {
      byCountry: {
        FR: [mk("alpha", 2), mk("bravo", 4)],
        BE: [mk("alpha", 1)],
      },
    },
  );
  expect(res.matched).toBe(4);

  const all = await db.select().from(kw).where(eq(kw.siteId, SITE));
  const byId = new Map(all.map((k) => [k.id, k]));
  // Each matched keyword got its gsc overlay + research stage + gsc source union.
  for (let i = 0; i < 4; i++) {
    const k = byId.get(`gsc-batch-k${i}`);
    expect(k?.gsc?.position).toBe([3, 5, 7, 9][i]);
    expect(k?.stages).toContain("research");
    expect(k?.sources.sort()).toEqual(["gsc", "semrush"]);
  }
  // status idea→active bridge on the first one; others keep deliberate "active".
  expect(byId.get("gsc-batch-k0")?.status).toBe("active");
  // Per-country merge batched: alpha has FR+BE, bravo has FR only.
  expect(byId.get("gsc-batch-k0")?.gscByCountry.FR?.position).toBe(2);
  expect(byId.get("gsc-batch-k0")?.gscByCountry.BE?.position).toBe(1);
  expect(byId.get("gsc-batch-k1")?.gscByCountry.FR?.position).toBe(4);
  expect(byId.get("gsc-batch-k1")?.gscByCountry.BE).toBeUndefined();
});

test("sans extras : comportement identique, gsc_by_country reste {}", async () => {
  await syncGscForSite(SITE, [
    {
      query: "lustre lab",
      clicks: 3,
      impressions: 120,
      ctr: 0.025,
      position: 6.4,
    },
  ]);
  const all = await db.select().from(kw).where(eq(kw.siteId, SITE));
  const matched = all.find((k) => k.id === "gsc-ov-k1");
  expect(matched?.gsc?.position).toBe(6.4);
  expect(matched?.gscByCountry).toEqual({});
});

test("drops a query ranking only in a non-configured market (langue mix guard)", async () => {
  // SITE is configured fr-only; the Vietnam-only query must NOT become a keyword.
  const res = await syncGscForSite(
    SITE,
    [
      {
        query: "certificat ssl",
        clicks: 0,
        impressions: 18,
        ctr: 0,
        position: 5,
      },
      { query: "foreign term", clicks: 0, impressions: 9, ctr: 0, position: 3 },
    ],
    {
      queryCountries: new Map([
        ["certificat ssl", [{ country: "FR", impressions: 18 }]],
        ["foreign term", [{ country: "VN", impressions: 9 }]],
      ]),
    },
  );
  expect(res).toEqual({ matched: 0, created: 1, queries: 2 });
  const all = await db.select().from(kw).where(eq(kw.siteId, SITE));
  expect(all.some((k) => k.term === "certificat ssl")).toBe(true);
  expect(all.some((k) => k.term === "foreign term")).toBe(false); // dropped
  expect(all.find((k) => k.term === "certificat ssl")?.lang).toBe("fr");
});

test("creates a query under its attributed language (URL locale) on a multilingual site", async () => {
  await db
    .update(sites)
    .set({ languages: ["en"] })
    .where(eq(sites.id, SITE));
  const res = await syncGscForSite(
    SITE,
    [
      {
        query: "ssl certificate guide",
        clicks: 0,
        impressions: 20,
        ctr: 0,
        position: 4,
      },
    ],
    {
      topPageByQuery: new Map([
        ["ssl certificate guide", "https://example.com/en/ssl"],
      ]),
    },
  );
  expect(res.created).toBe(1);
  const all = await db.select().from(kw).where(eq(kw.siteId, SITE));
  // attributed to "en" from the /en/ page segment — not the primary "fr"
  expect(all.find((k) => k.term === "ssl certificate guide")?.lang).toBe("en");
});
