// src/lib/mangools/related-core.test.ts — noyau du dialog « Mots-clés associés »
// (Task 11). Intégration gated DB_TESTS=1 (*_test DB uniquement) : la lecture du
// site (orgId + pays) passe par la VRAIE DB de test ; le reste (token,
// résolution location, client Mangools) est injecté par DI — zéro réseau,
// `mock.module` proscrit pour la DI (mémoire projet). La garde tenancy
// (`assertSiteInActiveOrg`) est stubbée (surface complète du module). On
// vérifie : happy path (rows mappées dans l'ordre API, null préservé), cap 300,
// no_connector (token absent), no_location (résolution nulle OU pays absent),
// quota (quota_exceeded), unauthorized (→ connecteur invalidé + no_connector).
import { beforeEach, describe, expect, mock, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";

mock.module("server-only", () => ({}));
mock.module("next/cache", () => ({
  revalidatePath: () => {},
  revalidateTag: () => {},
  updateTag: () => {},
  unstable_cache: (fn: unknown) => fn,
}));
// Tenancy stubbée : le fail-closed est prouvé ailleurs (assert-site), ici on
// isole le flux Mangools. Surface COMPLÈTE (bun partage les mocks entre
// fichiers d'un même process).
mock.module("@/lib/sites/assert-site", () => ({
  siteBelongsToOrg: async () => true,
  assertSiteInActiveOrg: async () => {},
  assertKeywordInActiveOrg: async () => "mock-site",
  assertContentItemInActiveOrg: async () => "mock-site",
}));

// MangoolsApiError ré-importé pour de vrai : quota/unauthorized lèvent la vraie
// classe pour que le `instanceof` du module la reconnaisse.
const { MangoolsApiError } = await import("./client");
const { getRelatedKeywords } = await import("./related-core");

import type { MangoolsRelatedResponse } from "./types";

function loadFixture(name: string): MangoolsRelatedResponse {
  return JSON.parse(
    readFileSync(join(import.meta.dir, "fixtures", name), "utf8"),
  ) as MangoolsRelatedResponse;
}

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

describe.skipIf(!RUN_DB)("getRelatedKeywords (postgres)", () => {
  let db: typeof import("@/lib/db/client").db;
  let schema: typeof import("@/lib/db/domain-schema");

  /** Deps par défaut : token présent, location résolue, client qui renvoie la
   *  fixture related. Chaque test peut surcharger un champ. */
  function makeDeps(
    overrides?: Partial<Parameters<typeof getRelatedKeywords>[2]>,
  ) {
    const invalidated: string[] = [];
    const deps = {
      getToken: async () => "tok-mangools",
      markInvalid: async (orgId: string) => {
        invalidated.push(orgId);
      },
      createClient: () =>
        ({
          relatedKeywords: async () => loadFixture("fixture-related.json"),
        }) as never,
      resolveLocation: async () => 2250,
      ...overrides,
    };
    return { deps, invalidated };
  }

  beforeEach(async () => {
    ({ db } = await import("@/lib/db/client"));
    schema = await import("@/lib/db/domain-schema");
    const { assertTestDb } = await import("@/lib/db/assert-test-db");
    await assertTestDb();

    await db.delete(schema.keywords);
    await db.delete(schema.sites);
    await db.insert(schema.sites).values({
      id: "site-a",
      orgId: "demo-org",
      ownerUserId: "seed-owner",
      name: "Site A",
      primaryLanguage: "fr",
      languages: [],
      targetCountries: ["FR"],
    });
  });

  test("happy path : rows mappées dans l'ordre API, null préservé", async () => {
    const { deps } = makeDeps();
    const res = await getRelatedKeywords(
      "site-a",
      "bague de fiancailles",
      deps,
    );
    expect(res.ok).toBe(true);
    if (!res.ok) return;
    // 3 mots-clés dans la fixture, ordre préservé (= pertinence).
    expect(res.rows).toHaveLength(3);
    expect(res.rows[0]).toEqual({
      term: "bague de fiançailles",
      volume: 84700,
      kd: 31,
      cpc: 1.78,
    });
    // 2e item : sv/seo/cpc null → null préservé (pas coercé en 0).
    expect(res.rows[1].volume).toBeNull();
    expect(res.rows[1].kd).toBeNull();
    expect(res.rows[1].cpc).toBeNull();
  });

  test("cap 300 : une réponse de 350 kw est tronquée à 300 (ordre préservé)", async () => {
    const big: MangoolsRelatedResponse = {
      keywords: Array.from({ length: 350 }, (_, i) => ({
        _id: `id-${i}`,
        kw: `kw ${i}`,
        lid: 0,
        country: null,
        sv: i,
        seo: null,
        cpc: null,
        ppc: null,
        msv: null,
      })),
      countKeywordsBeforeLimit: 350,
    };
    const { deps } = makeDeps({
      createClient: () => ({ relatedKeywords: async () => big }) as never,
    });
    const res = await getRelatedKeywords("site-a", "seed", deps);
    expect(res.ok).toBe(true);
    if (!res.ok) return;
    expect(res.rows).toHaveLength(300);
    expect(res.rows[0].term).toBe("kw 0");
    expect(res.rows[299].term).toBe("kw 299");
  });

  test("dédup : deux items avec le même terme → une seule row (premier gagne)", async () => {
    const dup: MangoolsRelatedResponse = {
      keywords: [
        {
          _id: "a",
          kw: "bague or",
          lid: 0,
          country: null,
          sv: 100,
          seo: 10,
          cpc: 1,
          ppc: null,
          msv: null,
        },
        {
          _id: "b",
          kw: "bague or",
          lid: 0,
          country: null,
          sv: 200,
          seo: 20,
          cpc: 2,
          ppc: null,
          msv: null,
        },
        {
          _id: "c",
          kw: "alliance",
          lid: 0,
          country: null,
          sv: 50,
          seo: 5,
          cpc: 0.5,
          ppc: null,
          msv: null,
        },
      ],
      countKeywordsBeforeLimit: 3,
    };
    const { deps } = makeDeps({
      createClient: () => ({ relatedKeywords: async () => dup }) as never,
    });
    const res = await getRelatedKeywords("site-a", "seed", deps);
    expect(res.ok).toBe(true);
    if (!res.ok) return;
    // Doublon fusionné → 2 rows (pas de collision de key React côté dialog).
    expect(res.rows).toHaveLength(2);
    expect(res.rows.map((r) => r.term)).toEqual(["bague or", "alliance"]);
    // Le premier occurrence (le plus pertinent) est conservé.
    expect(res.rows[0].volume).toBe(100);
  });

  test("no_connector : token absent", async () => {
    const { deps } = makeDeps({ getToken: async () => null });
    const res = await getRelatedKeywords("site-a", "seed", deps);
    expect(res).toEqual({ ok: false, error: "no_connector" });
  });

  test("no_location : résolution nulle", async () => {
    const { deps } = makeDeps({ resolveLocation: async () => null });
    const res = await getRelatedKeywords("site-a", "seed", deps);
    expect(res).toEqual({ ok: false, error: "no_location" });
  });

  test("no_location : aucun pays cible configuré sur le site", async () => {
    await db.delete(schema.keywords);
    await db.delete(schema.sites);
    await db.insert(schema.sites).values({
      id: "site-no-country",
      orgId: "demo-org",
      ownerUserId: "seed-owner",
      name: "No country",
      primaryLanguage: "fr",
      languages: [],
      targetCountries: [],
    });
    const { deps } = makeDeps();
    const res = await getRelatedKeywords("site-no-country", "seed", deps);
    expect(res).toEqual({ ok: false, error: "no_location" });
  });

  test("quota : quota_exceeded → error:quota", async () => {
    const { deps } = makeDeps({
      createClient: () =>
        ({
          relatedKeywords: async () => {
            throw new MangoolsApiError("quota_exceeded", 402);
          },
        }) as never,
    });
    const res = await getRelatedKeywords("site-a", "seed", deps);
    expect(res).toEqual({ ok: false, error: "quota" });
  });

  test("unauthorized : connecteur invalidé + no_connector", async () => {
    const { deps, invalidated } = makeDeps({
      createClient: () =>
        ({
          relatedKeywords: async () => {
            throw new MangoolsApiError("unauthorized", 401);
          },
        }) as never,
    });
    const res = await getRelatedKeywords("site-a", "seed", deps);
    expect(res).toEqual({ ok: false, error: "no_connector" });
    // Le connecteur de l'org du site a été marqué invalide.
    expect(invalidated).toEqual(["demo-org"]);
  });

  test("upstream : rate_limited → error:upstream", async () => {
    const { deps } = makeDeps({
      createClient: () =>
        ({
          relatedKeywords: async () => {
            throw new MangoolsApiError("rate_limited", 429);
          },
        }) as never,
    });
    const res = await getRelatedKeywords("site-a", "seed", deps);
    expect(res).toEqual({ ok: false, error: "upstream" });
  });
});
