// test/keyword-enrich.test.ts — job keyword-enrich : garde anti-doublon
// (POST /jobs) + dispatch handler (client Mangools + résolution location
// injectés par DI, jamais mock.module — cf. mémoire projet sur la fuite globale
// de mock.module entre fichiers de test). DB de test réelle : les statuts
// d'enrichissement, les métriques mergées et les totals sont assertés en base.
import { beforeAll, expect, test } from "bun:test";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { eq, inArray } from "drizzle-orm";
import {
  getConnectorStatus,
  getConnectorToken,
  markConnectorInvalid,
  setConnector,
} from "@/lib/connectors/store";
import { analyticsJobs, keywords } from "@/lib/db/domain-schema";
import { setEnrichStatus } from "@/lib/keywords/enrich-status";
import { MangoolsApiError, type MangoolsClient } from "@/lib/mangools/client";
import type { MangoolsKeywordItem } from "@/lib/mangools/types";
import { db } from "../src/db";
import { app } from "../src/index";
import {
  type KeywordEnrichDeps,
  processKeywordEnrich,
} from "../src/keyword-enrich";
import type { Job } from "../src/queue";
import {
  ensureTestSite,
  TEST_ORG as ORG,
  TEST_SITE as SITE,
  TEST_USER as USER,
} from "./fixtures/site";

// A real key exported in the developer's shell would turn these unauthenticated
// requests into 401s — the auth path has its own suite (auth.test.ts).
delete process.env.ANALYTICS_API_KEY;

beforeAll(ensureTestSite);

// Template `msv` réel (24+ mois) tiré de la fixture keyword-imports : donne un
// trend/growth non trivial aux items synthétisés par le faux client.
const FIXTURE = JSON.parse(
  readFileSync(
    new URL(
      "../../../src/lib/mangools/fixtures/fixture-keyword-imports.json",
      import.meta.url,
    ),
    "utf8",
  ),
) as { keywords: MangoolsKeywordItem[] };
const TEMPLATE_MSV = FIXTURE.keywords[0]!.msv;

/** Item Mangools synthétisé pour un terme donné (métriques + msv non nuls). */
function itemFor(term: string, locationId: number): MangoolsKeywordItem {
  return {
    _id: `id-${term}`,
    kw: term,
    lid: locationId,
    country: null,
    sv: 1000,
    seo: 42,
    cpc: 0.5,
    ppc: 100,
    msv: TEMPLATE_MSV,
  };
}

/** Faux client Mangools : seul `keywordImports` est piloté par le test ; les
 *  autres méthodes ne sont jamais atteintes (resolveLocation est injecté). */
function fakeClient(
  keywordImports: MangoolsClient["keywordImports"],
): MangoolsClient {
  const unused = () => {
    throw new Error("not used in keyword-enrich");
  };
  return {
    keywordImports,
    relatedKeywords: unused as unknown as MangoolsClient["relatedKeywords"],
    serps: unused as unknown as MangoolsClient["serps"],
    searchLocations: unused as unknown as MangoolsClient["searchLocations"],
  };
}

/** deps par défaut du test : token présent, client injecté, FR → 2250, ZZ →
 *  null (pays irrésolu). `now` fige l'horodatage pour des assertions stables. */
function baseDeps(
  client: MangoolsClient,
  over: Partial<KeywordEnrichDeps> = {},
): KeywordEnrichDeps {
  return {
    getToken: async () => "fake-token",
    markInvalid: async () => {},
    createClient: () => client,
    resolveLocation: async (country) => (country === "ZZ" ? null : 2250),
    setStatus: setEnrichStatus,
    now: () => "2026-07-25T00:00:00.000Z",
    ...over,
  };
}

async function insertKeyword(id: string, country: string): Promise<void> {
  await db.insert(keywords).values({
    id,
    siteId: SITE,
    term: id, // le terme = l'id → match trivial avec itemFor
    lang: "en",
    country,
    status: "idea",
    intent: [],
    primarySource: "manual",
    sources: ["manual"],
    metrics: {},
    trend: [],
  });
}

