// src/components/onboarding/steps/keywords-step.test.ts — l'étape « peupler
// les mots-clés » du wizard, testée par sa logique PURE
// (`./keywords-step-logic`).
//
// Même motif que `gsc-step.test.tsx` : le dépôt n'a pas de harness de rendu
// React et le script `bun run test` tourne sous `--conditions=react-server`, où
// importer un composant client fait crasher la suite. La décision « qu'est-ce
// que l'écran propose » et « que se passe-t-il au clic » vit donc dans un
// module sans JSX, et c'est lui qu'on met sous test.
import { describe, expect, mock, test } from "bun:test";
import type { QuotaExceeded } from "@/lib/billing/quotas";
import type { SearchIntent } from "@/lib/keywords/types";
import type { CountryVerdict } from "@/lib/mangools/list-import";
import type { MangoolsList } from "@/lib/mangools/types";
import {
  type AddKeywordResult,
  buildImportSelections,
  createKeywordsStepHandlers,
  createMangoolsHandlers,
  type ListSelection,
  MANGOOLS_ERROR_MESSAGE_KEYS,
  type MangoolsImportOutcome,
  type MangoolsImportResult,
  type MangoolsListsResult,
  type MangoolsPanelState,
  resolveCanContinue,
  resolveEverImported,
  resolveImportOutcome,
  resolveInitialMangoolsPanel,
  resolveKeywordDefaults,
  resolveKeywordsStepPaths,
  resolveListsPanelState,
  resolveOutcomePanelState,
  resolveSelectionsAfterImport,
  resolveVerdictNotices,
  setSelectionTheme,
  toggleListSelection,
} from "./keywords-step-logic";

const SITE_ID = "site-1";

describe("resolveKeywordsStepPaths", () => {
  test("service analytics configuré → les deux chemins", () => {
    expect(resolveKeywordsStepPaths(true)).toEqual({ csv: true, manual: true });
  });

  test("service analytics absent → l'import CSV n'est PAS proposé", () => {
    // `POST /keywords/import-heavy` répond 503 sans R2 ni ANALYTICS_URL.
    // Proposer le bouton quand même, c'est offrir un chemin qui ne mène nulle
    // part — la rupture exacte que ce chantier ferme.
    expect(resolveKeywordsStepPaths(false)).toEqual({
      csv: false,
      manual: true,
    });
  });

  test("la saisie à la main survit à toutes les configurations", () => {
    // `add-keywords` est une étape REQUISE : il doit toujours exister un moyen
    // de la franchir qui ne dépende d'aucune infrastructure externe.
    for (const analytics of [true, false]) {
      expect(resolveKeywordsStepPaths(analytics).manual).toBe(true);
    }
  });
});

describe("resolveKeywordDefaults", () => {
  test("la langue et le pays viennent du site", () => {
    expect(
      resolveKeywordDefaults({
        primaryLanguage: "fr",
        targetCountries: ["FR", "BE"],
      }),
    ).toEqual({ lang: "fr", country: "FR" });
  });

  test("le premier pays cible gagne, jamais un autre de la liste", () => {
    expect(
      resolveKeywordDefaults({
        primaryLanguage: "es",
        targetCountries: ["MX", "ES"],
      }).country,
    ).toBe("MX");
  });

  test("site sans pays cible → le repli du RÉSERVOIR, pas un second concurrent", () => {
    // `add-keyword-dialog.tsx` retombe sur "FR". Diverger ici produirait deux
    // réservoirs différents selon la surface de saisie — et « US » recréerait
    // le couple « fr » + « US » que le commentaire du module dit vouloir
    // éviter.
    expect(resolveKeywordDefaults({ primaryLanguage: "fr" }).country).toBe(
      "FR",
    );
    expect(
      resolveKeywordDefaults({ primaryLanguage: "fr", targetCountries: [] })
        .country,
    ).toBe("FR");
  });

  test("site sans langue principale → repli « en », celui du réservoir", () => {
    expect(resolveKeywordDefaults({}).lang).toBe("en");
  });
});

