// src/components/onboarding/steps/keywords-step-logic.ts — décisions PURES de
// l'étape « peupler les mots-clés » du wizard (ce que l'écran propose, ce que
// fait un clic). Séparé du composant pour la raison qui vaut déjà pour
// `gsc-step-logic.ts` : le module reste chargeable sous
// `--conditions=react-server` (le script `bun run test` du dépôt), et les règles
// de l'étape deviennent des propriétés testables plutôt que des promesses de
// JSX.
//
// ⚠️ Aucun chemin Search Console ici. GSC est devenue une étape à part entière,
// juste avant celle-ci : le proposer une seconde fois redemanderait à
// l'utilisateur ce qu'il vient de traiter.
import type { QuotaExceeded } from "@/lib/billing/quotas";
import type { SearchIntent } from "@/lib/keywords/types";
// ⚠️ `import type` OBLIGATOIRE sur les deux imports Mangools : `list-import.ts`
// charge `import-core`, un wasm ciblé nodejs qu'aucun navigateur ne peut
// exécuter. Le type est effacé à la compilation ; retirer le mot-clé `type`
// ferait entrer le module dans le bundle client.
import type { CountryVerdict } from "@/lib/mangools/list-import";
import type { MangoolsList } from "@/lib/mangools/types";

/** Retour de `addKeywordAction`, redéclaré structurellement : le module de
 *  logique ne doit importer AUCUNE valeur d'un module `"use server"`. */
export type AddKeywordResult =
  | { ok: true }
  | { ok: false; error: string; quota?: QuotaExceeded };

/**
 * Les chemins réellement praticables pour peupler le réservoir.
 *
 * `csv` suit `env.analyticsEnabled` parce que l'import lourd passe par R2 + le
 * service analytics : sans eux, `POST /keywords/import-heavy` répond 503. Un
 * bouton qui mène à un 503 est exactement la rupture que ce chantier ferme.
 *
 * `manual` est toujours vrai — c'est le filet de sécurité de l'étape, la seule
 * façon de franchir une étape REQUISE (`add-keywords`) sans dépendre d'une
 * infrastructure externe.
 */
export type KeywordsStepPaths = { csv: boolean; manual: true };

export function resolveKeywordsStepPaths(
  analyticsEnabled: boolean,
): KeywordsStepPaths {
  return { csv: analyticsEnabled, manual: true };
}

/**
 * Langue et pays d'un mot-clé saisi à la main, dérivés du site du parcours.
 *
 * Les deux replis sont ceux du RÉSERVOIR (`add-keyword-dialog.tsx`), pas des
 * valeurs choisies ici : deux défauts concurrents pour la même donnée
 * produiraient deux réservoirs différents selon la surface de saisie. Le pays
 * cible est de toute façon exigé à la création du site depuis la tâche 5 — le
 * repli ne sert que les sites créés avant cette exigence.
 *
 * Surtout PAS de couple « fr » + « US », que `addKeywordAction` produit déjà de
 * lui-même quand on ne lui dit rien, et qui a une fois pollué un réservoir.
 */
export function resolveKeywordDefaults(site: {
  primaryLanguage?: string;
  targetCountries?: string[];
}): { lang: string; country: string } {
  return {
    lang: site.primaryLanguage || "en",
    country: site.targetCountries?.[0] || "FR",
  };
}

export type KeywordsStepHandlerDeps = {
  /** `addKeywordAction` en production, un espion dans les tests. */
  addKeyword: (
    siteId: string,
    input: {
      term: string;
      lang: string;
      country: string;
      /** Vide ici : l'intention se qualifie plus tard, dans le réservoir. La
       *  demander dès l'onboarding alourdirait un filet de sécurité. */
      intent: SearchIntent[];
    },
  ) => Promise<AddKeywordResult>;
  /** Langue et pays du site du parcours — surtout PAS des constantes. Le
   *  couple « fr » + « US » a déjà pollué un réservoir dans ce dépôt : un
   *  mot-clé saisi ici hérite du site, comme tout le reste. */
  lang: string;
  country: string;
  /** Mot-clé inséré : l'appelant l'ajoute à sa liste locale. On NE recharge pas
   *  à chaque ajout — le rechargement ferait disparaître l'étape (l'état dérivé
   *  côté serveur vient de changer) et interdirait d'en saisir un second. */
  onAdded: (term: string) => void;
  onError: (message: string) => void;
  /** Quota de mots-clés atteint. Remonté à part du message d'erreur parce que
   *  la limite (`quota.limit`) doit être affichée : « tu es au plafond » sans
   *  le chiffre n'aide personne. L'appelant NE peut pas utiliser `QuotaNotice`
   *  ici — son lien vers `/settings/billing` reboucle sur le wizard tant que
   *  les étapes requises manquent. */
  onQuota: (quota: QuotaExceeded) => void;
  /** Sortie : on avance sans rien écrire. */
  onSkip: () => void;
  /** Message quand la saisie est vide — on ne fait même pas l'aller-retour. */
  emptyTermError: string;
  /** Message quand l'action LÈVE (panne réseau, session perdue) : le serveur
   *  n'en a fourni aucun. Passé par le composant, seul détenteur des
   *  traductions. */
  fallbackError: string;
};