async function insertJob(id: string, keywordIds: string[]): Promise<Job> {
  await db.insert(analyticsJobs).values({
    id,
    siteId: SITE,
    type: "keyword-enrich",
    payload: { provider: "mangools", keywordIds },
    status: "running",
  });
  return (
    await db.select().from(analyticsJobs).where(eq(analyticsJobs.id, id))
  )[0] as Job;
}

async function cleanup(jobId: string, keywordIds: string[]): Promise<void> {
  await db.delete(analyticsJobs).where(eq(analyticsJobs.id, jobId));
  if (keywordIds.length)
    await db.delete(keywords).where(inArray(keywords.id, keywordIds));
}

/** Lit le statut d'enrichissement mangools d'un lot de mots-clés. */
async function enrichStatuses(
  ids: string[],
): Promise<Record<string, { status: string; error?: string }>> {
  const rows = await db
    .select({ id: keywords.id, enrichment: keywords.enrichment })
    .from(keywords)
    .where(inArray(keywords.id, ids));
  const out: Record<string, { status: string; error?: string }> = {};
  for (const r of rows) {
    const s = r.enrichment?.mangools;
    if (s) out[r.id] = { status: s.status, error: s.error };
  }
  return out;
}

// ── Garde anti-doublon (POST /jobs) ─────────────────────────────────────────
test("POST /jobs accepte un keyword-enrich, puis 409 tant qu'actif (même site+provider)", async () => {
  const headers = { "content-type": "application/json" };
  const body = JSON.stringify({
    type: "keyword-enrich",
    siteId: SITE,
    payload: { provider: "mangools", keywordIds: ["kw-a", "kw-b"] },
  });

  const res = await app.request("/jobs", { method: "POST", headers, body });
  expect(res.status).toBe(200);
  const { id } = (await res.json()) as { id: string };
  const row = (
    await db.select().from(analyticsJobs).where(eq(analyticsJobs.id, id))
  )[0];
  expect(row.type).toBe("keyword-enrich");
  expect(row.status).toBe("queued");

  // Second job même site+provider tant que le premier est actif → 409.
  const dup = await app.request("/jobs", { method: "POST", headers, body });
  expect(dup.status).toBe(409);

  await db.delete(analyticsJobs).where(eq(analyticsJobs.id, id));
});

test("POST /jobs rejette un keyword-enrich sans keywordIds (min 1)", async () => {
  const res = await app.request("/jobs", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      type: "keyword-enrich",
      siteId: SITE,
      payload: { provider: "mangools", keywordIds: [] },
    }),
  });
  expect(res.status).toBe(400);
});

// ── Happy path : 60 kws → 2 batches ─────────────────────────────────────────
test("processKeywordEnrich: 60 kws en 2 batches → done + metrics mergées + totals progressifs", async () => {
  const ids = Array.from({ length: 60 }, (_, i) => `kw-enrich-happy-${i}`);
  for (const id of ids) await insertKeyword(id, "FR");
  const jobId = randomUUID();
  const job = await insertJob(jobId, ids);

  // Snapshot des totals du job APRÈS chaque batch : capturé au début de l'appel
  // keywordImports suivant (le 2e appel voit les totals du batch 1 déjà commités).
  const totalsAtCall: Array<Job["totals"]> = [];
  let call = 0;
  const client = fakeClient(async (kws, locationId) => {
    call += 1;
    const [row] = await db
      .select({ totals: analyticsJobs.totals })
      .from(analyticsJobs)
      .where(eq(analyticsJobs.id, jobId));
    totalsAtCall.push(row?.totals ?? null);
    return kws.map((k) => itemFor(k, locationId));
  });

  await processKeywordEnrich(job, baseDeps(client));

  // 2 appels keyword-imports (50 + 10).
  expect(call).toBe(2);
  // Au 2e appel, le batch 1 (50) est déjà comptabilisé.
  expect((totalsAtCall[1] as { processed?: number })?.processed).toBe(50);

  // Tous done.
  const statuses = await enrichStatuses(ids);
  expect(Object.values(statuses).every((s) => s.status === "done")).toBe(true);
  expect(Object.keys(statuses).length).toBe(60);

  // Métriques mangools mergées + bascule primarySource (manual sans volume).
  const [sample] = await db
    .select({
      metrics: keywords.metrics,
      sources: keywords.sources,
      primarySource: keywords.primarySource,
      trendBySource: keywords.trendBySource,
    })
    .from(keywords)
    .where(eq(keywords.id, ids[0]!));
  expect(sample.metrics.mangools?.volume).toBe(1000);
  expect(sample.metrics.mangools?.kd).toBe(42);
  expect(sample.sources).toContain("mangools");
  expect(sample.primarySource).toBe("mangools");
  expect(sample.trendBySource.mangools?.length).toBeGreaterThan(0);

  // Job terminé, totals finaux.
  const done = (
    await db.select().from(analyticsJobs).where(eq(analyticsJobs.id, jobId))
  )[0];
  expect(done.status).toBe("done");
  expect(done.totals).toEqual({ processed: 60, total: 60, errors: 0 });

  await cleanup(jobId, ids);
});

