"use client";

import { useEffect, useState, useTransition } from "react";
import Link from "next/link";
import { ListChecksIcon } from "lucide-react";
import {
  apiQuotaAction,
  createApiImportAction,
  createTargetAction,
  kwfinderListsPreviewAction,
} from "@/app/(app)/sites/[siteId]/keywords/imports/new/actions";
import { OptionSelect, type SelectOption } from "@/components/app/option-select";
import { Spinner } from "@/components/app/spinner";
import { StatusBadge } from "@/components/app/status-badge";
import { ImportJobStatus } from "@/components/keywords/import-job-status";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import { Label } from "@/components/ui/label";
import { listLaunchPlan, listsCensus } from "@/lib/api-import-lists";
import { defaultChoice, type CensusCountry, type CountryChoice } from "@/lib/import-countries";
import {
  formatMoment,
  LIST_IDS_MAX,
  type KeywordListRef,
  type ListPreview,
  type Quota,
} from "@/lib/integrations";
import { keywordsHref } from "@/lib/keywords-scope";
import { LANGUAGES } from "@/lib/locales";
import type { Target } from "@/lib/sites";
import { cn } from "@/lib/utils";

// La branche API du parcours d'import : **les listes déjà enregistrées dans le compte KWFinder,
// et rien d'autre** (reprise du 2026-09-02).
//
// Les deux modes précédents — découverte d'un domaine, enrichissement par collage — ont quitté
// l'écran. Ils n'ont pas été supprimés : l'adaptateur et le contrat les portent toujours, et
// `docs/pre-prod-checklist.md` dit qu'ils dorment. Ce que l'utilisateur demandait depuis le
// début, c'est de choisir parmi ses listes ; c'est le seul parcours ici.
//
// **Les listes d'abord, la cible ensuite**, et ce n'est pas un choix de mise en page. Une liste
// enregistrée porte le marché de chacun de ses mots-clés — le compte relevé mêle des listes FR
// et US —, donc les marchés proposés à l'étape 2 sont ceux que la sélection contient vraiment.
// C'est l'inverse exact des deux modes dormants, où la cible était un paramètre d'entrée de la
// requête.
//
// Rien de tout cela ne coûte de quota : `GET /kwfinder/lists` et `GET /kwfinder/lists/{id}` ne
// consomment aucune unité (mesuré). Le récapitulatif le dit en chiffres, pas en phrases.
//
// Les étapes vivent dans l'URL (`?step=`), la sélection non : elle n'est reconstructible par
// aucun paramètre. Un rechargement repart donc de l'étape 1, **et l'URL le dit** — voir `go`.

const STEPS = [
  { slug: "listes", title: "Listes", description: "Vos listes KWFinder." },
  { slug: "marches", title: "Marchés", description: "Les cibles où importer." },
  { slug: "recapitulatif", title: "Récapitulatif", description: "Ce qui part." },
] as const;

type Step = 1 | 2 | 3;

/** Le quota, ou son absence — `null` tant que le récapitulatif ne l'a pas demandé. */
type QuotaState = { status: "loading" } | { status: "loaded"; quota: Quota };

/** Un import qui est parti, ou qui a été refusé — la boucle sur les cibles n'est pas atomique. */
type LaunchOutcome =
  | {
      key: string;
      label: string;
      status: "launched";
      targetId: string;
      imports: { importId: string; jobId: string; listName: string }[];
    }
  | { key: string; label: string; status: "refused"; reason: string };

const NO_LANGUAGE = "__no_language";

/** Les langues connues, plus celle déjà choisie si CLDR ne la connaît pas. */
function languageOptions(current: string): SelectOption[] {
  const known = LANGUAGES.map((language) => ({ value: language.code, label: language.label }));
  const extra =
    current !== "" && !LANGUAGES.some((language) => language.code === current)
      ? [{ value: current, label: current }]
      : [];
  return [{ value: NO_LANGUAGE, label: "— à choisir —" }, ...extra, ...known];
}

