import { describe, expect, it } from "vitest";
import {
  CONSOLIDATED_VIEW,
  intentLabel,
  keywordPageSchema,
  keywordRowSchema,
  keywordsQuery,
  parseVolume,
  readFilters,
  RESERVOIR_END_LABELS,
  reservoirEnd,
  resolveView,
  sourceBadge,
  sourceLabel,
  suggestQuery,
} from "./keywords";

const NOW = new Date("2026-09-01T12:00:00.000Z");
const TARGET = "018f-target";

function query(...args: Parameters<typeof keywordsQuery>) {
  return new URLSearchParams(keywordsQuery(...args));
}

describe("readFilters", () => {
  it("ne retient que les filtres connus et non vides", () => {
    expect(
      readFilters({ q: "trail", intent: "", volume_min: "100", cursor: "abc", inconnu: "x" }),
    ).toEqual({ q: "trail", volume_min: "100" });
  });

  it("ignore un paramètre répété (tableau) plutôt que d'en choisir un au hasard", () => {
    expect(readFilters({ q: ["a", "b"] })).toEqual({});
  });
});

describe("keywordsQuery", () => {
  it("porte toujours la cible et la limite", () => {
    const params = query({ siteTargetId: TARGET, filters: {}, limit: 50, now: NOW });
    expect(params.get("site_target_id")).toBe(TARGET);
    expect(params.get("limit")).toBe("50");
    expect(params.get("view")).toBeNull();
  });

  it("omet la vue consolidée, qui est le défaut de l'api", () => {
    const params = query({
      siteTargetId: TARGET,
      view: "consolidated",
      filters: {},
      limit: 50,
      now: NOW,
    });
    expect(params.get("view")).toBeNull();
  });

  it("transmet la vue d'une source individuelle", () => {
    const params = query({
      siteTargetId: TARGET,
      view: "semrush_magic",
      filters: {},
      limit: 50,
      now: NOW,
    });
    expect(params.get("view")).toBe("semrush_magic");
  });

  it("ignore une vue inconnue plutôt que d'aller chercher un 422", () => {
    // `resolve_view` (api) répond 422 sur une vue inconnue, et la page du réservoir est un
    // Server Component : ce 422 emporterait l'écran entier dans `error.tsx` pour un
    // marque-page périmé. On retombe sur la vue consolidée, qui montre tout.
    const params = query({ siteTargetId: TARGET, view: "foo", filters: {}, limit: 50, now: NOW });
    expect(params.get("view")).toBeNull();
    expect(resolveView("foo")).toBe(CONSOLIDATED_VIEW);
    expect(resolveView(undefined)).toBe(CONSOLIDATED_VIEW);
    expect(resolveView(["semrush_magic"])).toBe(CONSOLIDATED_VIEW);
    expect(resolveView("semrush_magic")).toBe("semrush_magic");
  });

  it("ignore un identifiant de liste ou de tag qui n'est pas un UUID", () => {
    const params = query({
      siteTargetId: TARGET,
      filters: { list_id: "abc", tag_id: "11111111-1111-4111-8111-111111111111" },
      limit: 50,
      now: NOW,
    });
    expect(params.get("list_id")).toBeNull();
    expect(params.get("tag_id")).toBe("11111111-1111-4111-8111-111111111111");
  });

  it("ignore un statut d'attache hors nomenclature", () => {
    expect(
      query({ siteTargetId: TARGET, filters: { attach_status: "foo" }, limit: 50, now: NOW }).get(
        "attach_status",
      ),
    ).toBeNull();
    expect(
      query({
        siteTargetId: TARGET,
        filters: { attach_status: "orphan" },
        limit: 50,
        now: NOW,
      }).get("attach_status"),
    ).toBe("orphan");
  });

  it("convertit « derniers N jours » en date plancher", () => {
    const params = query({
      siteTargetId: TARGET,
      filters: { updated: "30" },
      limit: 50,
      now: NOW,
    });
    expect(params.get("updated_since")).toBe("2026-08-02T12:00:00.000Z");
    expect(params.get("updated")).toBeNull();
  });

  it("ignore une fraîcheur illisible plutôt que d'envoyer une date invalide", () => {
    for (const updated of ["hier", "-3", "0"]) {
      const params = query({ siteTargetId: TARGET, filters: { updated }, limit: 50, now: NOW });
      expect(params.get("updated_since")).toBeNull();
    }
  });

  it("passe les autres filtres sous leur nom d'api", () => {
    const params = query({
      siteTargetId: TARGET,
      filters: { q: "trail", attach_status: "orphan", volume_min: "100" },
      limit: 25,
      now: NOW,
    });
    expect(params.get("q")).toBe("trail");
    expect(params.get("attach_status")).toBe("orphan");
    expect(params.get("volume_min")).toBe("100");
  });

  it("ajoute le curseur quand il y en a un", () => {
    expect(
      query({ siteTargetId: TARGET, filters: {}, cursor: "abc", limit: 50, now: NOW }).get(
        "cursor",
      ),
    ).toBe("abc");
    expect(
      query({ siteTargetId: TARGET, filters: {}, cursor: null, limit: 50, now: NOW }).get("cursor"),
    ).toBeNull();
  });
});