export function createKeywordsStepHandlers(
  siteId: string,
  deps: KeywordsStepHandlerDeps,
): { skip: () => void; add: (term: string) => Promise<void> } {
  return {
    skip() {
      // Aucune écriture. `add-keywords` est REQUISE : passer ne la coche pas,
      // elle attendra dans la checklist d'activation. C'est voulu — la sortie
      // n'est pas un raccourci, c'est une porte.
      deps.onSkip();
    },
    async add(term: string) {
      const trimmed = term.trim();
      if (!trimmed) {
        deps.onError(deps.emptyTermError);
        return;
      }
      try {
        const res = await deps.addKeyword(siteId, {
          term: trimmed,
          lang: deps.lang,
          country: deps.country,
          intent: [],
        });
        if (!res.ok) {
          // Le quota AVANT le message : il porte la limite, que l'écran doit
          // afficher — le message générique du serveur ne la contient pas.
          if (res.quota) deps.onQuota(res.quota);
          // Message du serveur affiché TEL QUEL : il distingue « ce mot-clé
          // existe déjà » de « terme invalide », deux gestes différents.
          else deps.onError(res.error);
          return;
        }
        deps.onAdded(trimmed);
      } catch {
        // `addKeywordAction` LÈVE sur `Forbidden` et sur toute panne DB. Sans
        // ce catch, la promesse rejetterait en silence et le bouton se
        // réactiverait sans un mot.
        deps.onError(deps.fallbackError);
      }
    },
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// TROISIÈME CHEMIN : les listes enregistrées Mangools (KWFinder).
//
// Il n'a PAS de drapeau dans `resolveKeywordsStepPaths` : contrairement à
// l'import CSV (qui dépend de R2 + du service analytics), il ne dépend d'aucune
// infrastructure de l'instance. Un jeton manquant n'est pas une indisponibilité
// mais un geste — le champ de connexion s'affiche SUR PLACE, avec le flux
// existant (`connectMangoolsAction`), et surtout pas un renvoi vers l'étape 4
// que l'utilisateur devrait traverser pour revenir ici.
// ─────────────────────────────────────────────────────────────────────────────

/** Taxonomie de `listMangoolsListsAction`, redéclarée structurellement : un
 *  module `"use server"` ne peut exporter que des fonctions asynchrones, donc
 *  ses types ne franchissent pas la frontière. La cohérence est tenue par le
 *  COMPILATEUR au point d'injection : le composant passe la vraie action dans
 *  `MangoolsHandlerDeps`, et un code ajouté côté serveur cesserait d'être
 *  assignable ici. */
type MangoolsListsError = "no_connector" | "quota" | "upstream";

/** Idem pour `importMangoolsListsAction`, qui ajoute deux codes. CINQ au total :
 *  chacun DOIT avoir un message, cf. `MANGOOLS_ERROR_MESSAGE_KEYS`. */
export type MangoolsImportError =
  | MangoolsListsError
  | "too_many_lists"
  | "site_not_found";

export type MangoolsListsResult =
  | { ok: true; lists: MangoolsList[] }
  | { ok: false; error: MangoolsListsError };

export type MangoolsImportResult =
  | { ok: true; inserted: number; verdicts: Record<string, CountryVerdict> }
  | { ok: false; error: MangoolsImportError; quota?: QuotaExceeded };

/** Charge utile d'une sélection, telle que l'action la reçoit. */
export type ImportSelectionPayload = {
  listId: string;
  themeSlug: string | null;
  themeLabel: string | null;
};

/**
 * Clé de message par code d'erreur.
 *
 * `satisfies Record<MangoolsImportError, string>` fait porter l'exhaustivité au
 * COMPILATEUR : un sixième code côté serveur casse la compilation ici plutôt
 * que de s'afficher brut à l'utilisateur. `as const` conserve les littéraux,
 * que `useTranslations` sait typer.
 */
export const MANGOOLS_ERROR_MESSAGE_KEYS = {
  no_connector: "mangoolsErrorNoConnector",
  quota: "mangoolsErrorQuota",
  upstream: "mangoolsErrorUpstream",
  too_many_lists: "mangoolsErrorTooManyLists",
  site_not_found: "mangoolsErrorSiteNotFound",
} as const satisfies Record<MangoolsImportError, string>;

/**
 * Ce que le panneau Mangools affiche. `null` (panneau fermé) vit dans l'état du
 * composant, pas ici.
 *
 * `empty` et `failed` sont DEUX états, jamais un seul : « ton compte n'a aucune
 * liste » et « l'appel a échoué » appellent deux gestes opposés. C'est la
 * distinction que tient déjà l'étape Search Console, et la reproduire ici est
 * moins coûteux que de la réapprendre après coup.
 */
export type MangoolsPanelState =
  | { kind: "connect" }
  | { kind: "loading" }
  | { kind: "failed"; error: MangoolsListsError }
  | { kind: "empty" }
  | { kind: "lists"; lists: MangoolsList[] };

/** Premier état du panneau à l'ouverture. Sans jeton, le champ de connexion —
 *  pas une redirection vers l'étape d'enrichissement. */
export function resolveInitialMangoolsPanel(
  connected: boolean,
): MangoolsPanelState {
  return connected ? { kind: "loading" } : { kind: "connect" };
}

/**
 * Verdict du listage.
 *
 * `no_connector` ramène au champ de connexion : l'action rend ce code aussi
 * bien pour « jamais connecté » que pour « jeton refusé, connecteur marqué
 * invalide » (le jeton a pu être révoqué chez Mangools entre le rendu serveur
 * et le clic). Dans les deux cas le geste est le même — recoller un jeton.
 */
export function resolveListsPanelState(
  result: MangoolsListsResult,
): MangoolsPanelState {
  if (!result.ok) {
    if (result.error === "no_connector") return { kind: "connect" };
    return { kind: "failed", error: result.error };
  }
  if (result.lists.length === 0) return { kind: "empty" };
  return { kind: "lists", lists: result.lists };
}

/** Une liste cochée et le thème qui lui est associé.
 *
 *  `theme` est une chaîne LIBRE, pas un slug : l'étape n'a pas de sélecteur de
 *  thèmes existants — `ThemeSelect` consomme `useThemeLabels`, dont le
 *  fournisseur n'est monté que dans `src/app/(app)/layout.tsx`, et /onboarding
 *  vit HORS de ce groupe de routes. Ce n'est pas une perte : le chemin serveur
 *  crée le thème PENDANT l'import (`insertThemeRows`, idempotent), donc
 *  retaper le nom d'un thème existant le retrouve au lieu d'en fabriquer un
 *  second. */
export type ListSelection = { listId: string; theme: string };

/**
 * Coche / décoche une liste. Cocher pré-remplit le thème avec le NOM de la
 * liste — c'est le libellé que l'utilisateur a lui-même choisi chez Mangools,
 * donc la meilleure proposition disponible.
 *
 * Recocher une liste DÉJÀ cochée ne fait rien : sans cette garde, un double
 * événement écraserait le thème saisi par le nom de la liste et enverrait deux
 * fois le même `listId` au serveur.
 *
 * L'ordre de sélection est PRÉSERVÉ : sur un terme partagé par deux listes aux
 * thèmes différents, le serveur retient celui de la PREMIÈRE de la sélection.
 * C'est le seul critère que l'utilisateur contrôle ; le réordonner à
 * l'affichage le lui retirerait.
 */
export function toggleListSelection(
  selections: readonly ListSelection[],
  list: { id: string; name: string },
  checked: boolean,
): ListSelection[] {
  if (!checked) return selections.filter((s) => s.listId !== list.id);
  if (selections.some((s) => s.listId === list.id)) return [...selections];
  return [...selections, { listId: list.id, theme: list.name }];
}

/** Saisie du champ de thème. Une liste NON cochée n'est jamais ajoutée par ce
 *  chemin : le champ n'existe que sous une case cochée, et une sélection
 *  fabriquée ici échapperait à la pré-remplissage comme au décochage. */
export function setSelectionTheme(
  selections: readonly ListSelection[],
  listId: string,
  theme: string,
): ListSelection[] {
  return selections.map((s) => (s.listId === listId ? { ...s, theme } : s));
}

/**
 * Charge utile envoyée à `importMangoolsListsAction`.
 *
 * `themeSlug` est TOUJOURS `null` depuis cette étape : le champ est libre, donc
 * ce que l'utilisateur tape est un LIBELLÉ. C'est aussi la seule branche qui
 * crée la ligne `keyword_themes` côté serveur — envoyer un slug supposerait un
 * thème déjà existant et laisserait le mot-clé rangé sous un thème absent de la
 * liste des thèmes du site.
 *
 * Un champ vidé donne `themeLabel: null` (import SANS thème) et non une chaîne
 * vide : le serveur écarterait de toute façon un libellé vide, autant que
 * l'intention soit lisible dans la charge.
 */
export function buildImportSelections(
  selections: readonly ListSelection[],
): ImportSelectionPayload[] {
  return selections.map((s) => ({
    listId: s.listId,
    themeSlug: null,
    themeLabel: s.theme.trim() || null,
  }));
}

/**
 * Ce qu'il faut faire d'un retour d'import.
 *
 * ⚠️ `inserted` compte les mots-clés RÉELLEMENT CRÉÉS (sémantique héritée de
 * `addRelatedKeywordsAction`). Zéro recouvre donc DEUX situations opposées :
 *
 * - `already` — les listes ont bien été traitées, mais leurs mots-clés étaient
 *   déjà au réservoir. Métriques et courbes ont pourtant été rafraîchies. C'est
 *   un ré-import légitime, pas un échec.
 * - `none` — aucune liste n'était importable (verdict `mixed` / `unknown`).
 *
 * Les confondre ferait croire à un échec quiconque recoche une liste déjà
 * importée. Aucun des deux ne recharge : `add-keywords` reste faux, recharger
 * ramènerait la même étape à l'identique et détruirait le message qui explique
 * pourquoi — défaut déjà corrigé sur l'import CSV.
 *
 * `imported` (des mots-clés créés) ne recharge PAS TOUT SEUL non plus, et c'est
 * une décision, pas un oubli : c'est le seul cas où un verdict `off-target` a
 * réellement écrit des lignes, donc le seul où la phrase « importés sur US,
 * dans la langue du site (fr) » a quelque chose à dire. Un rechargement
 * automatique la détruirait avant qu'elle soit lue. L'étape rend alors son
 * bouton « Continuer » — le rechargement dur reste conditionné à une insertion
 * réelle, il est simplement déclenché par l'utilisateur, comme sur le chemin de
 * saisie manuelle juste à côté.
 *
 * `verdicts` traverse les trois issues de succès : les avertissements de pays
 * se disent quel que soit le décompte.
 */
export type MangoolsImportOutcome =
  | {
      kind: "imported";
      inserted: number;
      verdicts: Record<string, CountryVerdict>;
    }
  | { kind: "already"; lists: number; verdicts: Record<string, CountryVerdict> }
  | { kind: "none"; verdicts: Record<string, CountryVerdict> }
  | { kind: "failed"; error: MangoolsImportError; quota?: QuotaExceeded };

/** Une liste dont le verdict n'a PAS bloqué l'import. `mixed` et `unknown` le
 *  bloquent d'office (`planListImport` retourne un plan vide) ; `ok` et
 *  `off-target` le laissent passer — sans garantir la moindre ligne pour autant,
 *  le filtrage ligne à ligne de `planListImport` (`list-import.ts:198-205`)
 *  pouvant tout écarter. C'est un plafond, pas un décompte. */
function verdictImports(verdict: CountryVerdict): boolean {
  return verdict.kind === "ok" || verdict.kind === "off-target";
}

export function resolveImportOutcome(
  result: MangoolsImportResult,
): MangoolsImportOutcome {
  if (!result.ok) {
    // `quota` couvre DEUX refus : la limite de requêtes du compte Mangools
    // (sans payload) et le quota de mots-clés du forfait (avec). Le payload
    // porte la limite à afficher, donc il remonte tel quel.
    return { kind: "failed", error: result.error, quota: result.quota };
  }
  const { verdicts } = result;
  if (result.inserted > 0) {
    return { kind: "imported", inserted: result.inserted, verdicts };
  }
  const written = Object.values(verdicts).filter(verdictImports).length;
  return written > 0
    ? { kind: "already", lists: written, verdicts }
    : { kind: "none", verdicts };
}

/**
 * Le drapeau « un import a DÉJÀ créé des lignes », RÉMANENT.
 *
 * Il ne peut pas être dérivé du dernier retour : l'étape s'utilise en plusieurs
 * clics. On importe une liste (250 mots-clés créés), on en coche une seconde
 * qui ne crée rien (`already`, `none`, ou un échec) — et si la décision ne lit
 * que le dernier retour, le « Continuer » DISPARAÎT. Ne resterait alors que la
 * sortie « je n'ai pas encore de mots-clés », qui avance SANS rechargement :
 * l'étape suivante recevrait les candidats calculés côté serveur AVANT
 * l'import, donc un réservoir annoncé vide sur un réservoir peuplé.
 *
 * `null` (le retour effacé avant un nouvel essai) ne désarme rien non plus,
 * sinon le bouton basculerait pendant toute la transition d'import.
 */
export function resolveEverImported(
  previous: boolean,
  outcome: MangoolsImportOutcome | null,
): boolean {
  return previous || outcome?.kind === "imported";
}

/**
 * Le « Continuer » de l'étape — donc le RECHARGEMENT DUR — n'est offert que si
 * quelque chose a réellement été écrit : une saisie à la main, ou un import qui
 * a créé au moins une ligne (à un moment quelconque de l'étape, cf.
 * `resolveEverImported`).
 *
 * Cette décision vit ICI et non dans le composant : dans le `.tsx` elle
 * échappait à toute mutation, faute d'environnement de rendu.
 */
export function resolveCanContinue(
  addedCount: number,
  everImported: boolean,
): boolean {
  return addedCount > 0 || everImported;
}

/**
 * L'état de panneau qu'un retour d'import IMPOSE, `null` pour n'y pas toucher.
 *
 * `no_connector` remonte aussi bien du listage que de la boucle d'IMPORT (jeton
 * révoqué chez Mangools, ou retiré par un autre administrateur entre le listage
 * et le clic). Le chemin de listage ramène déjà au champ de jeton
 * (`resolveListsPanelState`) ; sans le même geste ici, le message dirait
 * « recolle ton token » alors qu'aucun champ de jeton n'est à l'écran, et la
 * seule issue serait de recharger la page.
 */
export function resolveOutcomePanelState(
  outcome: MangoolsImportOutcome | null,
): MangoolsPanelState | null {
  if (outcome?.kind === "failed" && outcome.error === "no_connector") {
    return { kind: "connect" };
  }
  return null;
}

/**
 * Les cases à conserver après un retour d'import.
 *
 * Un import TRAITÉ les purge : ces listes viennent de passer, les laisser
 * cochées rendrait le second clic parfaitement naturel alors qu'il ne ferait
 * que refaire le travail. Un ÉCHEC les garde — le geste attendu est
 * « réessayer », et il doit rester à un clic.
 */
export function resolveSelectionsAfterImport(
  selections: readonly ListSelection[],
  outcome: MangoolsImportOutcome | null,
): ListSelection[] {
  if (outcome === null || outcome.kind === "failed") return [...selections];
  return [];
}

// Clés de message des verdicts de pays : QUATRE, et non trois. Un site sans
// aucun pays cible produit lui aussi un `off-target`, avec une liste de cibles
// VIDE — « ton site cible FR, cette liste est en US » ne veut pas dire « ton
// site ne déclare aucun pays cible », et réutiliser la même phrase afficherait
// un avertissement sur chaque liste sans qu'on comprenne pourquoi.

/** Union DISCRIMINÉE et non un sac de champs facultatifs : le composant fait un
 *  `switch` sur `key`, et chaque branche reçoit exactement les valeurs
 *  d'interpolation de SON message. Un champ oublié casse la compilation au lieu
 *  d'afficher « ton site cible  » avec un trou. */
export type VerdictNotice = {
  listId: string;
  /** Nom de la liste, pour que l'avertissement désigne SA liste. */
  listName: string;
  /** `true` quand la liste n'a produit AUCUNE ligne. */
  blocking: boolean;
} & (
  | {
      key: "mangoolsVerdictOffTarget";
      values: { country: string; targets: string; lang: string };
    }
  | {
      key: "mangoolsVerdictNoTargets";
      values: { country: string; lang: string };
    }
  | { key: "mangoolsVerdictMixed"; values: { countries: string } }
  | { key: "mangoolsVerdictUnknown"; values: Record<string, never> }
);

/**
 * Les verdicts à dire, un par liste. Un verdict `ok` ne produit RIEN : rien à
 * signaler, et une ligne « tout va bien » par liste noierait les deux qui
 * comptent.
 *
 * `lang` accompagne TOUJOURS un import accepté : le format Mangools ne
 * transporte aucune langue, donc la ligne écrite porte le pays de la LISTE et
 * la langue du SITE. Ne dire que le pays laisserait l'utilisateur découvrir le
 * couple plus tard — et c'est précisément celui que ce dépôt a corrigé deux
 * fois.
 */
export function resolveVerdictNotices(
  verdicts: Record<string, CountryVerdict>,
  lists: readonly { id: string; name: string }[],
  siteLang: string,
): VerdictNotice[] {
  const names = new Map(lists.map((l) => [l.id, l.name]));
  const out: VerdictNotice[] = [];
  for (const [listId, verdict] of Object.entries(verdicts)) {
    // Repli sur l'identifiant : une liste absente du panneau (rechargée entre
    // temps) doit rester désignable, jamais un avertissement anonyme.
    const listName = names.get(listId) ?? listId;
    switch (verdict.kind) {
      case "ok":
        break;
      case "off-target":
        out.push(
          verdict.siteCountries.length === 0
            ? {
                listId,
                listName,
                key: "mangoolsVerdictNoTargets",
                blocking: false,
                values: { country: verdict.country, lang: siteLang },
              }
            : {
                listId,
                listName,
                key: "mangoolsVerdictOffTarget",
                blocking: false,
                values: {
                  country: verdict.country,
                  targets: verdict.siteCountries.join(", "),
                  lang: siteLang,
                },
              },
        );
        break;
      case "mixed":
        out.push({
          listId,
          listName,
          key: "mangoolsVerdictMixed",
          blocking: true,
          values: { countries: verdict.countries.join(", ") },
        });
        break;
      case "unknown":
        out.push({
          listId,
          listName,
          key: "mangoolsVerdictUnknown",
          blocking: true,
          values: {},
        });
        break;
    }
  }
  return out;
}

export type MangoolsHandlerDeps = {
  /** `listMangoolsListsAction` en production. Le TYPE de ce champ est ce qui
   *  garde la taxonomie d'erreur redéclarée alignée sur celle du serveur. */
  listLists: () => Promise<MangoolsListsResult>;
  /** `importMangoolsListsAction` en production. */
  importLists: (input: {
    siteId: string;
    selections: ImportSelectionPayload[];
  }) => Promise<MangoolsImportResult>;
  onPanel: (state: MangoolsPanelState) => void;
  /** Retour d'import à afficher, `null` pour l'effacer avant un nouvel essai.
   *
   *  AUCUN `onDone` ici : le rechargement dur n'est pas déclenché par l'import
   *  mais par le bouton « Continuer » de l'étape, que le composant ne rend que
   *  sur un retour `imported`. Recharger depuis ce handler emporterait les
   *  verdicts de pays avant qu'ils soient lus. */
  onOutcome: (outcome: MangoolsImportOutcome | null) => void;
};

export function createMangoolsHandlers(
  siteId: string,
  deps: MangoolsHandlerDeps,
): {
  load: () => Promise<void>;
  importSelected: (selections: readonly ListSelection[]) => Promise<void>;
} {
  return {
    async load() {
      // L'attente est annoncée AVANT l'appel : un listage passe par le throttle
      // de l'organisation (~2 req/s partagés), il peut prendre une seconde.
      deps.onPanel({ kind: "loading" });
      deps.onOutcome(null);
      try {
        deps.onPanel(resolveListsPanelState(await deps.listLists()));
      } catch {
        // /onboarding n'a AUCUNE error boundary : sans ce filet, une panne
        // réseau ou une session sans organisation active laisserait le panneau
        // en « chargement » pour toujours.
        deps.onPanel({ kind: "failed", error: "upstream" });
      }
    },
    async importSelected(selections: readonly ListSelection[]) {
      // Aucune liste cochée : pas d'aller-retour (l'action rendrait un succès à
      // zéro, indistinguable d'un import réel qui n'a rien créé).
      if (selections.length === 0) return;
      deps.onOutcome(null);
      let outcome: MangoolsImportOutcome;
      try {
        outcome = resolveImportOutcome(
          await deps.importLists({
            siteId,
            selections: buildImportSelections(selections),
          }),
        );
      } catch {
        outcome = { kind: "failed", error: "upstream" };
      }
      deps.onOutcome(outcome);
    },
  };
}