// ── not_found : kw absent de la réponse ─────────────────────────────────────
test('processKeywordEnrich: kw absent de la réponse → error:"not_found"', async () => {
  const ids = ["kw-enrich-nf-0", "kw-enrich-nf-1", "kw-enrich-nf-2"];
  for (const id of ids) await insertKeyword(id, "FR");
  const jobId = randomUUID();
  const job = await insertJob(jobId, ids);

  // La réponse omet le dernier terme.
  const client = fakeClient(async (kws, locationId) =>
    kws.filter((k) => k !== ids[2]).map((k) => itemFor(k, locationId)),
  );
  await processKeywordEnrich(job, baseDeps(client));

  const statuses = await enrichStatuses(ids);
  expect(statuses[ids[0]!]!.status).toBe("done");
  expect(statuses[ids[1]!]!.status).toBe("done");
  expect(statuses[ids[2]!]).toEqual({ status: "error", error: "not_found" });

  const done = (
    await db.select().from(analyticsJobs).where(eq(analyticsJobs.id, jobId))
  )[0];
  expect(done.status).toBe("done");
  expect(done.totals).toEqual({ processed: 3, total: 3, errors: 1 });

  await cleanup(jobId, ids);
});

// ── quota au 2e batch : batch 1 done, restants quota, job COMPLETED ──────────
test('processKeywordEnrich: quota au 2e batch → batch 1 done, restants error:"quota", job completed', async () => {
  const ids = Array.from({ length: 60 }, (_, i) => `kw-enrich-quota-${i}`);
  for (const id of ids) await insertKeyword(id, "FR");
  const jobId = randomUUID();
  const job = await insertJob(jobId, ids);

  let call = 0;
  const client = fakeClient(async (kws, locationId) => {
    call += 1;
    if (call === 2) throw new MangoolsApiError("quota_exceeded", 402);
    return kws.map((k) => itemFor(k, locationId));
  });
  await processKeywordEnrich(job, baseDeps(client));

  const statuses = await enrichStatuses(ids);
  const done = Object.values(statuses).filter((s) => s.status === "done");
  const quota = Object.values(statuses).filter((s) => s.error === "quota");
  expect(done.length).toBe(50);
  expect(quota.length).toBe(10);

  // completeJob, PAS failJob — le quota client n'est pas une panne.
  const job2 = (
    await db.select().from(analyticsJobs).where(eq(analyticsJobs.id, jobId))
  )[0];
  expect(job2.status).toBe("done");
  expect(job2.totals).toEqual({ processed: 60, total: 60, errors: 10 });

  await cleanup(jobId, ids);
});