export function ApiImportWizard({
  siteId,
  integrationId,
  lists,
  listsError,
  targets,
  defaultTargetId,
}: {
  siteId: string;
  integrationId: string;
  /** Les listes du compte, **chargées par le serveur** (`loadKwfinderLists`) : pas de squelette. */
  lists: KeywordListRef[];
  /** Le motif quand le fournisseur n'a pas répondu — affiché, jamais tu. */
  listsError: string | null;
  targets: Target[];
  defaultTargetId: string;
}) {
  const [step, setStep] = useState<Step>(1);
  const [picked, setPicked] = useState<string[]>([]);
  const [previews, setPreviews] = useState<ListPreview[]>([]);
  //  La sélection **déjà recensée**, sous une forme comparable. Sans elle, « Retour » puis
  //  « Continuer » sans rien changer relançait un recensement complet : mesuré à ~18 s pour la
  //  plus grosse liste du compte relevé, et une liste par requête espacée du débit du
  //  fournisseur. Rien n'a bougé côté fournisseur entre les deux clics — on garde le résultat.
  const [censusedFor, setCensusedFor] = useState<string | null>(null);
  const [selection, setSelection] = useState<Record<string, CountryChoice>>({});
  const [createdTargets, setCreatedTargets] = useState<Target[]>([]);
  const [quota, setQuota] = useState<QuotaState | null>(null);
  const [outcomes, setOutcomes] = useState<LaunchOutcome[]>([]);
  const [progress, setProgress] = useState<{ done: number; total: number; what: string } | null>(
    null,
  );
  const [error, setError] = useState<string | null>(null);
  const [pending, startTransition] = useTransition();

  /** L'étape courante, écrite dans l'URL sans repasser par le serveur. */
  const go = (next: Step) => {
    setStep(next);
    const url = new URL(window.location.href);
    url.searchParams.set("step", STEPS[next - 1].slug);
    window.history.replaceState(null, "", url);
  };

  useEffect(() => {
    //  L'URL est remise à l'étape 1 au montage : la sélection ne survit pas à un rechargement,
    //  et une URL qui annoncerait l'étape 3 devant un écran vide mentirait.
    const url = new URL(window.location.href);
    if (url.searchParams.get("step") !== STEPS[0].slug) {
      url.searchParams.set("step", STEPS[0].slug);
      window.history.replaceState(null, "", url);
    }
  }, []);

  /**
   * Relit le quota du fournisseur. Appelé à **chaque** affichage du récapitulatif et après un
   * lancement — jamais ailleurs.
   *
   * Il n'y a rien à économiser : `GET /kwfinder/limits` ne facture aucune unité (mesuré le
   * 2026-09-02, les compteurs `resources` ne bougent pas). Et le chiffre change sans nous —
   * l'utilisateur se sert de KWFinder dans son propre navigateur pendant qu'il est ici, son
   * quota `related-keywords` descend, et un reste lu une seule fois à la première ouverture de
   * l'étape affiche alors une valeur périmée.
   *
   * Ce qui interdit d'en faire davantage : le fournisseur plafonne le débit à trois requêtes
   * par courte période. Cette fonction ne dépend donc que de **gestes** — deux clics — et
   * jamais d'un rendu ni d'une frappe.
   *
   * Hors de `startTransition`, contrairement à l'appel qu'elle remplace : cette lecture ne
   * doit ni désactiver « Lancer l'import » ni faire dire « Envoi… » à l'écran. Un quota
   * illisible s'affiche comme inconnu et **ne bloque jamais** le lancement.
   */
  const refreshQuota = () => {
    setQuota({ status: "loading" });
    void apiQuotaAction(integrationId).then((result) => {
      //  Un quota illisible ne bloque pas le lancement : la clé peut très bien marcher pour
      //  l'import et cet appel-ci avoir raté sur un aléa réseau.
      setQuota({
        status: "loaded",
        quota:
          "error" in result
            ? {
                provider: "mangools",
                resource: "related-keywords",
                limit: null,
                remaining: null,
                error: result.error,
              }
            : result.quota,
      });
    });
  };

  // Cibles du site telles qu'elles sont **maintenant** : l'instantané serveur plus ce que cet
  // écran a créé. Dédoublonnées par identifiant, au cas où un rafraîchissement les recouvrirait.
  const allTargets = [
    ...targets,
    ...createdTargets.filter((created) => !targets.some((known) => known.id === created.id)),
  ];
  const currentTarget = allTargets.find((target) => target.id === defaultTargetId);

  const { countries, unresolved } = listsCensus(previews);
  const choiceOf = (country: CensusCountry): CountryChoice =>
    selection[country.code] ?? defaultChoice(country, allTargets, currentTarget);
  const setChoice = (country: CensusCountry, patch: Partial<CountryChoice>) => {
    setSelection((previous) => ({
      ...previous,
      [country.code]: { ...choiceOf(country), ...patch },
    }));
  };

  const plan = listLaunchPlan({ targets: allTargets, previews, countries, choice: choiceOf });
  const totalRows = plan.imports.reduce((sum, entry) => sum + entry.rows, 0);

  const toggle = (id: string, checked: boolean) => {
    setPicked((previous) =>
      checked
        ? [...previous.filter((item) => item !== id), id]
        : previous.filter((item) => item !== id),
    );
    //  Le recensement portait sur l'ancienne sélection : les cases de marchés repartent des
    //  propositions plutôt que de survivre à moitié.
    setSelection({});
  };

  /** La sélection courante, sous une forme stable — l'ordre des cases cochées n'en fait pas une
   * autre sélection. */
  const pickedKey = [...picked].sort().join("|");

  /** Étape 1 → 2 : le recensement des marchés de la sélection. Gratuit, mais un appel par liste. */
  const openMarkets = () => {
    setError(null);
    if (censusedFor === pickedKey && previews.length > 0) {
      //  Déjà recensée : on rouvre l'étape avec ce qu'on a, plutôt que de refaire une minute
      //  d'appels pour obtenir exactement les mêmes marchés.
      go(2);
      return;
    }
    startTransition(async () => {
      const result = await kwfinderListsPreviewAction({
        integration_id: integrationId,
        list_ids: picked,
        site_target_id: defaultTargetId,
      });
      if ("error" in result) {
        setError(result.error);
        return;
      }
      setPreviews(result.items);
      setCensusedFor(pickedKey);
      setSelection({});
      go(2);
    });
  };

  /** Étape 2 → 3 : le récapitulatif s'ouvre, et il relit le quota — **à chaque fois**, pas
   * seulement la première (voir `refreshQuota`). */
  const openSummary = () => {
    setError(null);
    go(3);
    refreshQuota();
  };

  /**
   * Le lancement : une cible manquante est créée, puis ses imports sont enfilés, et on
   * recommence. Séquentiel, pour deux raisons qui ne sont pas du confort : le plafond de cibles
   * par site se vérifie sur un compte qui bouge à chaque création, et un échec au milieu doit
   * laisser un état lisible plutôt qu'une grappe de promesses à moitié tenues.
   *
   * Un seul appel par cible, portant toutes ses listes : c'est l'api qui déplie en un import
   * par (liste, cible), donc la dédup s'arbitre d'un seul endroit.
   */
  const launch = () => {
    if (plan.blockers.length > 0) return;
    setError(null);
    startTransition(async () => {
      const results: LaunchOutcome[] = [];
      const fresh: Target[] = [];
      for (const entry of plan.imports) {
        setProgress({ done: results.length, total: plan.imports.length, what: entry.label });
        let entryTargetId = entry.targetId;
        if (entryTargetId === null) {
          const created = await createTargetAction({
            site_id: siteId,
            country: entry.country,
            language: entry.language,
          });
          if ("error" in created) {
            results.push({
              key: entry.key,
              label: entry.label,
              status: "refused",
              reason: created.error,
            });
            continue;
          }
          fresh.push(created.target);
          entryTargetId = created.target.id;
        }
        const result = await createApiImportAction({
          site_id: siteId,
          integration_id: integrationId,
          site_target_id: entryTargetId,
          list_ids: entry.listIds,
        });
        if ("error" in result) {
          results.push({
            key: entry.key,
            label: entry.label,
            status: "refused",
            reason: result.error,
          });
          continue;
        }
        results.push({
          key: entry.key,
          label: entry.label,
          status: "launched",
          targetId: entryTargetId,
          imports: result.items.map((item) => ({
            importId: item.import_id,
            jobId: item.job_id,
            //  Repli **par identifiant** et non par position : `list_id` est toujours rendu en
            //  mode liste, là où `list_name` peut manquer si le fournisseur n'a pas répondu au
            //  listing du compte. Indexer sur le rang tiendrait tant que l'api conserve l'ordre
            //  de la sélection — une garantie qu'on ne lui a jamais demandée.
            listName:
              item.list_name ?? entry.listNames[entry.listIds.indexOf(item.list_id ?? "")] ?? "",
          })),
        });
      }
      if (fresh.length > 0) setCreatedTargets((previous) => [...previous, ...fresh]);
      setProgress(null);
      setOutcomes(results);
      //  Le lancement est passé : le quota affiché date d'avant. On le relit — gratuitement.
      //  Quand tout est parti, l'écran de conclusion remplace le récapitulatif et personne ne
      //  lira ce chiffre ; quand une cible a été refusée, le récapitulatif reste affiché, et
      //  sans cette relecture il continuerait d'annoncer le reste d'avant.
      refreshQuota();
      if (!results.some((item) => item.status === "launched")) {
        setError("Aucun import n'a pu être lancé — le détail est ci-dessous.");
      }
    });
  };

  const launched = outcomes.filter((item) => item.status === "launched");
  const refused = outcomes.filter((item) => item.status === "refused");

  if (launched.length > 0) {
    const total = launched.reduce((sum, item) => sum + item.imports.length, 0);
    return (
      <Card>
        <CardHeader>
          <CardTitle>{total > 1 ? `${total} imports lancés` : "Import lancé"}</CardTitle>
          <CardDescription>
            Vous pouvez quitter cette page : le traitement continue côté serveur et le rapport vous
            attendra dans l'onglet Imports.
          </CardDescription>
        </CardHeader>
        <CardContent className="flex flex-col gap-4">
          {refused.length > 0 ? (
            <div role="alert" className="flex flex-col gap-2 rounded-lg border p-4 text-sm">
              {refused.map((item) => (
                <p key={item.key} className="flex flex-wrap items-start gap-2">
                  <Badge variant="destructive">{item.label}</Badge>
                  <span className="text-destructive min-w-0">
                    {item.status === "refused" ? item.reason : ""}
                  </span>
                </p>
              ))}
            </div>
          ) : null}
          {launched.map((item) =>
            item.status === "launched"
              ? item.imports.map((entry) => (
                  <div key={entry.importId} className="flex flex-col gap-2">
                    <p className="flex flex-wrap items-center gap-2 text-sm font-medium">
                      <span className="truncate">{entry.listName}</span>
                      <Badge variant="secondary">{item.label}</Badge>
                    </p>
                    <ImportJobStatus
                      jobId={entry.jobId}
                      importId={entry.importId}
                      siteId={siteId}
                      targetId={item.targetId}
                    />
                  </div>
                ))
              : null,
          )}
        </CardContent>
        <CardFooter className="flex-wrap gap-2">
          <Button
            nativeButton={false}
            render={
              <Link
                href={keywordsHref(siteId, "imports", {
                  target: launched[0]?.status === "launched" ? launched[0].targetId : undefined,
                })}
              />
            }
          >
            Retour aux imports
          </Button>
        </CardFooter>
      </Card>
    );
  }

  const current = STEPS[step - 1];

  return (
    <div className="flex flex-col gap-4">
      <ol className="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
        {STEPS.map((item, index) => (
          <li
            key={item.slug}
            aria-current={step === index + 1 ? "step" : undefined}
            className={cn("flex items-center gap-2", step === index + 1 && "text-foreground")}
          >
            <span
              className={cn(
                "flex size-5 items-center justify-center rounded-full border text-xs",
                step === index + 1 && "bg-primary text-primary-foreground border-transparent",
                step > index + 1 && "border-foreground/40",
              )}
            >
              {index + 1}
            </span>
            {item.title}
          </li>
        ))}
      </ol>

      <Card>
        <CardHeader>
          <CardTitle>{current.title}</CardTitle>
          <CardDescription>{current.description}</CardDescription>
        </CardHeader>

        <CardContent className="flex flex-col gap-4">
          {step === 1 ? (
            lists.length === 0 ? (
              <Empty>
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <ListChecksIcon />
                  </EmptyMedia>
                  <EmptyTitle>Aucune liste sur ce compte</EmptyTitle>
                  {listsError ? <EmptyDescription>{listsError}</EmptyDescription> : null}
                </EmptyHeader>
              </Empty>
            ) : (
              //  Hauteur bornée et défilement INTERNE (règle responsive) : huit listes
              //  repousseraient sinon « Continuer » hors de l'écran, et la ligne se replie à
              //  375 px plutôt que de déborder.
              <ul className="max-h-80 divide-y overflow-y-auto rounded-lg border">
                {lists.map((list) => {
                  const checked = picked.includes(list.id);
                  return (
                    <li key={list.id} className="flex flex-wrap items-center gap-x-3 gap-y-2 p-3">
                      <Checkbox
                        id={`list-${list.id}`}
                        checked={checked}
                        disabled={pending || (!checked && picked.length >= LIST_IDS_MAX)}
                        onCheckedChange={(value) => toggle(list.id, value === true)}
                      />
                      {/* `min-w-40` et non `min-w-0` : sous ~500 px, un nom qui a le droit de
                          rétrécir indéfiniment se tronque en « ADVM… » et trois listes
                          deviennent indiscernables. Avec un plancher, ce sont le badge et la
                          date qui passent à la ligne — le nom, lui, reste lisible. */}
                      <Label htmlFor={`list-${list.id}`} className="min-w-40 flex-1 font-normal">
                        <span className="truncate">{list.name}</span>
                      </Label>
                      <Badge variant="secondary">
                        {list.keyword_count} mot{list.keyword_count > 1 ? "s" : ""}-clé
                        {list.keyword_count > 1 ? "s" : ""}
                      </Badge>
                      {list.updated_at ? (
                        <span className="text-muted-foreground shrink-0 text-xs">
                          {formatMoment(list.updated_at)}
                        </span>
                      ) : null}
                    </li>
                  );
                })}
              </ul>
            )
          ) : null}

          {step === 2 ? (
            countries.length === 0 && unresolved.length === 0 ? (
              <Empty>
                <EmptyHeader>
                  <EmptyMedia variant="icon">
                    <ListChecksIcon />
                  </EmptyMedia>
                  <EmptyTitle>Aucun marché dans ces listes</EmptyTitle>
                </EmptyHeader>
              </Empty>
            ) : (
              <ul className="max-h-80 divide-y overflow-y-auto rounded-lg border">
                {countries.map((country) => {
                  const chosen = choiceOf(country);
                  const language = chosen.language.trim().toLowerCase();
                  const existing = allTargets.find(
                    (target) => target.country === country.code && target.language === language,
                  );
                  return (
                    <li
                      key={country.code}
                      className="flex flex-wrap items-center gap-x-3 gap-y-2 p-3"
                    >
                      <Checkbox
                        id={`market-${country.code}`}
                        checked={chosen.selected}
                        disabled={pending}
                        onCheckedChange={(value) =>
                          setChoice(country, { selected: value === true })
                        }
                      />
                      {/* Même plancher qu'à l'étape 1, et pour la même raison : « Émirats arabes
                          unis » ne doit pas se réduire à « Émir… » au pouce. */}
                      <Label
                        htmlFor={`market-${country.code}`}
                        className="min-w-40 flex-1 font-normal"
                      >
                        {/* Le libellé vient de l'api : `knownCountry` (lib/locales) rendrait
                            « France » pour AU, SG, HK, NZ, IL et JP. */}
                        <span className="truncate">{country.label}</span>
                        <span className="text-muted-foreground shrink-0 text-xs">
                          {country.rows} mot{country.rows > 1 ? "s" : ""}-clé
                          {country.rows > 1 ? "s" : ""}
                        </span>
                      </Label>
                      <Badge variant={existing ? "secondary" : "outline"}>
                        {existing ? "déjà là" : "à créer"}
                      </Badge>
                      <OptionSelect
                        id={`market-language-${country.code}`}
                        label={`Langue de la cible ${country.label}`}
                        className="w-full sm:w-44"
                        options={languageOptions(language)}
                        value={language === "" ? NO_LANGUAGE : language}
                        disabled={pending}
                        onValueChange={(value) =>
                          setChoice(country, { language: value === NO_LANGUAGE ? "" : value })
                        }
                      />
                    </li>
                  );
                })}
                {unresolved.map((item) => (
                  <li
                    key={item.raw}
                    className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 p-3 text-sm"
                  >
                    <span className="min-w-0 flex-1 truncate">{item.label}</span>
                    <span className="text-xs">
                      {item.rows} mot{item.rows > 1 ? "s" : ""}-clé{item.rows > 1 ? "s" : ""}
                    </span>
                    {/* Ni un pays, ni une cible : une localité ou un libellé hors tables.
                        Montré pour révéler le trou, jamais proposé — ces mots-clés seront
                        comptés « pays non reconnu » dans le rapport. */}
                    <Badge variant="outline">non reconnu</Badge>
                  </li>
                ))}
              </ul>
            )
          ) : null}

          {step === 3 ? (
            <dl className="flex flex-col gap-3 text-sm">
              <SummaryRow label="Imports">
                <span className="flex flex-wrap items-center gap-2">
                  <span className="font-medium">
                    {plan.imports.reduce((sum, entry) => sum + entry.listIds.length, 0)}
                  </span>
                  <span className="text-muted-foreground">
                    sur {plan.imports.length} cible{plan.imports.length > 1 ? "s" : ""}
                  </span>
                  {plan.creations > 0 ? (
                    <Badge variant="outline">
                      {plan.creations} cible{plan.creations > 1 ? "s" : ""} à créer
                    </Badge>
                  ) : null}
                </span>
              </SummaryRow>
              {plan.imports.map((entry) => (
                <SummaryRow key={entry.key} label={entry.label}>
                  <span className="flex flex-wrap items-center gap-2">
                    {/* Clé par **identifiant** : deux listes du compte peuvent porter le même
                        nom, et deux clés React identiques se recouvriraient. */}
                    {entry.listNames.map((name, index) => (
                      <Badge key={entry.listIds[index] ?? name} variant="secondary">
                        {name}
                      </Badge>
                    ))}
                    <span className="text-muted-foreground">
                      {entry.rows} mot{entry.rows > 1 ? "s" : ""}-clé{entry.rows > 1 ? "s" : ""}
                    </span>
                  </span>
                </SummaryRow>
              ))}
              {plan.droppedLists.length > 0 ? (
                <SummaryRow label="Non importées">
                  <span className="flex flex-wrap items-center gap-2">
                    {plan.droppedLists.map((dropped) => (
                      <Badge key={dropped.id} variant="outline">
                        {dropped.name}
                      </Badge>
                    ))}
                  </span>
                </SummaryRow>
              ) : null}
              {/* Mesuré le 2026-09-02 : lire une liste ne bouge aucun compteur du fournisseur.
                  Le chiffre le dit — aucune phrase n'a à l'expliquer. */}
              <SummaryRow label="Coût">
                <span className="flex flex-wrap items-center gap-2">
                  <span className="font-medium">0 requête facturée</span>
                  <span className="text-muted-foreground">
                    pour {totalRows} mot{totalRows > 1 ? "s" : ""}-clé{totalRows > 1 ? "s" : ""}
                  </span>
                </span>
              </SummaryRow>
              <SummaryRow label="Quota restant">
                <QuotaValue state={quota} />
              </SummaryRow>
            </dl>
          ) : null}

          {step > 1 && plan.blockers.length > 0 ? (
            //  `role="alert"` et **tous** les motifs : c'est un avertissement bloquant, pas une
            //  note de bas de page — en n'en montrant qu'un, l'utilisateur corrigerait au
            //  compte-gouttes, et un lecteur d'écran n'en entendrait aucun.
            <ul role="alert" className="flex flex-col gap-1 rounded-lg border p-4 text-sm">
              {plan.blockers.map((blocker) => (
                <li key={blocker}>{blocker}</li>
              ))}
            </ul>
          ) : null}

          {error ? (
            <p role="alert" className="text-destructive text-sm">
              {error}
            </p>
          ) : null}

          {pending || progress ? (
            // Règle du 2026-09-01 : jamais d'écran figé pendant un traitement. Un `div`, jamais
            // un `<p>` — le spinner ldrs est fait de `<div>`.
            <div className="flex flex-wrap items-center gap-2 text-sm">
              <Spinner size="sm" />
              {progress
                ? `${progress.what} — ${progress.done + 1} sur ${progress.total}…`
                : step === 1
                  ? "Lecture des listes…"
                  : "Envoi…"}
            </div>
          ) : null}
        </CardContent>

        <CardFooter className="flex-wrap gap-2">
          {step > 1 ? (
            <Button
              type="button"
              variant="outline"
              disabled={pending}
              onClick={() => go(step === 3 ? 2 : 1)}
            >
              Retour
            </Button>
          ) : null}
          {step === 1 ? (
            <Button type="button" disabled={pending || picked.length === 0} onClick={openMarkets}>
              Continuer
            </Button>
          ) : null}
          {step === 2 ? (
            <Button
              type="button"
              disabled={pending || plan.blockers.length > 0}
              onClick={openSummary}
            >
              Continuer
            </Button>
          ) : null}
          {step === 3 ? (
            <Button type="button" disabled={pending || plan.blockers.length > 0} onClick={launch}>
              Lancer l'import
            </Button>
          ) : null}
        </CardFooter>
      </Card>
    </div>
  );
}

function SummaryRow({ label, children }: { label: string; children: React.ReactNode }) {
  return (
    <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
      <dt className="text-muted-foreground w-32 shrink-0 truncate">{label}</dt>
      <dd className="min-w-0 flex-1">{children}</dd>
    </div>
  );
}

function QuotaValue({ state }: { state: QuotaState | null }) {
  if (state === null || state.status === "loading") {
    return <span className="text-muted-foreground">Lecture…</span>;
  }
  const { quota } = state;
  if (quota.remaining === null) {
    return (
      <span className="flex flex-wrap items-center gap-2">
        <StatusBadge tone="neutral">Quota inconnu</StatusBadge>
        {quota.error ? <span className="text-destructive min-w-0">{quota.error}</span> : null}
      </span>
    );
  }
  return (
    <span className="flex flex-wrap items-baseline gap-x-2">
      <span className="font-medium">{quota.remaining}</span>
      {quota.limit === null ? null : (
        <span className="text-muted-foreground">sur {quota.limit} requêtes</span>
      )}
    </span>
  );
}
