// src/lib/seranking/sync.test.ts — logique pure (groupByDb) + merge
// enrichment-safe contre le VRAI Postgres (section gated DB_TESTS=1).
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";

// syncSerankingForSelection → assertWithinQuota → getOrgQuotaLimits
// (quotas.ts), unstable_cache'd — le vrai next/cache jette hors d'un scope
// requête Next (pas d'incrementalCache), stub pass-through (même pattern que
// gsc/sync.test.ts). L'API SE-Ranking, elle, est injectée (DI fetchBulk/parse
// — pas de mock.module réseau).
mock.module("next/cache", () => ({
  revalidatePath: () => {},
  revalidateTag: () => {},
  updateTag: () => {},
  unstable_cache: (fn: unknown) => fn,
}));

import { eq, inArray } from "drizzle-orm";
import { assertTestDb } from "@/lib/db/assert-test-db";
import { organization } from "@/lib/db/auth-schema";
import { db } from "@/lib/db/client";
import { keywords, sites } from "@/lib/db/domain-schema";
import type { SerankingAccess } from "./balance";
import { SerankingError } from "./client";
import {
  estimateSync,
  groupByDb,
  type SelectedKw,
  type SerankingApiRow,
  syncSerankingForSelection,
} from "./sync";

const kw = (id: string, term: string, country: string): SelectedKw => ({
  id,
  term,
  country,
  sources: [],
});

const UNLIMITED: SerankingAccess = { mode: "unlimited" };

describe("groupByDb", () => {
  test("groups by SE-Ranking db code, dedupes by lowercased term", () => {
    const { groups, skippedCountries } = groupByDb([
      kw("1", "Plombier Paris", "FR"),
      kw("2", "plombier paris", "FR"), // même terme (casse) → même envoi
      kw("3", "plumber london", "GB"), // GB → base "uk"
      kw("4", "foo", "XK"), // pas de base → sauté
    ]);
    expect([...groups.keys()].sort()).toEqual(["fr", "uk"]);
    expect(groups.get("fr")?.get("plombier paris")).toEqual(["1", "2"]);
    expect(groups.get("uk")?.get("plumber london")).toEqual(["3"]);
    expect(skippedCountries).toEqual(["XK"]);
  });

  test("keeps one original-cased term per group for the API payload", () => {
    const { termOf } = groupByDb([kw("1", "Plombier Paris", "FR")]);
    expect(termOf.get("fr:plombier paris")).toBe("Plombier Paris");
  });

  test("empty input → empty groups, no skips", () => {
    const { groups, skippedCountries } = groupByDb([]);
    expect(groups.size).toBe(0);
    expect(skippedCountries).toEqual([]);
  });
});

describe("estimateSync", () => {
  test("3 kw sur 2 pays → requests 2", () => {
    const est = estimateSync([
      kw("1", "plombier paris", "FR"),
      kw("2", "electricien paris", "FR"),
      kw("3", "plumber london", "GB"),
    ]);
    expect(est.keywords).toBe(3);
    expect(est.requests).toBe(2);
    expect(est.countries).toBe(2);
    expect(est.skippedCountries).toEqual([]);
  });

  test("6000 termes même pays → requests 2 (chunk 5000)", () => {
    const rows = Array.from({ length: 6000 }, (_, i) =>
      kw(`id-${i}`, `terme unique ${i}`, "FR"),
    );
    const est = estimateSync(rows);
    expect(est.keywords).toBe(6000);
    expect(est.requests).toBe(2);
    expect(est.countries).toBe(1);
  });

  test("pays non couvert exclu du compte (keywords, requests ET countries)", () => {
    const est = estimateSync([
      kw("1", "plombier paris", "FR"),
      kw("2", "foo", "XK"), // pas de base SE-Ranking
    ]);
    expect(est.keywords).toBe(1);
    expect(est.requests).toBe(1);
    expect(est.countries).toBe(1);
    expect(est.skippedCountries).toEqual(["XK"]);
  });
});

// ---------------------------------------------------------------------------
// Section DB — merge enrichment-safe contre le vrai Postgres (seo_insight_test
// via le script npm `test`). Même pattern de guard que keywords-integrity.
// ---------------------------------------------------------------------------

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

