import { expect, mock, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { MangoolsApiError as MangoolsApiErrorType } from "./client";
import { createThrottle } from "./throttle";

// client.ts importe "server-only" (throw hors Next) — stub AVANT l'import
// dynamique du client, convention repo (cf. keywords/themes.test.ts).
mock.module("server-only", () => ({}));
const { createMangoolsClient, MangoolsApiError } = await import("./client");

const TOKEN = "test-secret-token-should-never-leak";

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

function jsonResponse(
  body: unknown,
  status = 200,
  headers?: Record<string, string>,
): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { "content-type": "application/json", ...headers },
  });
}

/** Cast d'un impl allégé vers `typeof fetch` (Bun exige `preconnect`). */
function asFetch(
  impl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
): typeof fetch {
  return impl as unknown as typeof fetch;
}

/** Throttle instantané : sleep fake, horloge fake — aucun vrai délai. */
function fastThrottle() {
  let t = 0;
  const sleeps: number[] = [];
  return {
    throttle: createThrottle({
      minIntervalMs: 500,
      now: () => t,
      sleep: (ms: number) => {
        sleeps.push(ms);
        t += ms;
        return Promise.resolve();
      },
    }),
    sleeps,
  };
}

test("relatedKeywords parse la fixture réelle et mappe les items", async () => {
  const fixture = loadFixture("fixture-related.json");
  let seenUrl = "";
  let seenHeaders: Headers | undefined;
  const fetchMock = asFetch(async (input, init) => {
    seenUrl = String(input);
    seenHeaders = new Headers(init?.headers);
    return jsonResponse(fixture);
  });
  const { throttle } = fastThrottle();
  const client = createMangoolsClient(TOKEN, { fetch: fetchMock, throttle });

  const res = await client.relatedKeywords({
    kw: "bague de fiançailles",
    locationId: 0,
  });

  expect(seenUrl).toContain(
    "https://api.mangools.com/v3/kwfinder/related-keywords",
  );
  expect(seenUrl).toContain("kw=bague+de+fian%C3%A7ailles");
  expect(seenUrl).toContain("location_id=0");
  expect(seenHeaders?.get("x-access-token")).toBe(TOKEN);
  expect(res.countKeywordsBeforeLimit).toBe(3);
  expect(res.keywords).toHaveLength(3);
  const first = res.keywords[0];
  expect(first).toEqual({
    _id: "95609a8eaff14e115815fb5383d80a68",
    kw: "bague de fiançailles",
    lid: 0,
    country: null,
    sv: 84700,
    seo: 31,
    cpc: 1.78,
    ppc: 100,
    msv: expect.any(Array),
  });
  // Champs bruts non déclarés (svs, ts, svn…) écartés du mapping.
  expect(Object.keys(first).sort()).toEqual(
    ["_id", "country", "cpc", "kw", "lid", "msv", "ppc", "seo", "sv"].sort(),
  );
  const nullish = res.keywords[1];
  expect(nullish.sv).toBeNull();
  expect(nullish.cpc).toBeNull();
});

test("serps parse la fixture, passe platform_id et expose les metrics majestic", async () => {
  const fixture = loadFixture("fixture-serps.json");
  let seenUrl = "";
  const fetchMock = asFetch(async (input) => {
    seenUrl = String(input);
    return jsonResponse(fixture);
  });
  const { throttle } = fastThrottle();
  const client = createMangoolsClient(TOKEN, { fetch: fetchMock, throttle });

  const res = await client.serps({
    kw: "bague de fiançailles",
    locationId: 2840,
    platformId: 1,
  });

  expect(seenUrl).toContain("https://api.mangools.com/v3/serpchecker/serps");
  expect(seenUrl).toContain("location_id=2840");
  expect(seenUrl).toContain("platform_id=1");
  expect(res.kw).toBe("bague de fiançailles");
  expect(res.location_id).toBe(2840);
  expect(res.items.length).toBeGreaterThan(0);
  const item = res.items[0];
  expect(item.type).toBe("ORGANIC");
  expect(item.domain).toBe("histoiredor.com");
  expect(item.serpRank).toBe(1);
  const withMajestic = res.items.find((i) => i.m?.majestic?.v);
  expect(typeof withMajestic?.m?.majestic?.v?.TrustFlow).toBe("number");
});

test("serps omet platform_id quand non fourni", async () => {
  const fixture = loadFixture("fixture-serps.json");
  let seenUrl = "";
  const fetchMock = asFetch(async (input) => {
    seenUrl = String(input);
    return jsonResponse(fixture);
  });
  const { throttle } = fastThrottle();
  const client = createMangoolsClient(TOKEN, { fetch: fetchMock, throttle });

  await client.serps({ kw: "x", locationId: 2840 });
  expect(seenUrl).not.toContain("platform_id");
});