describe("libellés de source", () => {
  it("traduit les sources connues", () => {
    expect(sourceLabel("semrush_magic")).toBe("Semrush — Keyword Magic");
    expect(sourceBadge("semrush_magic")).toBe("Semrush");
  });

  it("affiche une source inconnue telle quelle plutôt que de la masquer", () => {
    expect(sourceLabel("source_de_demain")).toBe("source_de_demain");
    expect(sourceBadge("source_de_demain")).toBe("source_de_demain");
  });

  it("rend un tiret pour une valeur sans provenance", () => {
    // Cas devenu **inatteignable depuis le réservoir** : toute observation rendue par l'api
    // porte un nom de source non nul (§3.2), et une cellule sans observation ne construit
    // aucun badge. Le repli est conservé pour les appelants défensifs — il ne coûte rien, et
    // son retrait transformerait un `null` inattendu en « undefined » à l'écran.
    expect(sourceLabel(null)).toBe("—");
    expect(sourceBadge(undefined)).toBe("—");
  });
});

describe("parseVolume", () => {
  it("accepte un entier positif ou nul", () => {
    expect(parseVolume("0")).toBe(0);
    expect(parseVolume("1200")).toBe(1200);
  });

  it("rejette ce qui n'est pas un entier borné", () => {
    // Une URL bricolée ne doit pas produire un 422 pleine page : la valeur est écartée et
    // l'écran le dit dans le champ.
    for (const raw of ["", "abc", "-5", "12.5", "1e3", " 12 "]) {
      expect(parseVolume(raw)).toBeNull();
    }
  });
});

describe("intentLabel", () => {
  it("traduit une intention connue", () => {
    expect(intentLabel("informational")).toBe("Informationnelle");
  });

  it("traduit chaque intention d'une ligne multi-intentions", () => {
    // `to_intent` (api) range plusieurs intentions dans une seule colonne texte.
    expect(intentLabel("informational,commercial")).toBe("Informationnelle · Commerciale");
  });

  it("affiche telle quelle une intention inconnue plutôt que de la masquer", () => {
    expect(intentLabel("local")).toBe("local");
    expect(intentLabel("informational,local")).toBe("Informationnelle · local");
  });

  it("rend un tiret pour une valeur absente", () => {
    expect(intentLabel(null)).toBe("—");
    expect(intentLabel("")).toBe("—");
  });
});

describe("keywordsQuery — bornes de volume", () => {
  it("ignore un volume illisible plutôt que de provoquer un 422 pleine page", () => {
    const params = query({
      siteTargetId: TARGET,
      filters: { volume_min: "abc", volume_max: "12.5" },
      limit: 50,
      now: NOW,
    });
    expect(params.get("volume_min")).toBeNull();
    expect(params.get("volume_max")).toBeNull();
  });

  it("transmet un volume valide", () => {
    const params = query({
      siteTargetId: TARGET,
      filters: { volume_min: "100", volume_max: "5000" },
      limit: 50,
      now: NOW,
    });
    expect(params.get("volume_min")).toBe("100");
    expect(params.get("volume_max")).toBe("5000");
  });
});