describe("createKeywordsStepHandlers", () => {
  const OK: () => Promise<AddKeywordResult> = async () => ({ ok: true });

  function deps(result: () => Promise<AddKeywordResult> = OK) {
    return {
      addKeyword: mock(
        async (
          _siteId: string,
          _input: {
            term: string;
            lang: string;
            country: string;
            intent: SearchIntent[];
          },
        ) => result(),
      ),
      lang: "fr",
      country: "FR",
      onAdded: mock((_term: string) => {}),
      onError: mock((_message: string) => {}),
      onQuota: mock((_quota: QuotaExceeded) => {}),
      onSkip: mock(() => {}),
      emptyTermError: "saisie vide",
      fallbackError: "échec réseau",
    };
  }

  test("passer avance le wizard SANS écrire de mot-clé", () => {
    const d = deps();
    createKeywordsStepHandlers(SITE_ID, d).skip();
    expect(d.onSkip).toHaveBeenCalledTimes(1);
    expect(d.addKeyword).not.toHaveBeenCalled();
    expect(d.onError).not.toHaveBeenCalled();
  });

  test("ajouter écrit sur le site COURANT, avec la langue et le pays du site", async () => {
    // Le couple « fr » + « US » a déjà pollué un réservoir : le mot-clé saisi
    // ici hérite du site, il n'invente ni langue ni pays.
    const d = deps();
    await createKeywordsStepHandlers(SITE_ID, d).add("chaussures de rando");
    expect(d.addKeyword).toHaveBeenCalledWith(SITE_ID, {
      term: "chaussures de rando",
      lang: "fr",
      country: "FR",
      intent: [],
    });
    expect(d.onAdded).toHaveBeenCalledWith("chaussures de rando");
    expect(d.onError).not.toHaveBeenCalled();
  });

  test("les espaces autour du terme sont retirés avant l'écriture", async () => {
    const d = deps();
    await createKeywordsStepHandlers(SITE_ID, d).add("  velo electrique \n");
    expect(d.addKeyword).toHaveBeenCalledWith(
      SITE_ID,
      expect.objectContaining({ term: "velo electrique" }),
    );
    expect(d.onAdded).toHaveBeenCalledWith("velo electrique");
  });

  test("saisie vide → message immédiat, aucun aller-retour serveur", async () => {
    const d = deps();
    await createKeywordsStepHandlers(SITE_ID, d).add("   ");
    expect(d.addKeyword).not.toHaveBeenCalled();
    expect(d.onError).toHaveBeenCalledWith("saisie vide");
    expect(d.onAdded).not.toHaveBeenCalled();
  });

  test("refus du serveur → message affiché TEL QUEL, rien n'est ajouté", async () => {
    // « Ce mot-clé existe déjà » et « Terme invalide » appellent deux gestes
    // différents : une phrase générique les confondrait.
    const d = deps(async () => ({
      ok: false,
      error: "Ce mot-clé existe déjà pour ce site.",
    }));
    await createKeywordsStepHandlers(SITE_ID, d).add("deja la");
    expect(d.onError).toHaveBeenCalledWith(
      "Ce mot-clé existe déjà pour ce site.",
    );
    expect(d.onAdded).not.toHaveBeenCalled();
    expect(d.onQuota).not.toHaveBeenCalled();
  });

  test("quota atteint → remonté à `onQuota`, pas noyé dans le message", async () => {
    // Le payload porte la limite, que l'écran doit afficher : le message
    // générique du serveur ne la contient pas. (Pas de `QuotaNotice` dans cette
    // étape — son lien vers /settings/billing reboucle sur le wizard.)
    const quota: QuotaExceeded = {
      resource: "keywords",
      limit: 100,
      remaining: 0,
    };
    const d = deps(async () => ({
      ok: false,
      error: "Quota de mots-clés atteint.",
      quota,
    }));
    await createKeywordsStepHandlers(SITE_ID, d).add("un de trop");
    expect(d.onQuota).toHaveBeenCalledWith(quota);
    expect(d.onError).not.toHaveBeenCalled();
    expect(d.onAdded).not.toHaveBeenCalled();
  });

  test("action qui lève → message de repli, pas d'écran figé", async () => {
    const d = deps(async () => {
      throw new Error("boom");
    });
    await createKeywordsStepHandlers(SITE_ID, d).add("panne");
    expect(d.onError).toHaveBeenCalledWith("échec réseau");
    expect(d.onAdded).not.toHaveBeenCalled();
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// Troisième chemin : importer des listes enregistrées Mangools.
// ─────────────────────────────────────────────────────────────────────────────

function list(id: string, name: string, count = 12): MangoolsList {
  return {
    id,
    name,
    keywordCount: count,
    createdAt: "2026-01-01T00:00:00.000Z",
    updatedAt: "2026-02-01T00:00:00.000Z",
  };
}

describe("resolveInitialMangoolsPanel", () => {
  test("jeton déjà connecté → on charge les listes tout de suite", () => {
    expect(resolveInitialMangoolsPanel(true)).toEqual({ kind: "loading" });
  });

  test("aucun jeton → le champ de connexion s'affiche SUR PLACE", () => {
    // Surtout pas un renvoi vers l'étape 4 : l'utilisateur devrait la traverser
    // puis revenir ici, alors qu'il vient de choisir « importer depuis
    // Mangools ».
    expect(resolveInitialMangoolsPanel(false)).toEqual({ kind: "connect" });
  });
});

describe("resolveListsPanelState", () => {
  test("des listes → elles s'affichent", () => {
    const lists = [list("a", "Advmfr")];
    expect(resolveListsPanelState({ ok: true, lists })).toEqual({
      kind: "lists",
      lists,
    });
  });

  test("compte VIDE ≠ appel en échec", () => {
    // Distinction déjà tenue par l'étape Search Console : « tu n'as aucune
    // propriété » et « Google n'a pas répondu » appellent deux gestes opposés.
    expect(resolveListsPanelState({ ok: true, lists: [] })).toEqual({
      kind: "empty",
    });
    expect(resolveListsPanelState({ ok: false, error: "upstream" })).toEqual({
      kind: "failed",
      error: "upstream",
    });
  });

  test("jeton révoqué entre-temps → le champ de connexion revient sur place", () => {
    // `listMangoolsListsAction` rend `no_connector` aussi bien pour « jamais
    // connecté » que pour « jeton refusé, connecteur marqué invalide » : dans
    // les deux cas le geste est le même, recoller un jeton.
    expect(
      resolveListsPanelState({ ok: false, error: "no_connector" }),
    ).toEqual({ kind: "connect" });
  });

  test("quota Mangools → échec affiché, pas un compte vide", () => {
    expect(resolveListsPanelState({ ok: false, error: "quota" })).toEqual({
      kind: "failed",
      error: "quota",
    });
  });
});

describe("toggleListSelection", () => {
  test("cocher pré-remplit le thème avec le NOM de la liste", () => {
    expect(toggleListSelection([], list("a", "Advmfr"), true)).toEqual([
      { listId: "a", theme: "Advmfr" },
    ]);
  });

  test("décocher retire la sélection ET son thème", () => {
    const after = toggleListSelection(
      [
        { listId: "a", theme: "Advmfr" },
        { listId: "b", theme: "Bagues" },
      ],
      list("a", "Advmfr"),
      false,
    );
    expect(after).toEqual([{ listId: "b", theme: "Bagues" }]);
  });

  test("recocher une liste déjà cochée ne la duplique pas et garde le thème saisi", () => {
    // Sinon une re-coche écraserait la saisie de l'utilisateur par le nom de la
    // liste, et le serveur recevrait deux fois le même `listId`.
    const before: ListSelection[] = [{ listId: "a", theme: "Bijoux or" }];
    expect(toggleListSelection(before, list("a", "Advmfr"), true)).toEqual(
      before,
    );
  });

  test("l'ordre de sélection est préservé", () => {
    // Le serveur tranche un terme partagé par DEUX listes au profit de la
    // PREMIÈRE de la sélection : l'ordre est le seul critère que l'utilisateur
    // contrôle, il ne doit pas être réinventé à l'affichage.
    let sel: ListSelection[] = [];
    sel = toggleListSelection(sel, list("b", "B"), true);
    sel = toggleListSelection(sel, list("a", "A"), true);
    expect(sel.map((s) => s.listId)).toEqual(["b", "a"]);
  });
});

describe("setSelectionTheme", () => {
  test("remplace le thème d'une liste cochée sans toucher aux autres", () => {
    const after = setSelectionTheme(
      [
        { listId: "a", theme: "Advmfr" },
        { listId: "b", theme: "Bagues" },
      ],
      "a",
      "Bijoux or",
    );
    expect(after).toEqual([
      { listId: "a", theme: "Bijoux or" },
      { listId: "b", theme: "Bagues" },
    ]);
  });

  test("une liste NON cochée n'est pas ajoutée par une saisie de thème", () => {
    expect(setSelectionTheme([], "a", "Bijoux or")).toEqual([]);
  });
});

describe("buildImportSelections", () => {
  test("le thème saisi part comme LIBELLÉ, jamais comme slug", () => {
    // L'étape 3 n'a pas de sélecteur de thèmes existants (le fournisseur de
    // libellés n'est monté que dans le groupe (app), et /onboarding vit hors de
    // ce groupe) : le champ est libre, et c'est l'import qui crée le thème.
    expect(
      buildImportSelections([{ listId: "a", theme: "  Bijoux or  " }]),
    ).toEqual([{ listId: "a", themeSlug: null, themeLabel: "Bijoux or" }]);
  });

  test("thème vidé → aucun thème, pas un libellé vide", () => {
    expect(buildImportSelections([{ listId: "a", theme: "   " }])).toEqual([
      { listId: "a", themeSlug: null, themeLabel: null },
    ]);
  });
});

describe("resolveImportOutcome", () => {
  const okVerdict: CountryVerdict = { kind: "ok", country: "FR" };

  test("au moins un mot-clé créé → « Continuer » proposé, verdicts conservés", () => {
    // Le rechargement dur est déclenché par l'utilisateur, pas par l'import :
    // c'est le seul cas où un verdict `off-target` a réellement écrit des
    // lignes, donc le seul où sa phrase a quelque chose à dire.
    expect(
      resolveImportOutcome({
        ok: true,
        inserted: 42,
        verdicts: { a: okVerdict },
      }),
    ).toEqual({ kind: "imported", inserted: 42, verdicts: { a: okVerdict } });
  });

  test("zéro créé mais la liste a bien été écrite → « rien de nouveau », PAS un échec", () => {
    // `inserted` compte les mots-clés CRÉÉS : recocher une liste déjà importée
    // rend zéro tout en ayant rafraîchi métriques et courbes. Le confondre avec
    // « rien n'a été importé » ferait croire à un échec.
    const verdicts: Record<string, CountryVerdict> = {
      a: okVerdict,
      b: { kind: "off-target", country: "US", siteCountries: ["FR"] },
    };
    expect(resolveImportOutcome({ ok: true, inserted: 0, verdicts })).toEqual({
      kind: "already",
      lists: 2,
      verdicts,
    });
  });

  test("zéro créé et AUCUNE liste importable → « rien n'a été importé »", () => {
    const verdicts: Record<string, CountryVerdict> = {
      a: { kind: "mixed", countries: ["FR", "US"] },
      b: { kind: "unknown" },
    };
    expect(resolveImportOutcome({ ok: true, inserted: 0, verdicts })).toEqual({
      kind: "none",
      verdicts,
    });
  });

  test("sélection vide → « rien n'a été importé »", () => {
    expect(
      resolveImportOutcome({ ok: true, inserted: 0, verdicts: {} }),
    ).toEqual({ kind: "none", verdicts: {} });
  });

  test("quota de facturation → le payload remonte, la limite doit s'afficher", () => {
    const quota: QuotaExceeded = {
      resource: "keywords",
      limit: 100,
      remaining: 0,
    };
    expect(resolveImportOutcome({ ok: false, error: "quota", quota })).toEqual({
      kind: "failed",
      error: "quota",
      quota,
    });
  });

  test("quota MANGOOLS → même code, mais aucun payload de facturation", () => {
    // `quota` couvre deux refus distincts : la limite de requêtes du compte
    // Mangools (sans payload) et le quota de mots-clés du forfait (avec).
    expect(resolveImportOutcome({ ok: false, error: "quota" })).toEqual({
      kind: "failed",
      error: "quota",
      quota: undefined,
    });
  });
});

describe("resolveEverImported", () => {
  const imported: MangoolsImportOutcome = {
    kind: "imported",
    inserted: 250,
    verdicts: { a: { kind: "ok", country: "FR" } },
  };
  const already: MangoolsImportOutcome = {
    kind: "already",
    lists: 1,
    verdicts: { a: { kind: "ok", country: "FR" } },
  };

  test("un import qui crée des lignes arme le drapeau", () => {
    expect(resolveEverImported(false, imported)).toBe(true);
  });

  test("le drapeau est COLLANT : un second import à zéro ne le désarme pas", () => {
    // Le scénario en deux clics : on importe une liste (250 mots-clés créés),
    // puis on en coche une seconde qui ne crée rien. Sans rémanence, le
    // « Continuer » disparaîtrait et il ne resterait que la sortie « je n'ai
    // pas encore de mots-clés » — qui avance SANS rechargement, donc l'étape
    // suivante recevrait des candidats calculés AVANT l'import.
    expect(resolveEverImported(true, already)).toBe(true);
    expect(resolveEverImported(true, { kind: "none", verdicts: {} })).toBe(
      true,
    );
    expect(
      resolveEverImported(true, { kind: "failed", error: "upstream" }),
    ).toBe(true);
  });

  test("effacer le retour avant un nouvel essai ne désarme pas non plus", () => {
    // `importSelected` annonce `null` AVANT l'appel : sans rémanence, le bouton
    // basculerait pendant toute la transition.
    expect(resolveEverImported(true, null)).toBe(true);
  });

  test("un import qui n'a rien créé n'arme PAS le drapeau", () => {
    expect(resolveEverImported(false, already)).toBe(false);
    expect(resolveEverImported(false, { kind: "none", verdicts: {} })).toBe(
      false,
    );
    expect(
      resolveEverImported(false, { kind: "failed", error: "no_connector" }),
    ).toBe(false);
    expect(resolveEverImported(false, null)).toBe(false);
  });
});

describe("resolveCanContinue", () => {
  test("rien d'écrit → pas de « Continuer », seulement la sortie", () => {
    expect(resolveCanContinue(0, false)).toBe(false);
  });

  test("saisie à la main → « Continuer »", () => {
    expect(resolveCanContinue(1, false)).toBe(true);
  });

  test("import passé → « Continuer », même sans saisie à la main", () => {
    expect(resolveCanContinue(0, true)).toBe(true);
  });
});

describe("resolveOutcomePanelState", () => {
  test("jeton révoqué PENDANT l'import → retour au champ de jeton", () => {
    // `no_connector` remonte AUSSI de la boucle d'import : jeton révoqué chez
    // Mangools, ou retiré par un autre administrateur entre le listage et le
    // clic. Sans ça, le message dit « recolle ton token » alors qu'aucun champ
    // de jeton n'est à l'écran, et la seule issue est de recharger la page.
    expect(
      resolveOutcomePanelState({ kind: "failed", error: "no_connector" }),
    ).toEqual({ kind: "connect" });
  });

  test("les autres échecs laissent le panneau où il est", () => {
    expect(
      resolveOutcomePanelState({ kind: "failed", error: "upstream" }),
    ).toBeNull();
    expect(
      resolveOutcomePanelState({ kind: "failed", error: "too_many_lists" }),
    ).toBeNull();
  });

  test("un succès ne touche pas au panneau", () => {
    expect(resolveOutcomePanelState({ kind: "none", verdicts: {} })).toBeNull();
    expect(resolveOutcomePanelState(null)).toBeNull();
  });
});

describe("resolveSelectionsAfterImport", () => {
  const selections: ListSelection[] = [{ listId: "a", theme: "Advmfr" }];

  test("import traité → les cases sont purgées", () => {
    // Les laisser cochées rend le second clic parfaitement naturel alors que
    // ces listes viennent d'être traitées.
    expect(
      resolveSelectionsAfterImport(selections, {
        kind: "imported",
        inserted: 5,
        verdicts: {},
      }),
    ).toEqual([]);
    expect(
      resolveSelectionsAfterImport(selections, { kind: "none", verdicts: {} }),
    ).toEqual([]);
  });

  test("échec → la sélection survit, réessayer reste à un clic", () => {
    expect(
      resolveSelectionsAfterImport(selections, {
        kind: "failed",
        error: "upstream",
      }),
    ).toEqual(selections);
  });

  test("effacement du retour avant l'appel → la sélection survit", () => {
    expect(resolveSelectionsAfterImport(selections, null)).toEqual(selections);
  });
});

describe("MANGOOLS_ERROR_MESSAGE_KEYS", () => {
  test("les CINQ codes de l'action ont une clé de message", () => {
    // Le type `Record<MangoolsImportError, …>` l'impose au compilateur ; ce test
    // fige le fait qu'aucun code ne s'affiche brut à l'utilisateur.
    expect(Object.keys(MANGOOLS_ERROR_MESSAGE_KEYS).sort()).toEqual([
      "no_connector",
      "quota",
      "site_not_found",
      "too_many_lists",
      "upstream",
    ]);
  });
});

describe("resolveVerdictNotices", () => {
  const lists = [list("a", "Advmfr"), list("b", "Bagues")];

  test("liste alignée sur les pays du site → rien à dire", () => {
    expect(
      resolveVerdictNotices({ a: { kind: "ok", country: "FR" } }, lists, "fr"),
    ).toEqual([]);
  });

  test("écart accepté → le pays de la liste ET la langue du site", () => {
    // Le format Mangools ne transporte aucune langue : la ligne écrite porte le
    // pays de la liste et la langue du SITE. Le dire en entier, sinon
    // l'utilisateur découvre le couple plus tard.
    expect(
      resolveVerdictNotices(
        {
          a: { kind: "off-target", country: "US", siteCountries: ["FR", "BE"] },
        },
        lists,
        "fr",
      ),
    ).toEqual([
      {
        listId: "a",
        listName: "Advmfr",
        key: "mangoolsVerdictOffTarget",
        blocking: false,
        values: { country: "US", targets: "FR, BE", lang: "fr" },
      },
    ]);
  });

  test("site SANS pays cible → libellé distinct, pas « ton site cible  »", () => {
    // `resolveListCountry` rend aussi `off-target` avec une liste de cibles
    // VIDE. Réutiliser la même phrase afficherait un avertissement sur chaque
    // liste sans qu'on comprenne pourquoi.
    expect(
      resolveVerdictNotices(
        { a: { kind: "off-target", country: "US", siteCountries: [] } },
        lists,
        "fr",
      ),
    ).toEqual([
      {
        listId: "a",
        listName: "Advmfr",
        key: "mangoolsVerdictNoTargets",
        blocking: false,
        values: { country: "US", lang: "fr" },
      },
    ]);
  });

  test("plusieurs pays → RIEN n'est importé, avec les pays trouvés", () => {
    expect(
      resolveVerdictNotices(
        { b: { kind: "mixed", countries: ["FR", "US"] } },
        lists,
        "fr",
      ),
    ).toEqual([
      {
        listId: "b",
        listName: "Bagues",
        key: "mangoolsVerdictMixed",
        blocking: true,
        values: { countries: "FR, US" },
      },
    ]);
  });

  test("aucun pays lisible → rien n'est importé non plus", () => {
    expect(
      resolveVerdictNotices({ a: { kind: "unknown" } }, lists, "fr"),
    ).toEqual([
      {
        listId: "a",
        listName: "Advmfr",
        key: "mangoolsVerdictUnknown",
        blocking: true,
        values: {},
      },
    ]);
  });

  test("liste inconnue du panneau → repli sur son identifiant, jamais un nom vide", () => {
    const [notice] = resolveVerdictNotices(
      { zzz: { kind: "unknown" } },
      lists,
      "fr",
    );
    expect(notice?.listName).toBe("zzz");
  });
});

describe("createMangoolsHandlers", () => {
  function deps(
    over: {
      lists?: () => Promise<MangoolsListsResult>;
      imports?: () => Promise<MangoolsImportResult>;
    } = {},
  ) {
    return {
      listLists: mock(
        over.lists ??
          (async (): Promise<MangoolsListsResult> => ({ ok: true, lists: [] })),
      ),
      importLists: mock(
        async (_input: {
          siteId: string;
          selections: {
            listId: string;
            themeSlug: string | null;
            themeLabel: string | null;
          }[];
        }): Promise<MangoolsImportResult> =>
          over.imports
            ? over.imports()
            : { ok: true, inserted: 0, verdicts: {} },
      ),
      onPanel: mock((_state: MangoolsPanelState) => {}),
      onOutcome: mock((_o: unknown) => {}),
    };
  }

  test("charger annonce l'attente AVANT l'appel, puis le résultat", async () => {
    const d = deps({
      lists: async () => ({ ok: true, lists: [list("a", "Advmfr")] }),
    });
    await createMangoolsHandlers(SITE_ID, d).load();
    expect(d.onPanel.mock.calls.map(([s]) => s.kind)).toEqual([
      "loading",
      "lists",
    ]);
  });

  test("listage qui LÈVE → échec affiché, pas d'écran figé", async () => {
    const d = deps({
      lists: async () => {
        throw new Error("boom");
      },
    });
    await createMangoolsHandlers(SITE_ID, d).load();
    expect(d.onPanel).toHaveBeenLastCalledWith({
      kind: "failed",
      error: "upstream",
    });
  });

  test("import qui crée des mots-clés → le thème saisi part avec la sélection", async () => {
    const d = deps({
      imports: async () => ({
        ok: true,
        inserted: 5,
        verdicts: { a: { kind: "ok", country: "FR" } },
      }),
    });
    await createMangoolsHandlers(SITE_ID, d).importSelected([
      { listId: "a", theme: "Advmfr" },
    ]);
    expect(d.importLists).toHaveBeenCalledWith({
      siteId: SITE_ID,
      selections: [{ listId: "a", themeSlug: null, themeLabel: "Advmfr" }],
    });
    // Le handler NE recharge JAMAIS lui-même : il rend le verdict, et c'est le
    // « Continuer » de l'étape qui recharge — sinon la phrase « importés sur
    // US, dans la langue du site (fr) » serait détruite avant d'être lue.
    expect(d.onOutcome).toHaveBeenLastCalledWith({
      kind: "imported",
      inserted: 5,
      verdicts: { a: { kind: "ok", country: "FR" } },
    });
  });

  test("import à ZÉRO ligne → l'étape N'AVANCE PAS, le message reste lisible", async () => {
    // Recharger sur un import à zéro ramènerait la même étape à l'identique et
    // détruirait au passage le message qui explique pourquoi.
    const d = deps({
      imports: async () => ({
        ok: true,
        inserted: 0,
        verdicts: { a: { kind: "mixed", countries: ["FR", "US"] } },
      }),
    });
    await createMangoolsHandlers(SITE_ID, d).importSelected([
      { listId: "a", theme: "Advmfr" },
    ]);
    expect(d.onOutcome).toHaveBeenLastCalledWith({
      kind: "none",
      verdicts: { a: { kind: "mixed", countries: ["FR", "US"] } },
    });
  });

  test("ré-import d'une liste déjà là → pas de rechargement, mais « rien de nouveau »", async () => {
    const d = deps({
      imports: async () => ({
        ok: true,
        inserted: 0,
        verdicts: { a: { kind: "ok", country: "FR" } },
      }),
    });
    await createMangoolsHandlers(SITE_ID, d).importSelected([
      { listId: "a", theme: "Advmfr" },
    ]);
    expect(d.onOutcome).toHaveBeenLastCalledWith({
      kind: "already",
      lists: 1,
      verdicts: { a: { kind: "ok", country: "FR" } },
    });
  });

  test("aucune liste cochée → aucun appel serveur", async () => {
    const d = deps();
    await createMangoolsHandlers(SITE_ID, d).importSelected([]);
    expect(d.importLists).not.toHaveBeenCalled();
    expect(d.onOutcome).not.toHaveBeenCalled();
  });

  test("import qui LÈVE → message d'échec, jamais un rechargement", async () => {
    const d = deps({
      imports: async () => {
        throw new Error("boom");
      },
    });
    await createMangoolsHandlers(SITE_ID, d).importSelected([
      { listId: "a", theme: "Advmfr" },
    ]);
    expect(d.onOutcome).toHaveBeenLastCalledWith({
      kind: "failed",
      error: "upstream",
    });
  });
});