test("searchLocations parse le tableau de la fixture France", async () => {
  const fixture = loadFixture("fixture-locations-france.json");
  let seenUrl = "";
  const fetchMock = asFetch(async (input) => {
    seenUrl = String(input);
    return jsonResponse(fixture);
  });
  const { throttle } = fastThrottle();
  const client = createMangoolsClient(TOKEN, { fetch: fetchMock, throttle });

  const res = await client.searchLocations("France");
  expect(seenUrl).toContain("https://api.mangools.com/v3/mangools/locations");
  expect(seenUrl).toContain("query=France");
  const france = res.find((l) => l._id === 2250);
  expect(france).toEqual({
    _id: 2250,
    label: "France",
    country_code: "FR",
    target_type: "Country",
  });
});

test("keywordImports POST { keywords, location_id } et renvoie MangoolsKeywordItem[]", async () => {
  const fixture = loadFixture("fixture-keyword-imports.json");
  let seenMethod = "";
  let seenBody = "";
  let seenUrl = "";
  const fetchMock = asFetch(async (input, init) => {
    seenUrl = String(input);
    seenMethod = init?.method ?? "GET";
    seenBody = String(init?.body ?? "");
    return jsonResponse(fixture);
  });
  const { throttle } = fastThrottle();
  const client = createMangoolsClient(TOKEN, { fetch: fetchMock, throttle });

  const res = await client.keywordImports(["bague or", "bague argent"], 2250);

  expect(seenUrl).toContain(
    "https://api.mangools.com/v3/kwfinder/keyword-imports",
  );
  expect(seenMethod).toBe("POST");
  expect(JSON.parse(seenBody)).toEqual({
    keywords: ["bague or", "bague argent"],
    location_id: 2250,
  });
  expect(res).toHaveLength(2);
  expect(res[0].kw).toBe("bague or");
  expect(res[0].seo).toBe(28);
  expect(Object.keys(res[0]).sort()).toEqual(
    ["_id", "country", "cpc", "kw", "lid", "msv", "ppc", "seo", "sv"].sort(),
  );
});

test("401 → MangoolsApiError code unauthorized, sans le token dans le message", async () => {
  const fetchMock = asFetch(async () => jsonResponse({ error: "nope" }, 401));
  const { throttle } = fastThrottle();
  const client = createMangoolsClient(TOKEN, { fetch: fetchMock, throttle });

  const err = await client
    .relatedKeywords({ kw: "x", locationId: 0 })
    .catch((e) => e);
  expect(err).toBeInstanceOf(MangoolsApiError);
  expect((err as MangoolsApiErrorType).code).toBe("unauthorized");
  expect((err as MangoolsApiErrorType).status).toBe(401);
  expect((err as MangoolsApiErrorType).message).not.toContain(TOKEN);
});

test("403 → unauthorized", async () => {
  const fetchMock = asFetch(async () => jsonResponse({}, 403));
  const { throttle } = fastThrottle();
  const client = createMangoolsClient(TOKEN, { fetch: fetchMock, throttle });
  const err = await client.searchLocations("France").catch((e) => e);
  expect((err as MangoolsApiErrorType).code).toBe("unauthorized");
});

test("402 → quota_exceeded SANS reprise", async () => {
  let calls = 0;
  const fetchMock = asFetch(async () => {
    calls += 1;
    return jsonResponse({ error: "quota" }, 402);
  });
  const { throttle, sleeps } = fastThrottle();
  const client = createMangoolsClient(TOKEN, { fetch: fetchMock, throttle });

  const err = await client
    .relatedKeywords({ kw: "x", locationId: 0 })
    .catch((e) => e);
  expect((err as MangoolsApiErrorType).code).toBe("quota_exceeded");
  expect(calls).toBe(1); // aucune reprise
  expect(sleeps).toEqual([]); // aucun backoff
});

test("429 puis 200 → UNE reprise, backoff = Retry-After (secondes)", async () => {
  let calls = 0;
  const fixture = loadFixture("fixture-related.json");
  const fetchMock = asFetch(async () => {
    calls += 1;
    if (calls === 1) {
      return jsonResponse({ error: "rate" }, 429, { "retry-after": "3" });
    }
    return jsonResponse(fixture);
  });
  const { throttle, sleeps } = fastThrottle();
  const client = createMangoolsClient(TOKEN, { fetch: fetchMock, throttle });

  const res = await client.relatedKeywords({ kw: "x", locationId: 0 });
  expect(calls).toBe(2);
  expect(res.keywords).toHaveLength(3);
  expect(sleeps).toEqual([3000]); // Retry-After: 3 s → 3000 ms, seul sleep attendu
});