describe("suggestQuery", () => {
  it("n'appelle pas l'api sous deux caractères", () => {
    // Le 422 de `GET /keywords/suggest` est une décision de coût côté api : l'écran ne doit
    // pas aller le chercher une fois par frappe pour se le faire refuser.
    expect(suggestQuery("")).toBeNull();
    expect(suggestQuery("j")).toBeNull();
    expect(suggestQuery("  j  ")).toBeNull();
  });

  it("rend la saisie détourée dès deux caractères", () => {
    expect(suggestQuery("je")).toBe("je");
    expect(suggestQuery("  jewelry manufacturing  ")).toBe("jewelry manufacturing");
  });
});

describe("reservoirEnd", () => {
  it("ne dit « Fin du réservoir » qu'en l'absence de recherche", () => {
    expect(reservoirEnd({ searching: false, fuzzy: false })).toBe("reservoir-end");
    expect(RESERVOIR_END_LABELS["reservoir-end"]).toBe("Fin du réservoir pour ces filtres.");
  });

  it("au bout d'une recherche, rappelle la borne au lieu d'annoncer le fond du réservoir", () => {
    // Le reproche exact de la review : une recherche qui s'arrête n'a pas épuisé le
    // réservoir — elle a épuisé ses résultats, ou atteint sa profondeur paginable.
    expect(reservoirEnd({ searching: true, fuzzy: false })).toBe("search-end");
    const label = RESERVOIR_END_LABELS["search-end"];
    expect(label).not.toContain("Fin du réservoir");
    expect(label).toContain("1000");
    expect(label).toContain("affinez");
  });

  it("le repli flou l'emporte : il ne pagine pas du tout", () => {
    expect(reservoirEnd({ searching: true, fuzzy: true })).toBe("fuzzy-end");
    expect(RESERVOIR_END_LABELS["fuzzy-end"]).toContain("approchants");
  });
});

// --- contrat de `GET /keywords` ---------------------------------------------------------------

// Les tests ci-dessus ne parsent rien : ils décrivent des fonctions pures. Or le seul endroit
// où le web peut se tromper **silencieusement** sur la forme servie par FastAPI est le parse —
// `tsc` ne vérifie que la cohérence entre le schéma et ses lecteurs, jamais que le schéma
// décrit l'api. Et l'échec est brutal : `keywordPageSchema.parse` est appelé dans un Server
// Component, une ZodError y devient `error.tsx` sur toute la page du réservoir.
//
// Ce bloc fige donc la forme livrée par l'api (spec 2026-09-01 §3.2), fixture à l'appui : six
// métriques en **tableaux d'observations**, jamais nulles, jamais absentes, même forme dans
// les deux vues.

/** Une observation telle que l'api la sérialise — `Z` final, pas `+00:00`. */
function observation(value: unknown, source: string, day: string) {
  return { value, source, fetched_at: `2026-08-${day}T12:00:00Z` };
}

/** Une ligne d'api complète ; chaque test ne redéclare que ce qu'il éprouve. */
function apiRow(overrides: Record<string, unknown> = {}) {
  return {
    id: "0198f1c4-0000-7000-8000-000000000001",
    keyword: "jewelry manufacturer",
    locale: "en-US",
    volume: [observation(1200, "mangools", "30")],
    kd: [observation(42.5, "mangools", "30")],
    cpc: [observation(1.5, "mangools", "30")],
    intent: [observation("commercial", "mangools", "30")],
    serp_features: [observation({ featured_snippet: true }, "mangools", "30")],
    trend: [observation([10, 20, 30], "mangools", "30")],
    last_updated: "2026-08-30T12:00:00Z",
    attach_status: "orphan",
    lists: [],
    tags: [],
    ...overrides,
  };
}