// ── unauthorized : connecteur invalidé + error:"token" + completed ──────────
test('processKeywordEnrich: unauthorized → connecteur invalid + error:"token" + completed', async () => {
  // Vrai connecteur en base (exerce le chiffrement crypto → BETTER_AUTH_SECRET).
  await setConnector(ORG, "mangools", "byok-secret", USER);

  const ids = ["kw-enrich-401-0", "kw-enrich-401-1"];
  for (const id of ids) await insertKeyword(id, "FR");
  const jobId = randomUUID();
  const job = await insertJob(jobId, ids);

  const client = fakeClient(async () => {
    throw new MangoolsApiError("unauthorized", 401);
  });
  // getToken/markInvalid RÉELS (store) — seuls createClient + resolveLocation
  // sont injectés — pour vérifier l'invalidation effective en base.
  await processKeywordEnrich(job, {
    getToken: getConnectorToken,
    markInvalid: markConnectorInvalid,
    createClient: () => client,
    resolveLocation: async () => 2250,
    setStatus: setEnrichStatus,
    now: () => "2026-07-25T00:00:00.000Z",
  });

  // Connecteur basculé invalid.
  const status = await getConnectorStatus(ORG, "mangools");
  expect(status.status).toBe("invalid");

  // Tous les kws en error:"token".
  const statuses = await enrichStatuses(ids);
  expect(statuses[ids[0]!]).toEqual({ status: "error", error: "token" });
  expect(statuses[ids[1]!]).toEqual({ status: "error", error: "token" });

  const done = (
    await db.select().from(analyticsJobs).where(eq(analyticsJobs.id, jobId))
  )[0];
  expect(done.status).toBe("done");
  expect(done.totals).toEqual({ processed: 2, total: 2, errors: 2 });

  await cleanup(jobId, ids);
});

// ── pays irrésolu : error:"no_location" pour ce groupe seulement ────────────
test('processKeywordEnrich: pays irrésolu → error:"no_location" pour ce groupe seulement', async () => {
  const frIds = ["kw-enrich-loc-fr-0", "kw-enrich-loc-fr-1"];
  const zzIds = ["kw-enrich-loc-zz-0", "kw-enrich-loc-zz-1"];
  for (const id of frIds) await insertKeyword(id, "FR");
  for (const id of zzIds) await insertKeyword(id, "ZZ");
  const ids = [...frIds, ...zzIds];
  const jobId = randomUUID();
  const job = await insertJob(jobId, ids);

  const client = fakeClient(async (kws, locationId) =>
    kws.map((k) => itemFor(k, locationId)),
  );
  await processKeywordEnrich(job, baseDeps(client));

  const statuses = await enrichStatuses(ids);
  expect(statuses[frIds[0]!]!.status).toBe("done");
  expect(statuses[frIds[1]!]!.status).toBe("done");
  expect(statuses[zzIds[0]!]).toEqual({
    status: "error",
    error: "no_location",
  });
  expect(statuses[zzIds[1]!]).toEqual({
    status: "error",
    error: "no_location",
  });

  const done = (
    await db.select().from(analyticsJobs).where(eq(analyticsJobs.id, jobId))
  )[0];
  expect(done.status).toBe("done");
  expect(done.totals).toEqual({ processed: 4, total: 4, errors: 2 });

  await cleanup(jobId, ids);
});

// ── pas de connecteur : tous error:"token", job completed ───────────────────
test('processKeywordEnrich: pas de connecteur → tous error:"token", job completed', async () => {
  const ids = ["kw-enrich-notok-0", "kw-enrich-notok-1"];
  for (const id of ids) await insertKeyword(id, "FR");
  const jobId = randomUUID();
  const job = await insertJob(jobId, ids);

  let clientBuilt = false;
  const client = fakeClient(async () => {
    clientBuilt = true;
    return [];
  });
  await processKeywordEnrich(
    job,
    baseDeps(client, { getToken: async () => null }),
  );

  // Aucun appel API quand il n'y a pas de token.
  expect(clientBuilt).toBe(false);

  const statuses = await enrichStatuses(ids);
  expect(statuses[ids[0]!]).toEqual({ status: "error", error: "token" });
  expect(statuses[ids[1]!]).toEqual({ status: "error", error: "token" });

  const done = (
    await db.select().from(analyticsJobs).where(eq(analyticsJobs.id, jobId))
  )[0];
  expect(done.status).toBe("done");
  expect(done.totals).toEqual({ processed: 2, total: 2, errors: 2 });

  await cleanup(jobId, ids);
});