test("429 avec Retry-After abusif → backoff plafonné à MAX_BACKOFF_MS (15 s)", async () => {
  let calls = 0;
  const fixture = loadFixture("fixture-related.json");
  const fetchMock = asFetch(async () => {
    calls += 1;
    // Retry-After: 3600 s (1 h) → sans plafond, 3 600 000 ms suspendraient le
    // chemin UI synchrone (Task 9) une heure. Doit être borné à 15 000 ms.
    if (calls === 1) {
      return jsonResponse({ error: "rate" }, 429, { "retry-after": "3600" });
    }
    return jsonResponse(fixture);
  });
  const { throttle, sleeps } = fastThrottle();
  const client = createMangoolsClient(TOKEN, { fetch: fetchMock, throttle });

  const res = await client.relatedKeywords({ kw: "x", locationId: 0 });
  expect(calls).toBe(2);
  expect(res.keywords).toHaveLength(3);
  expect(sleeps).toEqual([15000]); // 3600 s plafonné à 15 000 ms, pas 3 600 000
});

test("429 sans Retry-After → backoff 2000 ms par défaut", async () => {
  let calls = 0;
  const fixture = loadFixture("fixture-related.json");
  const fetchMock = asFetch(async () => {
    calls += 1;
    if (calls === 1) return jsonResponse({ error: "rate" }, 429);
    return jsonResponse(fixture);
  });
  const { throttle, sleeps } = fastThrottle();
  const client = createMangoolsClient(TOKEN, { fetch: fetchMock, throttle });

  await client.relatedKeywords({ kw: "x", locationId: 0 });
  expect(sleeps).toEqual([2000]); // backoff par défaut, seul sleep attendu
});

test("429 deux fois → throw rate_limited (une seule reprise)", async () => {
  let calls = 0;
  const fetchMock = asFetch(async () => {
    calls += 1;
    return jsonResponse({ error: "rate" }, 429, { "retry-after": "1" });
  });
  const { throttle } = fastThrottle();
  const client = createMangoolsClient(TOKEN, { fetch: fetchMock, throttle });

  const err = await client
    .relatedKeywords({ kw: "x", locationId: 0 })
    .catch((e) => e);
  expect((err as MangoolsApiErrorType).code).toBe("rate_limited");
  expect((err as MangoolsApiErrorType).status).toBe(429);
  expect(calls).toBe(2); // 1 essai + 1 reprise, pas plus
});

test("429 repris → la reprise est re-throttlée : l'appel suivant reste espacé de ≥500 ms de son départ, et Retry-After:0 est planché à 500 ms", async () => {
  // Horloge + sleep injectés : zéro vrai délai. On capture l'instant `t` de
  // chaque fetch pour prouver l'espacement réel entre reprise et appel suivant.
  let t = 0;
  const sleeps: number[] = [];
  const throttle = createThrottle({
    minIntervalMs: 500,
    now: () => t,
    sleep: (ms: number) => {
      sleeps.push(ms);
      t += ms;
      return Promise.resolve();
    },
  });
  const fixture = loadFixture("fixture-related.json");
  const fetchAt: number[] = [];
  let calls = 0;
  const fetchMock = asFetch(async () => {
    fetchAt.push(t);
    calls += 1;
    // 1er appel : 429 avec Retry-After: 0 → doit être planché à MIN_BACKOFF_MS.
    if (calls === 1) {
      return jsonResponse({ error: "rate" }, 429, { "retry-after": "0" });
    }
    return jsonResponse(fixture);
  });
  const client = createMangoolsClient(TOKEN, { fetch: fetchMock, throttle });

  // 1er run : 429 (t=0) → backoff planché 500 → reprise re-throttlée (t=500).
  await client.relatedKeywords({ kw: "x", locationId: 0 });
  // 2e run indépendant, lancé immédiatement (horloge toujours à 500).
  await client.relatedKeywords({ kw: "y", locationId: 0 });

  expect(calls).toBe(3); // 429 + reprise + 2e appel
  // Retry-After: 0 planché à MIN_BACKOFF_MS (500), pas de reprise instantanée.
  expect(sleeps[0]).toBe(500);
  const retryDepart = fetchAt[1]; // départ de la reprise
  const nextCall = fetchAt[2]; // départ du 2e appel
  expect(retryDepart).toBe(500); // reprise elle-même throttlée (lastStart rafraîchi)
  // L'appel suivant reste espacé de ≥500 ms du DÉPART de la reprise.
  expect(nextCall - retryDepart).toBeGreaterThanOrEqual(500);
});