describe("keywordRowSchema", () => {
  it("empile les observations d'un champ dans l'ordre rendu par l'api", () => {
    // L'ordre est contractuel côté SQL (source préférée, puis fraîcheur, puis nom de source) :
    // l'écran affiche dans l'ordre reçu, ce test interdit de le supposer trié autrement.
    const row = keywordRowSchema.parse(
      apiRow({
        volume: [observation(5000, "semrush_magic", "31"), observation(1200, "mangools", "30")],
      }),
    );
    expect(row.volume.map((entry) => entry.source)).toEqual(["semrush_magic", "mangools"]);
    expect(row.volume[0]?.value).toBe(5000);
  });

  it("accepte un champ qu'aucune source n'a observé — un tableau vide, jamais `null`", () => {
    const row = keywordRowSchema.parse(apiRow({ cpc: [] }));
    expect(row.cpc).toEqual([]);
    // Et le voisin reste peuplé : un trou sur un champ ne masque plus rien sur les autres.
    expect(row.volume).toHaveLength(1);
  });

  it("accepte un mot-clé sans aucune métrique : six tableaux vides et `last_updated` nul", () => {
    // Cas d'un mot-clé attaché à la main. Indiscernable du précédent champ par champ, et c'est
    // voulu ; seul `last_updated` distingue les deux.
    const row = keywordRowSchema.parse(
      apiRow({
        volume: [],
        kd: [],
        cpc: [],
        intent: [],
        serp_features: [],
        trend: [],
        last_updated: null,
      }),
    );
    expect([row.volume, row.kd, row.cpc, row.intent, row.serp_features, row.trend]).toEqual([
      [],
      [],
      [],
      [],
      [],
      [],
    ]);
    expect(row.last_updated).toBeNull();
  });

  it("lit la vue par source avec le même schéma : au plus une entrée par champ", () => {
    // C'est la promesse §3.2 tenue côté api, et la raison pour laquelle l'écran n'a **aucune
    // branche** selon la vue. Si l'api revenait un jour à une valeur nue en vue par source, ce
    // test tomberait ici plutôt qu'en production.
    const row = keywordRowSchema.parse(
      apiRow({ volume: [observation(1200, "mangools", "30")], kd: [], cpc: [], intent: [] }),
    );
    expect(row.volume).toHaveLength(1);
    expect(row.kd).toEqual([]);
  });

  it("refuse une entrée creuse — la tolérance porte sur le tableau vide, pas sur son contenu", () => {
    // Exactement l'ancienne forme d'une métrique sans valeur. L'accepter afficherait un badge
    // de provenance en face d'une observation qui n'existe pas.
    const parsed = keywordRowSchema.safeParse(
      apiRow({ volume: [{ value: null, source: null, fetched_at: null }] }),
    );
    expect(parsed.success).toBe(false);
  });

  it("refuse l'ancienne forme objet d'une métrique", () => {
    // Garde-fou d'ordre de déploiement : ce schéma ne sait lire que la nouvelle forme, donc
    // l'api doit partir avant le web. Le test dit laquelle des deux moitiés est en retard.
    const parsed = keywordRowSchema.safeParse(
      apiRow({ volume: { value: 1200, source: "mangools", fetched_at: "2026-08-30T12:00:00Z" } }),
    );
    expect(parsed.success).toBe(false);
  });

  it("lit les décimales comme des nombres JSON, jamais des chaînes", () => {
    // Piège relevé par le contract-keeper T4 : Pydantic sérialise un `Decimal` en chaîne. Via
    // jsonb, c'est le codec d'asyncpg qui décode, et le contrat api type ces champs en `float`.
    expect(keywordRowSchema.parse(apiRow()).kd[0]?.value).toBe(42.5);
    const asString = keywordRowSchema.safeParse(
      apiRow({ cpc: [observation("1.50", "mangools", "30")] }),
    );
    expect(asString.success).toBe(false);
  });

  it("parse les deux champs que la table ne montre pas", () => {
    // `serp_features` et `trend` ne vont nulle part à l'écran : les omettre du schéma ne se
    // verrait dans aucune revue de code, et ferait échouer le parse au premier rendu.
    const row = keywordRowSchema.parse(apiRow());
    expect(row.trend[0]?.value).toEqual([10, 20, 30]);
    expect(row.serp_features[0]?.source).toBe("mangools");
  });
});

describe("keywordPageSchema", () => {
  it("parse une page du réservoir", () => {
    const page = keywordPageSchema.parse({
      items: [apiRow()],
      next_cursor: null,
      view: "consolidated",
      preferred_source: "mangools",
      fuzzy: false,
    });
    expect(page.items[0]?.volume[0]?.source).toBe("mangools");
    expect(page.preferred_source).toBe("mangools");
  });

  it("survit à une api qui n'annonce pas encore `fuzzy`", () => {
    // `.catch(false)` et non `.optional()` : un web déployé en avance sur l'api ne doit pas
    // casser la page pour un champ manquant.
    const page = keywordPageSchema.parse({
      items: [],
      next_cursor: null,
      view: "consolidated",
      preferred_source: null,
    });
    expect(page.fuzzy).toBe(false);
  });
});