// ── erreur non-taxonomisée au 2e batch : failJob + aucun kw orphelin ─────────
test('processKeywordEnrich: erreur non-taxonomisée au 2e batch → job failed, batch 1 done, restants error:"internal" (aucun queued|syncing)', async () => {
  const ids = Array.from({ length: 60 }, (_, i) => `kw-enrich-fail-${i}`);
  for (const id of ids) await insertKeyword(id, "FR");
  const jobId = randomUUID();
  const job = await insertJob(jobId, ids);

  // Le 2e batch throw une erreur NON taxonomisée (ni quota ni unauthorized) →
  // rethrow inner → catch externe → cleanup + failJob.
  let call = 0;
  const client = fakeClient(async (kws, locationId) => {
    call += 1;
    if (call === 2) throw new Error("db connection reset");
    return kws.map((k) => itemFor(k, locationId));
  });
  await processKeywordEnrich(job, baseDeps(client));

  const statuses = await enrichStatuses(ids);
  const doneKws = Object.values(statuses).filter((s) => s.status === "done");
  const internal = Object.values(statuses).filter(
    (s) => s.error === "internal",
  );
  // Batch 1 (50) done, batch 2 (10) basculé error:"internal" par le cleanup.
  expect(doneKws.length).toBe(50);
  expect(internal.length).toBe(10);
  // AUCUN kw ne reste bloqué en queued|syncing (garde anti spinner infini).
  const stuck = Object.values(statuses).filter(
    (s) => s.status === "queued" || s.status === "syncing",
  );
  expect(stuck.length).toBe(0);
  expect(Object.keys(statuses).length).toBe(60);

  // Job en échec (retry) — PAS completed.
  const jobRow = (
    await db.select().from(analyticsJobs).where(eq(analyticsJobs.id, jobId))
  )[0];
  expect(jobRow.status).toBe("failed");

  await cleanup(jobId, ids);
});

// ── match robuste : écho Mangools NFD / espace parasite → matché, pas de
//    not_found fantôme sur les termes accentués ────────────────────────────
test("processKeywordEnrich: écho Mangools en NFD ou avec espace parasite → matché (pas de not_found)", async () => {
  // Terme stocké en NFC ; l'API le renverra en NFD. Autre terme : écho espacé.
  const accentId = "kw-enrich-nfc-accent";
  const spaceId = "kw-enrich-space";
  const accentTerm = "référencement café".normalize("NFC");
  const spaceTerm = "seo audit";
  await db.insert(keywords).values({
    id: accentId,
    siteId: SITE,
    term: accentTerm,
    lang: "fr",
    country: "FR",
    status: "idea",
    intent: [],
    primarySource: "manual",
    sources: ["manual"],
    metrics: {},
    trend: [],
  });
  await db.insert(keywords).values({
    id: spaceId,
    siteId: SITE,
    term: spaceTerm,
    lang: "en",
    country: "FR",
    status: "idea",
    intent: [],
    primarySource: "manual",
    sources: ["manual"],
    metrics: {},
    trend: [],
  });
  const ids = [accentId, spaceId];
  const jobId = randomUUID();
  const job = await insertJob(jobId, ids);

  // Écho Mangools bruité : NFD pour l'accentué, espaces autour pour l'autre.
  const echo: Record<string, string> = {
    [accentTerm]: accentTerm.normalize("NFD"),
    [spaceTerm]: `  ${spaceTerm} `,
  };
  const client = fakeClient(async (kws, locationId) =>
    kws.map((k) => itemFor(echo[k] ?? k, locationId)),
  );
  await processKeywordEnrich(job, baseDeps(client));

  // Les deux matchés malgré l'écho bruité → done, aucun not_found fantôme.
  const statuses = await enrichStatuses(ids);
  expect(statuses[accentId]!.status).toBe("done");
  expect(statuses[spaceId]!.status).toBe("done");

  const jobRow = (
    await db.select().from(analyticsJobs).where(eq(analyticsJobs.id, jobId))
  )[0];
  expect(jobRow.status).toBe("done");
  expect(jobRow.totals).toEqual({ processed: 2, total: 2, errors: 0 });

  await cleanup(jobId, ids);
});