describe.skipIf(!RUN)("syncSerankingForSelection (postgres)", () => {
  const ORG = "org-srk-test";
  const SITE = "site-srk-test";
  const IDS = [`${SITE}-plombier-paris`, `${SITE}-gsc-only`, `${SITE}-no-data`];

  async function cleanup() {
    await db.delete(keywords).where(inArray(keywords.id, IDS));
    await db.delete(sites).where(eq(sites.id, SITE));
    await db.delete(organization).where(eq(organization.id, ORG));
  }

  beforeAll(async () => {
    await assertTestDb();
    await cleanup(); // reste d'un run précédent crashé
    await db.insert(organization).values({
      id: ORG,
      name: "srk-test",
      slug: "srk-test",
      createdAt: new Date(),
      // Trial actif : une org sans trial ni subscription résout à des limites
      // ZÉRO (fail-closed) et le pré-check quota bloquerait le sync.
      trialEndsAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
    });
    await db.insert(sites).values({
      id: SITE,
      orgId: ORG,
      name: "srk",
      primaryLanguage: "fr",
      targetCountries: ["FR"],
    });
    await db.insert(keywords).values([
      {
        id: IDS[0],
        siteId: SITE,
        term: "Plombier Paris",
        lang: "fr",
        country: "FR",
        status: "idea",
        intent: ["informational"],
        primarySource: "semrush",
        sources: ["semrush"],
        metrics: {
          semrush: {
            volume: 900,
            kd: 10,
            cpc: 1,
            growthPct: null,
            updatedAt: "2026-01-01",
          },
        },
        trend: [{ month: "2026-01", volume: 900 }],
      },
      {
        id: IDS[1],
        siteId: SITE,
        term: "gsc only",
        lang: "fr",
        country: "FR",
        status: "idea",
        intent: [],
        primarySource: "gsc",
        sources: ["gsc"],
        metrics: {},
        trend: [],
      },
      {
        id: IDS[2],
        siteId: SITE,
        term: "no data",
        lang: "fr",
        country: "FR",
        status: "idea",
        intent: [],
        primarySource: "manual",
        sources: ["manual"],
        metrics: {},
        trend: [],
      },
    ]);
  });

  afterAll(async () => {
    await cleanup();
  });

  const API_ROW = (term: string): SerankingApiRow => ({
    term,
    found: true,
    metrics: {
      volume: 260,
      kd: 29,
      cpc: 3.05,
      growthPct: 50,
      competitiveDensity: 0.48,
      numberOfResults: null,
    },
    intent: ["local", "commercial"],
    trend: [
      { month: "2026-06", volume: 240 },
      { month: "2026-07", volume: 260 },
    ],
  });

  test("enrichment-safe merge: metrics/sources/intent merged, identity untouched", async () => {
    const res = await syncSerankingForSelection(
      SITE,
      ORG,
      { mode: "ids", ids: IDS },
      UNLIMITED,
      {
        fetchBulk: async () => "unused",
        parse: () => [
          API_ROW("Plombier Paris"),
          API_ROW("gsc only"),
          {
            term: "no data",
            found: false,
            metrics: null,
            intent: [],
            trend: [],
          },
        ],
      },
    );
    expect(res.enriched).toBe(2);
    expect(res.noData).toBe(1);
    expect(res.requests).toBe(1);
    expect(res.skippedCountries).toEqual([]);

    const [semrushKw] = await db
      .select()
      .from(keywords)
      .where(eq(keywords.id, IDS[0]));
    // identité intacte
    expect(semrushKw.term).toBe("Plombier Paris");
    expect(semrushKw.country).toBe("FR");
    expect(semrushKw.status).toBe("idea");
    // metrics mergées, pas écrasées
    expect(semrushKw.metrics.semrush?.volume).toBe(900);
    expect(semrushKw.metrics.seranking?.volume).toBe(260);
    expect(semrushKw.metrics.seranking?.kd).toBe(29);
    // primarySource conservée (semrush portait un volume)
    expect(semrushKw.primarySource).toBe("semrush");
    // unions
    expect([...semrushKw.sources].sort()).toEqual(["semrush", "seranking"]);
    expect([...semrushKw.intent].sort()).toEqual([
      "commercial",
      "informational",
      "local",
    ]);
    // trend global déjà rempli → intact ; par-source ajouté
    expect(semrushKw.trend).toEqual([{ month: "2026-01", volume: 900 }]);
    expect(semrushKw.trendBySource.seranking).toHaveLength(2);

    const [gscKw] = await db
      .select()
      .from(keywords)
      .where(eq(keywords.id, IDS[1]));
    // aucune métrique avant → primarySource bascule + trend global rempli
    expect(gscKw.primarySource).toBe("seranking");
    expect(gscKw.trend).toHaveLength(2);
    expect([...gscKw.sources].sort()).toEqual(["gsc", "seranking"]);

    const [noDataKw] = await db
      .select()
      .from(keywords)
      .where(eq(keywords.id, IDS[2]));
    expect(noDataKw.metrics).toEqual({});
    expect(noDataKw.sources).toEqual(["manual"]);
    expect(noDataKw.primarySource).toBe("manual");

    // Rail de statut par-ligne (même rail que Mangools, clé `seranking`) : les
    // enrichis passent `done`, le no-data `error:not_found`. La transition
    // `syncing` initiale est écrasée par la transition finale (sync synchrone).
    expect(semrushKw.enrichment?.seranking?.status).toBe("done");
    expect(gscKw.enrichment?.seranking?.status).toBe("done");
    expect(noDataKw.enrichment?.seranking?.status).toBe("error");
    expect(noDataKw.enrichment?.seranking?.error).toBe("not_found");
    // Rail provider-agnostique : aucune clé mangools posée par un sync SE-Ranking.
    expect(semrushKw.enrichment?.mangools).toBeUndefined();
  });

  test("re-sync is idempotent on quota (source already present)", async () => {
    const res = await syncSerankingForSelection(
      SITE,
      ORG,
      { mode: "ids", ids: [IDS[0]] },
      UNLIMITED,
      {
        fetchBulk: async () => "unused",
        parse: () => [API_ROW("Plombier Paris")],
      },
    );
    expect(res.enriched).toBe(1);
    const [row] = await db
      .select()
      .from(keywords)
      .where(eq(keywords.id, IDS[0]));
    expect(row.sources.filter((s) => s === "seranking")).toHaveLength(1);
  });

  test("metered: consume avant chaque chunk — épuisement mid-sync stoppe net, writes déjà commités conservés", async () => {
    // 2 groupes distincts (FR + GB, deux bases) → 2 requêtes API. consume()
    // autorise le 1er chunk, refuse le 2e (solde de 1).
    const METERED_IDS = [`${SITE}-metered-fr`, `${SITE}-metered-gb`];
    await db.insert(keywords).values([
      {
        id: METERED_IDS[0],
        siteId: SITE,
        term: "metered fr",
        lang: "fr",
        country: "FR",
        status: "idea",
        intent: [],
        primarySource: "manual",
        sources: ["manual"],
        metrics: {},
        trend: [],
      },
      {
        id: METERED_IDS[1],
        siteId: SITE,
        term: "metered gb",
        lang: "en",
        country: "GB",
        status: "idea",
        intent: [],
        primarySource: "manual",
        sources: ["manual"],
        metrics: {},
        trend: [],
      },
    ]);
    try {
      const consumeCalls: string[] = [];
      let calls = 0;
      const consume = async (orgId: string) => {
        consumeCalls.push(orgId);
        calls++;
        return calls === 1;
      };
      // Groupe traité en premier = ordre du SELECT (non garanti) : le fetch
      // renvoie le CODE base pour que `parse` réponde toujours au bon terme,
      // quel que soit l'ordre — le test reste robuste à l'ordre d'itération.
      const err = await syncSerankingForSelection(
        SITE,
        ORG,
        { mode: "ids", ids: METERED_IDS },
        { mode: "metered", balance: 1 },
        {
          fetchBulk: async ({ source }) => source,
          parse: (raw) =>
            raw === "fr" ? [API_ROW("metered fr")] : [API_ROW("metered gb")],
          consume,
        },
      ).catch((e) => e);
      expect(consumeCalls).toEqual([ORG, ORG]);
      expect(err).toBeInstanceOf(SerankingError);
      expect((err as SerankingError).kind).toBe("no-syncs");
      // 1 requête a réussi avant l'épuisement (message = compte partiel).
      expect((err as SerankingError).message).toBe(
        "1 enrichi(s) avant épuisement du solde",
      );
      const rows = await db
        .select()
        .from(keywords)
        .where(inArray(keywords.id, METERED_IDS));
      // le chunk réussi a bien écrit en DB avant le throw du 2e — un seul des
      // deux a reçu ses métriques SE-Ranking.
      const enrichedRows = rows.filter(
        (r) => r.metrics.seranking?.volume === 260,
      );
      expect(enrichedRows).toHaveLength(1);
    } finally {
      await db.delete(keywords).where(inArray(keywords.id, METERED_IDS));
    }
  });

  test("chunk en erreur (fetchBulk throw au 2e chunk) → chunk 1 done, chunk 2 error:internal, rien en syncing (F3)", async () => {
    // 2 bases distinctes (FR + GB) → 2 requêtes API. Le 1er chunk réussit, le
    // 2e throw : le rail par-ligne doit repasser ses ids en `error:internal`
    // AVANT de propager (jamais laissés `syncing` — spinner infini sinon).
    const F3_IDS = [`${SITE}-f3-fr`, `${SITE}-f3-gb`];
    await db.insert(keywords).values([
      {
        id: F3_IDS[0],
        siteId: SITE,
        term: "f3 fr",
        lang: "fr",
        country: "FR",
        status: "idea",
        intent: [],
        primarySource: "manual",
        sources: ["manual"],
        metrics: {},
        trend: [],
      },
      {
        id: F3_IDS[1],
        siteId: SITE,
        term: "f3 gb",
        lang: "en",
        country: "GB",
        status: "idea",
        intent: [],
        primarySource: "manual",
        sources: ["manual"],
        metrics: {},
        trend: [],
      },
    ]);
    try {
      let calls = 0;
      const err = await syncSerankingForSelection(
        SITE,
        ORG,
        { mode: "ids", ids: F3_IDS },
        UNLIMITED,
        {
          // 1er chunk OK (renvoie le code base pour que `parse` réponde au bon
          // terme quel que soit l'ordre d'itération) ; 2e chunk → throw.
          fetchBulk: async ({ source }) => {
            calls++;
            if (calls === 2) throw new Error("boom fetch");
            return source;
          },
          parse: (raw) =>
            raw === "fr" ? [API_ROW("f3 fr")] : [API_ROW("f3 gb")],
        },
      ).catch((e) => e);
      expect(err).toBeInstanceOf(Error);

      const rows = await db
        .select()
        .from(keywords)
        .where(inArray(keywords.id, F3_IDS));
      // Un chunk done, un chunk error:internal, AUCUN syncing. L'ordre du SELECT
      // n'est pas garanti → on discrimine par statut, jamais par id.
      const statuses = rows.map((r) => r.enrichment?.seranking?.status);
      expect(statuses.filter((s) => s === "done")).toHaveLength(1);
      expect(statuses.filter((s) => s === "error")).toHaveLength(1);
      expect(statuses.filter((s) => s === "syncing")).toHaveLength(0);

      const errored = rows.find(
        (r) => r.enrichment?.seranking?.status === "error",
      );
      expect(errored?.enrichment?.seranking?.error).toBe("internal");
      // Le chunk erroré n'a rien écrit ; le chunk done a bien reçu ses métriques.
      expect(errored?.metrics.seranking).toBeUndefined();
      const done = rows.find((r) => r.enrichment?.seranking?.status === "done");
      expect(done?.metrics.seranking?.volume).toBe(260);
    } finally {
      await db.delete(keywords).where(inArray(keywords.id, F3_IDS));
    }
  });

  test("byok: fetchBulk reçoit la clé et le limiteur de l'org", async () => {
    const seen: { apiKey?: string; hasLimiter: boolean }[] = [];
    await syncSerankingForSelection(
      SITE,
      ORG,
      { mode: "ids", ids: [IDS[0]] },
      { mode: "byok", apiKey: "org-byok-key" },
      {
        fetchBulk: async ({ apiKey, limiter }) => {
          seen.push({ apiKey, hasLimiter: limiter != null });
          return "unused";
        },
        parse: () => [API_ROW("Plombier Paris")],
      },
    );
    expect(seen).toHaveLength(1);
    expect(seen[0].apiKey).toBe("org-byok-key");
    expect(seen[0].hasLimiter).toBe(true);
  });
});