test("500 → upstream_error", async () => {
  const fetchMock = asFetch(async () => jsonResponse({}, 500));
  const { throttle } = fastThrottle();
  const client = createMangoolsClient(TOKEN, { fetch: fetchMock, throttle });
  const err = await client.serps({ kw: "x", locationId: 2840 }).catch((e) => e);
  expect((err as MangoolsApiErrorType).code).toBe("upstream_error");
  expect((err as MangoolsApiErrorType).status).toBe(500);
});

// --- Listes enregistrées (GET /kwfinder/lists, GET /kwfinder/lists/{id}) ------
// Formes relevées sur un compte réel le 2026-07-28 : l'enveloppe de liste porte
// des horodatages en MILLISECONDES, les mots-clés en SECONDES (`ts`).

const LIST_RAW = {
  _id: "6a5880eeeefecfa46ace107d",
  name: "ADVMFR USA Top",
  user_id: "675679b76aee08cdea6d04e0",
  creator: "675679b76aee08cdea6d04e0",
  created_at: 1784185070000,
  updated_at: 1784606505000,
  __v: 0,
  keyword_ids: ["a", "b", "c"],
};
const KW_RAW = {
  _id: "08b7c71d5b0b37413c69a028a787f388",
  kw: "jewelry wholesale manufacturer",
  lid: 2840,
  location: { _id: 2840, code: "us", label: "United States" },
  seo: 29,
  sv: 210,
  cpc: 2.83,
  ppc: 91,
  msv: [[2024, 0, 320]],
  ts: 1784185097,
};

test("lists() projette l'enveloppe et compte les mots-clés", async () => {
  const { throttle } = fastThrottle();
  const client = createMangoolsClient(TOKEN, {
    fetch: asFetch(async () => jsonResponse([LIST_RAW])),
    throttle,
  });
  const [l] = await client.lists();
  expect(l?.id).toBe("6a5880eeeefecfa46ace107d");
  expect(l?.name).toBe("ADVMFR USA Top");
  expect(l?.keywordCount).toBe(3);
});

test("lists() traite les horodatages en MILLISECONDES", async () => {
  const { throttle } = fastThrottle();
  const client = createMangoolsClient(TOKEN, {
    fetch: asFetch(async () => jsonResponse([LIST_RAW])),
    throttle,
  });
  const [l] = await client.lists();
  // 1784185070000 ms = 2026-07-14. Traité en secondes, on obtiendrait 1970.
  expect(l?.createdAt.startsWith("2026-")).toBe(true);
  expect(l?.updatedAt.startsWith("2026-")).toBe(true);
});

test("lists() sur un compte sans liste rend un tableau vide", async () => {
  const { throttle } = fastThrottle();
  const client = createMangoolsClient(TOKEN, {
    fetch: asFetch(async () => jsonResponse([])),
    throttle,
  });
  expect(await client.lists()).toEqual([]);
});

test("list() rend les mots-clés et les pays rencontrés, en majuscules", async () => {
  const { throttle } = fastThrottle();
  const client = createMangoolsClient(TOKEN, {
    fetch: asFetch(async () =>
      jsonResponse({ list: LIST_RAW, keywords: [KW_RAW] }),
    ),
    throttle,
  });
  const d = await client.list("6a5880eeeefecfa46ace107d");
  expect(d.keywords[0]?.kw).toBe("jewelry wholesale manufacturer");
  expect(d.keywords[0]?.sv).toBe(210);
  expect(d.countries).toEqual(["US"]);
});

test("list() déduplique les pays d'une liste mélangée", async () => {
  const fr = {
    ...KW_RAW,
    _id: "x",
    location: { _id: 1, code: "fr", label: "France" },
  };
  const { throttle } = fastThrottle();
  const client = createMangoolsClient(TOKEN, {
    fetch: asFetch(async () =>
      jsonResponse({ list: LIST_RAW, keywords: [KW_RAW, fr, KW_RAW] }),
    ),
    throttle,
  });
  expect((await client.list("id")).countries.sort()).toEqual(["FR", "US"]);
});

test("list() sur 401 lève une MangoolsApiError unauthorized", async () => {
  const { throttle } = fastThrottle();
  const client = createMangoolsClient(TOKEN, {
    fetch: asFetch(async () => jsonResponse({}, 401)),
    throttle,
  });
  const err = await client.list("id").catch((e) => e);
  expect((err as MangoolsApiErrorType).code).toBe("unauthorized");
});
