"use client";

import { useCallback, useState, useTransition } from "react";
import Link from "next/link";
import {
  createImportAction,
  createTargetAction,
  importDateAction,
  keywordListsAction,
  previewImportAction,
} from "@/app/(app)/sites/[siteId]/keywords/imports/new/actions";
import { UploadDropzone } from "@/components/jobs/upload-dropzone";
import { OptionSelect, type SelectOption } from "@/components/app/option-select";
import { SearchCombobox, type SearchSuggestion } from "@/components/app/search-combobox";
import { Spinner } from "@/components/app/spinner";
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 {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { defaultChoice, type CensusCountry, type CountryChoice } from "@/lib/import-countries";
import {
  addImportFile,
  effectiveFormat,
  listSuggestions,
  mappingDirty,
  mappingReady,
  mergedCensus,
  multiLaunchPlan,
  needsMapping,
  patchImportFile,
  readyFiles,
  removeImportFile,
  resolveDestination,
  type ImportFile,
} from "@/lib/import-files";
import {
  fetchedAtIso,
  localDay,
  formatLabel,
  GENERIC_FORMAT,
  IMPORT_FORMATS,
  MAX_UPLOAD_BYTES,
  suggestedListName,
  uploadResultSchema,
  type ImportPreview,
} from "@/lib/imports";
import { CANONICAL_FIELDS, CANONICAL_LABELS, type KeywordList } from "@/lib/keywords";
import { keywordsHref } from "@/lib/keywords-scope";
import { formatTarget, LANGUAGES } from "@/lib/locales";
import type { Target } from "@/lib/sites";
import { cn } from "@/lib/utils";

// Wizard d'import en trois étapes (spec Imports §1.3–1.6, §7.4), pattern `SiteWizard` :
// carte centrée, état local `step`, Server Actions pour tout sauf l'upload des fichiers.
//
// Depuis le cloisonnement par pays (§4.2–4.4), l'étape 3 n'a plus une seule branche : un export
// qui porte le pays de chaque ligne (KWFinder, CSV générique avec colonne pays) est recensé sur
// **tout** le fichier, l'utilisateur coche les marchés qu'il veut, et le wizard lance **un job
// par cible** sur le fichier déjà envoyé — sans aucun re-transfert, le `sha256` suffit.
// Un fichier sans colonne pays (Semrush) ne voit rien de tout cela et se comporte comme avant.
//
// Depuis SEOINSIGHT-57, le wizard prend **N fichiers d'un coup** : chacun garde sa détection,
// son mapping et sa colonne pays (étape 2), tous partagent la cible, les pays cochés et la
// liste de destination (étape 3). Le lot lance donc N × M jobs. Aucun changement d'api : ce
// sont N appels aux endpoints existants, et `lib/import-files.ts` tient les comptes qui ne
// s'additionnent pas — un marché visé par deux fichiers ne crée qu'une cible et ne remplit
// qu'une case du plafond du site.
//
// Ce que la boucle ne peut pas promettre : l'atomicité. Chaque `POST /imports` publie par son
// propre commit, chaque `POST /sites/{id}/targets` aussi — l'écran rend donc un état **par
// import**, lancé ou refusé avec son motif, jamais un tout-ou-rien.

const STEPS = [
  { title: "Fichiers", description: "Les CSV exportés de votre plateforme SEO." },
  { title: "Format & aperçu", description: "Ce que nous avons reconnu dans chaque fichier." },
  { title: "Cibles & destination", description: "Où atterrissent les mots-clés." },
];

const SAMPLE_COLUMNS = [
  { key: "keyword", label: "Mot-clé" },
  { key: "volume", label: "Volume" },
  { key: "kd", label: "KD" },
  { key: "cpc", label: "CPC" },
  { key: "intent", label: "Intention" },
  { key: "position", label: "Position" },
  { key: "url", label: "URL" },
] as const;

const NONE = "__none";

/**
 * Chargeur muet du champ de destination, quand plus d'une cible est visée : les listes chargées
 * ne valent que pour la cible du sélecteur, en proposer une ailleurs vaudrait un 404. Constante
 * de module pour rester stable d'un rendu à l'autre — `SearchCombobox` l'exige.
 */
const NO_SUGGESTION = async (): Promise<SearchSuggestion[]> => [];

/** Valeur sentinelle du Select de langue : aucune langue choisie (CLDR n'en propose pas). */
const NO_LANGUAGE = "__no_language";

/** Un import qui est parti, ou qui a été refusé — la boucle n'est pas atomique. */
type LaunchOutcome =
  | {
      key: string;
      label: string;
      status: "launched";
      targetId: string;
      importId: string;
      jobId: string;
      listId: string | null;
    }
  | {
      key: string;
      label: string;
      status: "refused";
      reason: string;
      /** Refus que le rejeu forcé peut lever — un doublon de fichier, et rien d'autre. */
      retryable: boolean;
    };

/**
 * Ce refus est-il « ce fichier a déjà été importé sur cette cible » ?
 *
 * Le doublon ne se révèle pas toujours à l'envoi : `POST /imports/files` ne le cherche que
 * contre la cible d'upload, alors qu'un lot multi-pays vise N cibles. Le 409 de `POST /imports`
 * est donc le **second** endroit où il apparaît — et l'api y invite elle-même au rejeu (« …
 * relancer avec « force » pour le rejouer », `api/app/imports/service.py`). Le sniff porte sur
 * cette invitation, ce qui le distingue de l'autre 409 du même endpoint (nom de liste déjà
 * pris), qu'aucun `force` ne lèverait.
 */
function isDuplicateRefusal(status: number | undefined, reason: string): boolean {
  return status === 409 && reason.includes("force");
}

function importedAtLabel(iso: string): string {
  return new Intl.DateTimeFormat("fr-FR", {
    day: "2-digit",
    month: "2-digit",
    year: "numeric",
  }).format(new Date(iso));
}

function cellValue(row: ImportPreview["sample"][number], key: string): string {
  const value = row[key as keyof typeof row];
  if (value === null || value === undefined) return "—";
  return Array.isArray(value) ? value.join(", ") : String(value);
}

function sizeLabel(bytes: number): string {
  return `${(bytes / 1024 / 1024).toFixed(2)} Mio`;
}

/**
 * Options du sélecteur de langue d'un pays du recensement.
 *
 * `LANGUAGES` ne couvre pas les 184 langues de CLDR : une proposition hors table (et la langue
 * d'une cible déjà créée sur une autre machine) est ajoutée telle quelle, sinon le Select
 * porterait une valeur sans option correspondante et n'afficherait rien du tout.
 */
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 ImportWizard({
  siteId,
  targets,
  defaultTargetId,
  today,
}: {
  siteId: string;
  targets: Target[];
  defaultTargetId: string;
  /**
   * Jour calculé côté serveur. Il date les métriques importées (`fetched_at`, plus jamais
   * saisi depuis la décision 2026-09-01) et nomme la liste proposée ; `new Date()` au premier
   * rendu client divergerait de l'HTML rendu.
   */
  today: string;
}) {
  const [step, setStep] = useState<1 | 2 | 3>(1);
  /** Les fichiers envoyés, dans l'ordre de dépôt. Identité = `sha256` (voir `import-files`). */
  const [files, setFiles] = useState<ImportFile[]>([]);
  /** Fichier dont l'étape 2 montre la détection et l'aperçu. */
  const [activeSha, setActiveSha] = useState<string | null>(null);
  const [targetId, setTargetId] = useState(defaultTargetId);
  /**
   * Cibles créées depuis cet écran. `targets` est un instantané **serveur** (`imports/new/page`)
   * : une cible créée à l'étape 3 n'y apparaîtra jamais, et la rafraîchir remonterait le wizard
   * — fichiers envoyés, aperçus, mappings et sélection perdus. Elles sont donc fusionnées ici.
   */
  const [createdTargets, setCreatedTargets] = useState<Target[]>([]);
  /** Ce que l'utilisateur a dit de chaque pays recensé. Absent = l'état par défaut du pays. */
  const [selection, setSelection] = useState<Record<string, CountryChoice>>({});
  // `null` tant que les listes de la cible n'ont pas répondu — le champ de destination est
  // alors inerte : sans le catalogue, `resolveDestination` ne peut pas reconnaître un nom
  // existant tapé à l'identique, et la saisie promettrait une création qui vaudrait un 409.
  const [lists, setLists] = useState<KeywordList[] | null>(null);
  /** Liste existante explicitement choisie dans les suggestions, ou `null`. */
  const [listId, setListId] = useState<string | null>(null);
  /** La saisie du champ de destination : un nom de liste à créer, sauf si elle en désigne une. */
  const [listQuery, setListQuery] = useState("");
  // Cible pour laquelle les doublons ont été cherchés : `duplicate_of` ne vaut que pour elle, et
  // l'étape 3 permet d'en changer. Sans ce repère, le dialogue s'ouvrirait à tort.
  const [uploadTargetId, setUploadTargetId] = useState(defaultTargetId);
  const [confirmOpen, setConfirmOpen] = useState(false);
  const [outcomes, setOutcomes] = useState<LaunchOutcome[]>([]);
  /** Où en est la boucle — N fichiers × M cibles, ça peut durer plusieurs secondes. */
  const [progress, setProgress] = useState<{ done: number; total: number; what: string } | null>(
    null,
  );
  const [error, setError] = useState<string | null>(null);
  const [pending, startTransition] = useTransition();

  const usable = readyFiles(files);
  // Envoyés pendant que la boucle d'analyse tournait : ni aperçu, ni motif de refus.
  const unanalysed = files.filter((file) => file.preview === null && file.error === null);
  const active = files.find((file) => file.sha256 === activeSha) ?? usable[0] ?? null;
  // Lié à une constante locale, et non lu depuis `active` : une propriété perd son
  // rétrécissement de type dès qu'un `onValueChange` la relit, et l'étape 2 en est pleine.
  const activePreview = active?.preview ?? null;

  // 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 ferait se
  // recouvrir.
  const allTargets = [
    ...targets,
    ...createdTargets.filter((created) => !targets.some((known) => known.id === created.id)),
  ];
  const currentTarget = allTargets.find((target) => target.id === targetId);

  //  Le recensement de TOUS les fichiers (§4.2 étendu au lot). Toujours un tableau après parse
  //  — `countries` a un `.default([])` côté Zod —, y compris devant une api antérieure.
  const { countries, unresolved } = mergedCensus(usable);
  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 destination = resolveDestination(lists ?? [], listId, listQuery);
  const plan = multiLaunchPlan({
    files: usable,
    targets: allTargets,
    targetId,
    countries,
    choice: choiceOf,
    listName: listQuery,
    existingListId: destination?.id ?? null,
  });
  // Réutiliser une liste existante suppose de l'avoir chargée : `lists` ne vaut que pour la
  // cible du sélecteur. Tant que tous les imports y atterrissent — un seul marché, même arrosé
  // par cinq fichiers —, les listes existantes sont proposées. Dès qu'un second pays est
  // retenu, la destination se donne par son nom : une liste par cible, suffixée de son pays.
  const canPickExistingList =
    plan.imports.length > 0 && plan.imports.every((entry) => entry.targetId === targetId);

  //  `SearchCombobox` exige un chargeur stable : une fermeture recréée à chaque frappe
  //  reconstruirait son débounce, dont le nettoyage annulerait l'appel qui vient d'être
  //  programmé. D'où le `useCallback` sur les seules listes, et le basculement vers la
  //  constante `NO_SUGGESTION` porté par le rendu plutôt que par une dépendance de plus.
  const loadListSuggestions = useCallback(
    async (query: string): Promise<SearchSuggestion[]> =>
      listSuggestions(lists ?? [], query).map((list) => ({
        id: list.id,
        label: list.name,
        hint: String(list.item_count),
      })),
    [lists],
  );

  /**
   * Listes de la cible. Rechargées à chaque changement de cible : une liste appartient à une
   * cible, celles de la précédente n'ont plus rien à faire dans les suggestions — et le choix
   * explicite retombe à zéro, la saisie devenant à nouveau un nom à créer.
   */
  const loadLists = (nextTargetId: string) => {
    setLists(null);
    setListId(null);
    startTransition(async () => {
      const result = await keywordListsAction({ site_target_id: nextTargetId });
      if ("error" in result) {
        // Pas de champ vide et muet : la création par nom reste possible, on le dit.
        setLists([]);
        setError(result.error);
        return;
      }
      setLists(result.lists);
    });
  };

  /** Aperçu d'UN fichier, avec le format et le mapping qu'on veut lui appliquer. */
  const previewOne = async (
    file: ImportFile,
    nextFormat: string | null,
    nextMapping: Record<string, string>,
    nextCountryColumn: string | null,
  ): Promise<Partial<ImportFile>> => {
    const result = await previewImportAction({
      sha256: file.sha256,
      ...(nextFormat ? { format: nextFormat } : {}),
      ...(Object.keys(nextMapping).length > 0 ? { columns: nextMapping } : {}),
      ...(nextCountryColumn ? { country_column: nextCountryColumn } : {}),
      site_target_id: targetId,
    });
    if ("error" in result) return { error: result.error };
    //  « Le mapping, nous on le fait » : quand rien n'a été câblé, l'api propose les colonnes
    //  qu'elle a reconnues et a **déjà** calculé l'aperçu avec. Les `Select` en prennent la
    //  forme pour que l'écran dise la même chose que le tableau. Un mapping déjà saisi n'est
    //  jamais écrasé — l'utilisateur qui a tranché reste souverain.
    const suggested = result.preview.suggested_mapping;
    return {
      preview: result.preview,
      error: null,
      appliedCountryColumn: nextCountryColumn,
      ...(Object.keys(nextMapping).length === 0 && suggested ? { mapping: suggested } : {}),
    };
  };

  /**
   * Étape 1 → 2 : l'aperçu de chaque fichier qui n'en a pas encore.
   *
   * Séquentiel : ce sont des Server Actions, que Next sérialise de toute façon, et un échec au
   * milieu doit laisser une liste lisible — le fichier fautif porte son motif, les autres
   * continuent (règle « un échec n'annule pas les autres »).
   */
  const runPreviews = () => {
    setError(null);
    const todo = files.filter((file) => file.preview === null);
    if (todo.length === 0) {
      setStep(2);
      return;
    }
    const hadReady = files.length > todo.length;
    startTransition(async () => {
      // Chaque écriture est un **updater fonctionnel**, jamais un instantané patché : la
      // dropzone reste ouverte pendant l'analyse, et un envoi qui aboutit au milieu de la
      // boucle serait effacé par le `setFiles` suivant — le fichier serait sur le serveur et
      // nulle part à l'écran. `analysed` ne sert qu'à décider de la suite (fichier affiché,
      // nom proposé) et ne prétend pas être l'état.
      const analysed: ImportFile[] = [];
      let done = 0;
      for (const file of todo) {
        setProgress({ done, total: todo.length, what: `Analyse de ${file.filename}` });
        const patch = await previewOne(file, file.format, file.mapping, file.countryColumn);
        setFiles((previous) => patchImportFile(previous, file.sha256, patch));
        analysed.push({ ...file, ...patch });
        done += 1;
      }
      setProgress(null);
      const ready = readyFiles(analysed);
      if (ready.length === 0 && !hadReady) {
        setError("Aucun fichier n'a pu être analysé — le motif est indiqué sous chacun d'eux.");
        return;
      }
      setActiveSha((current) =>
        current !== null && ready.some((file) => file.sha256 === current)
          ? current
          : (ready[0]?.sha256 ?? null),
      );
      // Le nom proposé vient du premier fichier lisible : c'est le lot qui est nommé, pas
      // chaque export. L'utilisateur reste libre de le remplacer d'un mot.
      setListQuery((value) =>
        value === "" ? suggestedListName(ready[0]?.preview?.format ?? "", today) : value,
      );
      setStep(2);
    });
  };

  /** Ré-aperçu du fichier de l'étape 2, après un changement de format ou de mapping. */
  const rerun = (
    file: ImportFile,
    nextFormat: string | null,
    nextMapping: Record<string, string>,
    nextCountryColumn: string | null,
  ) => {
    setError(null);
    startTransition(async () => {
      const patch = await previewOne(file, nextFormat, nextMapping, nextCountryColumn);
      setFiles((previous) => patchImportFile(previous, file.sha256, patch));
      // Le recensement vient de changer : les cases cochées portaient sur l'ancien. Elles
      // repartent des propositions du nouveau plutôt que de survivre à moitié.
      setSelection({});
    });
  };

  /**
   * Le lancement : une cible manquante est créée, puis son import est enfilé, et on recommence.
   *
   * Séquentiel et non parallèle, 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.
   *
   * Deux mémoires vivent le temps de la boucle, et elles sont ce qui rend le lot possible :
   * les cibles créées à l'instant (deux fichiers sur le même marché ne créent qu'une cible) et
   * les listes créées à l'instant (deux fichiers sur la même cible remplissent la même liste,
   * là où un second `{name}` identique prendrait un 409).
   *
   * `only` rejoue un sous-ensemble : c'est le chemin du rejeu forcé, quand un doublon ne s'est
   * révélé qu'au 409 de `POST /imports` (une cible que l'envoi n'avait pas interrogée). Les
   * imports déjà partis ne sont pas relancés — seuls leurs voisins refusés le sont, et leur
   * état remplace le précédent.
   */
  const launch = (force: boolean, only?: ReadonlySet<string>) => {
    if (plan.blockers.length > 0) return;
    setError(null);
    setConfirmOpen(false);
    const entries =
      only === undefined ? plan.imports : plan.imports.filter((entry) => only.has(entry.key));
    if (entries.length === 0) return;
    startTransition(async () => {
      //  Un seul instant pour tout le lot : les fichiers datent du même jour local (calculé au
      //  clic — plus jamais saisi depuis la décision 2026-09-01).
      const fetchedAt = fetchedAtIso(localDay());
      const results: LaunchOutcome[] = [];
      const fresh: Target[] = [];
      const madeTargets = new Map<string, string>();
      const madeLists = new Map<string, string>();
      for (const entry of entries) {
        const label = usable.length > 1 ? `${entry.label} — ${entry.filename}` : entry.label;
        setProgress({
          done: results.length,
          total: entries.length,
          what: `Lancement ${label}`,
        });
        const file = usable.find((item) => item.sha256 === entry.sha256);
        const format = file === undefined ? null : effectiveFormat(file);
        if (file === undefined || format === null) {
          // Inatteignable aujourd'hui — `plan` sort de `usable`, et un fichier analysé a
          // toujours un format. Un `continue` muet ferait pourtant disparaître l'import du
          // décompte le jour où un refactor le rendrait atteignable : on le compte, et on le
          // dit.
          results.push({
            key: entry.key,
            label,
            status: "refused",
            reason: "Fichier introuvable au moment du lancement — reprenez à l'étape 1.",
            retryable: false,
          });
          continue;
        }
        const couple = `${entry.country}/${entry.language}`;
        let entryTargetId = entry.targetId ?? madeTargets.get(couple) ?? null;
        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,
              status: "refused",
              reason: created.error,
              retryable: false,
            });
            continue;
          }
          fresh.push(created.target);
          entryTargetId = created.target.id;
          madeTargets.set(couple, entryTargetId);
        }
        // Une liste créée par le fichier précédent est réutilisée par son identifiant : c'est
        // ce qui fait atterrir tout le lot dans UNE liste plutôt que dans un 409.
        const listKey = "name" in entry.list ? `${entryTargetId}\u0000${entry.list.name}` : null;
        const known = listKey === null ? undefined : madeLists.get(listKey);
        const list = known === undefined ? entry.list : { id: known };
        const result = await createImportAction({
          site_id: siteId,
          sha256: entry.sha256,
          format,
          site_target_id: entryTargetId,
          // Existante par identifiant, ou à créer par son nom — `ListRef` exige exactement l'un
          // des deux.
          list,
          filename: entry.filename,
          fetched_at: fetchedAt,
          ...(needsMapping(file) ? { columns: file.mapping } : {}),
          ...(needsMapping(file) && file.countryColumn !== null
            ? { country_column: file.countryColumn }
            : {}),
          force,
        });
        if ("error" in result) {
          // 409 : doublon de fichier sur CETTE cible, **ou** nom de liste déjà pris pour elle.
          // Le message relayé dit lequel, et il ne vaut que pour cet import-là. Le premier se
          // rejoue en forçant — l'écran propose le geste plutôt que de laisser l'utilisateur
          // devant une impasse ; le second demande un autre nom, `force` n'y peut rien.
          results.push({
            key: entry.key,
            label,
            status: "refused",
            reason: result.error,
            retryable: !force && isDuplicateRefusal(result.status, result.error),
          });
          continue;
        }
        if (listKey !== null && result.listId !== null) madeLists.set(listKey, result.listId);
        results.push({
          key: entry.key,
          label,
          status: "launched",
          targetId: entryTargetId,
          ...result,
        });
      }
      if (fresh.length > 0) setCreatedTargets((previous) => [...previous, ...fresh]);
      setProgress(null);
      //  Un rejeu partiel **remplace** l'état des seuls imports rejoués : ceux qui étaient
      //  partis au premier tour restent lancés, avec leur suivi de job en cours.
      setOutcomes((previous) =>
        only === undefined
          ? results
          : [...previous.filter((item) => !only.has(item.key)), ...results],
      );
      if (only === undefined && !results.some((item) => item.status === "launched")) {
        setError("Aucun import n'a pu être lancé — le détail est ci-dessous.");
      }
      if (only !== undefined && results.some((item) => item.status === "launched")) {
        setError(null);
      }
    });
  };

  /**
   * Le doublon n'est pas une case à cocher : quand l'envoi a reconnu un fichier sur une cible
   * que le plan retient, le lancement passe d'abord par un dialogue qui dit ce que « relancer »
   * veut dire. Réévalué **par fichier et par cible** : `duplicate_import_id` filtre par (org,
   * cible, sha256), donc un même fichier sur deux marchés ne déclenche rien.
   */
  const duplicates = usable.filter(
    (file) =>
      file.duplicateOf !== null &&
      plan.imports.some(
        (entry) => entry.sha256 === file.sha256 && entry.targetId === uploadTargetId,
      ),
  );

  const submit = () => {
    if (duplicates.length > 0) {
      setConfirmOpen(true);
      return;
    }
    launch(false);
  };

  const current = STEPS[step - 1];
  const launched = outcomes.filter((item) => item.status === "launched");
  const refused = outcomes.filter((item) => item.status === "refused");
  //  Les refus qu'un rejeu forcé lèverait : un doublon découvert au 409, sur une cible que
  //  l'envoi n'avait pas interrogée. Sans ce chemin, l'utilisateur reprenait le 409 en boucle
  //  sans jamais pouvoir dire « oui, rejoue-le ».
  const retryable = refused.filter((item) => item.status === "refused" && item.retryable);
  const retryKeys = new Set(retryable.map((item) => item.key));
  const retryButton = (
    <Button variant="outline" disabled={pending} onClick={() => launch(true, retryKeys)}>
      {retryable.length > 1
        ? `Mettre à jour les ${retryable.length} déjà importés`
        : "Mettre à jour l'import existant"}
    </Button>
  );

  if (launched.length > 0) {
    return (
      <div className="space-y-4">
        <Card>
          <CardHeader>
            <CardTitle>
              {launched.length > 1 ? `${launched.length} 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="space-y-4">
            {refused.length > 0 ? (
              // Chaque import est publié par son propre commit : rien ne garantit le
              // tout-ou-rien, et taire les refus laisserait croire à un lancement complet.
              <div role="alert" className="space-y-2 rounded-lg border p-4 text-sm">
                <ul className="space-y-1">
                  {refused.map((item) => (
                    <li 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.reason}</span>
                    </li>
                  ))}
                </ul>
                {retryable.length > 0 ? (
                  <div className="flex flex-wrap items-center gap-2">
                    {retryButton}
                    <span className="text-muted-foreground">
                      Les métriques de la même date seront mises à jour, sans doublon.
                    </span>
                  </div>
                ) : null}
              </div>
            ) : null}
            {progress ? (
              <p className="flex flex-wrap items-center gap-2 text-sm">
                <Spinner size="sm" />
                {progress.what} — {progress.done + 1} sur {progress.total}…
              </p>
            ) : null}
            {launched.map((item) => (
              <div key={item.key} className="space-y-2">
                <p className="text-sm font-medium">{item.label}</p>
                <ImportJobStatus
                  jobId={item.jobId}
                  importId={item.importId}
                  listId={item.listId}
                  siteId={siteId}
                  targetId={item.targetId}
                />
              </div>
            ))}
          </CardContent>
          <CardFooter className="flex-wrap gap-2">
            <Button
              nativeButton={false}
              render={
                <Link href={keywordsHref(siteId, "imports", { target: launched[0]?.targetId })} />
              }
            >
              Retour aux imports
            </Button>
            {launched.length === 1 && launched[0] ? (
              <Button
                variant="outline"
                nativeButton={false}
                render={<Link href={keywordsHref(siteId, `imports/${launched[0].importId}`)} />}
              >
                Voir le rapport
              </Button>
            ) : null}
          </CardFooter>
        </Card>
      </div>
    );
  }

  return (
    <div className="space-y-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.title}
            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="space-y-4">
          {step === 1 ? (
            <>
              <UploadDropzone
                path="/imports/files"
                fields={{ site_target_id: targetId }}
                multiple
                maxBytes={MAX_UPLOAD_BYTES}
                label="Déposez vos exports CSV ou cliquez pour les choisir"
                hint="CSV uniquement, plusieurs à la fois — Semrush, SE Ranking, ou n'importe quel export avec un mapping manuel."
                onUploaded={(payload) => {
                  const parsed = uploadResultSchema.safeParse(payload);
                  if (!parsed.success) {
                    setError("Réponse inattendue de l'API à l'envoi du fichier.");
                    return;
                  }
                  setFiles((previous) => addImportFile(previous, parsed.data));
                  setSelection({});
                  setError(null);
                  setUploadTargetId(targetId);
                  // `POST /imports/files` ne rend que l'identifiant du doublon : sa date se
                  // lit sur l'import, et seulement dans ce cas — le flux nominal n'appelle rien.
                  const duplicateOf = parsed.data.duplicate_of;
                  if (duplicateOf !== null) {
                    startTransition(async () => {
                      const found = await importDateAction({ import_id: duplicateOf });
                      // Un échec ici ne bloque rien : le dialogue s'ouvrira sans la date.
                      if ("error" in found) return;
                      setFiles((previous) =>
                        patchImportFile(previous, parsed.data.sha256, {
                          duplicateAt: found.importedAt,
                        }),
                      );
                    });
                  }
                }}
              />
              {files.length > 0 ? (
                <ul className="divide-y rounded-lg border">
                  {files.map((file) => (
                    <li
                      key={file.sha256}
                      className="flex flex-wrap items-center gap-x-3 gap-y-1 p-3"
                    >
                      <span className="min-w-0 flex-1 truncate text-sm font-medium">
                        {file.filename}
                      </span>
                      <span className="text-muted-foreground text-xs">{sizeLabel(file.size)}</span>
                      {file.duplicateOf ? (
                        <>
                          <Badge variant="outline">Déjà importé</Badge>
                          <Link
                            href={keywordsHref(siteId, `imports/${file.duplicateOf}`)}
                            className="text-xs underline underline-offset-4"
                          >
                            Voir le rapport
                          </Link>
                        </>
                      ) : null}
                      <Button
                        type="button"
                        variant="ghost"
                        size="sm"
                        disabled={pending}
                        onClick={() =>
                          setFiles((previous) => removeImportFile(previous, file.sha256))
                        }
                      >
                        Retirer
                      </Button>
                    </li>
                  ))}
                </ul>
              ) : null}
            </>
          ) : null}

          {step === 2 ? (
            <>
              {files.length > 1 ? (
                // Un fichier par ligne, avec ce que la détection en a dit : c'est la seule
                // façon de voir d'un coup d'œil qu'un export du lot est tombé en générique.
                // Hauteur bornée, défilement INTERNE (règle responsive).
                <ul className="max-h-56 divide-y overflow-y-auto rounded-lg border">
                  {files.map((file) => {
                    const chosen = file.sha256 === active?.sha256;
                    return (
                      <li key={file.sha256}>
                        <button
                          type="button"
                          aria-pressed={chosen}
                          onClick={() => setActiveSha(file.sha256)}
                          className={cn(
                            "flex w-full flex-wrap items-center gap-x-2 gap-y-1 p-3 text-left text-sm",
                            chosen && "bg-muted",
                          )}
                        >
                          <span className="min-w-0 flex-1 truncate">{file.filename}</span>
                          {file.preview ? (
                            <>
                              <Badge variant="secondary">
                                {formatLabel(effectiveFormat(file))}
                              </Badge>
                              {file.preview.confidence > 0 ? (
                                <Badge variant="outline">
                                  {Math.round(file.preview.confidence * 100)} %
                                </Badge>
                              ) : null}
                              {mappingReady(file) ? null : (
                                <Badge variant="destructive">mapping à faire</Badge>
                              )}
                            </>
                          ) : file.error === null ? (
                            //  Envoyé pendant que la boucle d'analyse tournait : il n'a pas
                            //  d'aperçu et n'a pourtant rien de fautif. Le dire « illisible »
                            //  serait un mensonge — c'est le bouton d'analyse qui l'attend.
                            <Badge variant="outline">à analyser</Badge>
                          ) : (
                            <Badge variant="destructive">illisible</Badge>
                          )}
                        </button>
                      </li>
                    );
                  })}
                </ul>
              ) : null}

              {active === null ? null : activePreview === null ? (
                <p role="alert" className="text-destructive text-sm">
                  {active.error ?? "Ce fichier n'a pas pu être analysé."}
                </p>
              ) : (
                <>
                  <div className="flex flex-wrap items-end gap-4">
                    <div className="space-y-1">
                      <Label htmlFor="wizard-format">Format</Label>
                      <OptionSelect
                        id="wizard-format"
                        label="Format du fichier"
                        className="w-72"
                        options={
                          IMPORT_FORMATS.some((item) => item.value === activePreview?.format)
                            ? [...IMPORT_FORMATS]
                            : [
                                {
                                  value: activePreview.format,
                                  label: formatLabel(activePreview.format),
                                },
                                ...IMPORT_FORMATS,
                              ]
                        }
                        value={effectiveFormat(active) ?? activePreview.format}
                        onValueChange={(value) => {
                          // La colonne pays n'a de sens que pour `generic_csv` : les adaptateurs
                          // à signature déclarent la leur, la garder les ferait mentir.
                          const nextColumn = value === GENERIC_FORMAT ? active.countryColumn : null;
                          const nextMapping = value === GENERIC_FORMAT ? active.mapping : {};
                          setFiles((previous) =>
                            patchImportFile(previous, active.sha256, {
                              format: value,
                              countryColumn: nextColumn,
                              mapping: nextMapping,
                            }),
                          );
                          rerun(active, value, nextMapping, nextColumn);
                        }}
                      />
                    </div>
                    <p className="text-muted-foreground text-sm">
                      Détection : {formatLabel(activePreview.format)}
                      {activePreview.language ? ` · export ${activePreview.language}` : ""}
                      {activePreview.confidence > 0
                        ? ` · confiance ${Math.round(activePreview.confidence * 100)} %`
                        : ""}
                      {countries.length > 0 ? ` · ${countries.length} pays dans le lot` : ""}
                    </p>
                  </div>

                  {activePreview.warnings.length > 0 ? (
                    <ul className="space-y-1 rounded-lg border p-4 text-sm">
                      {/* La clé porte le `detail` : `ambiguous_column` est le premier code à
                          pouvoir apparaître plusieurs fois dans une même réponse (un par champ
                          hésitant), et clé-er sur le seul code ferait taire à l'écran
                          l'ambiguïté que la spec impose précisément de rendre. */}
                      {activePreview.warnings.map((warning) => (
                        <li
                          key={`${warning.code}:${warning.detail}`}
                          className="flex items-start gap-2"
                        >
                          <Badge variant="outline">Avertissement</Badge>
                          <span>{warning.detail}</span>
                        </li>
                      ))}
                    </ul>
                  ) : null}

                  {needsMapping(active) ? (
                    <div className="space-y-3 rounded-lg border p-4">
                      <p className="text-sm font-medium">Colonnes de ce fichier</p>
                      <p className="text-muted-foreground text-sm">
                        {activePreview.suggested_mapping
                          ? "Nous avons reconnu vos colonnes — vérifiez et ajustez si besoin. Seule la colonne mot-clé est obligatoire."
                          : "Aucune colonne n'a pu être reconnue : associez-les vous-même. Seule la colonne mot-clé est obligatoire."}
                      </p>
                      <div className="grid gap-3 sm:grid-cols-2">
                        {CANONICAL_FIELDS.map((field) => (
                          <div key={field} className="space-y-1">
                            <Label htmlFor={`map-${field}`}>{CANONICAL_LABELS[field]}</Label>
                            <OptionSelect
                              id={`map-${field}`}
                              label={CANONICAL_LABELS[field]}
                              options={[
                                { value: NONE, label: "— aucune —" },
                                ...activePreview.headers.map((header) => ({
                                  value: header,
                                  label: header,
                                })),
                              ]}
                              value={active.mapping[field] ?? NONE}
                              onValueChange={(value) =>
                                setFiles((previous) => {
                                  const next = { ...active.mapping };
                                  if (value === NONE) delete next[field];
                                  else next[field] = value;
                                  return patchImportFile(previous, active.sha256, {
                                    mapping: next,
                                  });
                                })
                              }
                            />
                          </div>
                        ))}
                      </div>
                      <div className="space-y-1">
                        <Label htmlFor="map-country">Colonne pays (facultative)</Label>
                        <OptionSelect
                          id="map-country"
                          label="Colonne qui porte le pays de chaque ligne"
                          className="sm:w-72"
                          options={[
                            { value: NONE, label: "— aucune —" },
                            ...activePreview.headers.map((header) => ({
                              value: header,
                              label: header,
                            })),
                          ]}
                          value={active.countryColumn ?? NONE}
                          onValueChange={(value) =>
                            setFiles((previous) =>
                              patchImportFile(previous, active.sha256, {
                                countryColumn: value === NONE ? null : value,
                              }),
                            )
                          }
                        />
                        <p className="text-muted-foreground text-xs">
                          Hors mapping : le pays n'est pas une métrique du mot-clé. Désignée, elle
                          permet de recenser les marchés du fichier et de n'importer que les vôtres.
                        </p>
                      </div>
                      <div className="flex flex-wrap items-center gap-3">
                        <Button
                          type="button"
                          variant={mappingDirty(active) ? "default" : "ghost"}
                          disabled={pending || !mappingReady(active) || !mappingDirty(active)}
                          onClick={() =>
                            rerun(active, GENERIC_FORMAT, active.mapping, active.countryColumn)
                          }
                        >
                          Appliquer le mapping
                        </Button>
                        {mappingDirty(active) ? null : (
                          <span className="text-muted-foreground text-sm">
                            L'aperçu ci-dessous applique déjà ces colonnes.
                          </span>
                        )}
                      </div>
                    </div>
                  ) : null}

                  {activePreview.sample.length > 0 ? (
                    // Hauteur bornée : l'aperçu scrolle DANS son cadre (règle responsive) — il ne
                    // doit jamais repousser le bouton « Continuer » hors écran.
                    <div className="max-h-72 overflow-x-auto overflow-y-auto rounded-lg border">
                      <Table>
                        <TableHeader className="bg-muted">
                          <TableRow>
                            <TableHead>Ligne</TableHead>
                            {SAMPLE_COLUMNS.map((column) => (
                              <TableHead key={column.key}>{column.label}</TableHead>
                            ))}
                          </TableRow>
                        </TableHeader>
                        <TableBody>
                          {activePreview.sample.map((row) => (
                            <TableRow key={row.line}>
                              <TableCell className="text-muted-foreground">{row.line}</TableCell>
                              {SAMPLE_COLUMNS.map((column) => (
                                <TableCell key={column.key}>{cellValue(row, column.key)}</TableCell>
                              ))}
                            </TableRow>
                          ))}
                        </TableBody>
                      </Table>
                    </div>
                  ) : (
                    <p className="text-muted-foreground text-sm">
                      Aucune ligne exploitable dans l'aperçu — vérifiez le format ou le mapping.
                    </p>
                  )}
                </>
              )}
            </>
          ) : null}

          {step === 3 ? (
            <div className="space-y-4">
              <div className="max-w-md space-y-1">
                <Label htmlFor="wizard-target">Cible</Label>
                <OptionSelect
                  id="wizard-target"
                  label="Cible (pays · langue)"
                  options={allTargets.map((target) => ({
                    value: target.id,
                    label: `${formatTarget(target)}${target.is_default ? " ★" : ""}`,
                  }))}
                  value={targetId}
                  // Figé pendant la boucle : le plan exécuté est celui qui a été capturé au
                  // clic, l'écran ne doit pas afficher autre chose que ce qui part.
                  disabled={pending}
                  onValueChange={(value) => {
                    setTargetId(value);
                    setSelection({});
                    loadLists(value);
                  }}
                />
                <p className="text-muted-foreground text-xs">
                  {countries.length > 0
                    ? "Elle est pré-cochée ci-dessous quand les fichiers contiennent son pays, et c'est la seule dont les listes existantes sont proposées."
                    : "La cible fait autorité sur la locale."}
                </p>
              </div>

              {countries.length > 0 || unresolved.length > 0 ? (
                <div className="space-y-2">
                  <p className="text-sm font-medium">Pays trouvés dans les fichiers</p>
                  <p className="text-muted-foreground text-xs">
                    Un import par pays coché et par fichier qui le contient : chacun a sa cible, sa
                    liste et son rapport. Les lignes des pays non cochés sont ignorées.
                  </p>
                  {/* Hauteur bornée et défilement INTERNE (règle responsive) : onze marchés
                      repousseraient sinon « Lancer l'import » hors de l'écran, et la ligne se
                      replie à 375 px plutôt que de déborder — le Select de langue passe seul
                      sur sa ligne. */}
                  <ul className="max-h-72 divide-y overflow-x-auto overflow-y-auto rounded-lg border">
                    {countries.map((country) => {
                      const picked = choiceOf(country);
                      const language = picked.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={`country-${country.code}`}
                            checked={picked.selected}
                            disabled={pending}
                            onCheckedChange={(checked) =>
                              setChoice(country, { selected: checked === true })
                            }
                          />
                          <Label
                            htmlFor={`country-${country.code}`}
                            className="min-w-0 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} lignes
                            </span>
                          </Label>
                          <Badge variant={existing ? "secondary" : "outline"}>
                            {existing ? "déjà là" : "à créer"}
                          </Badge>
                          <OptionSelect
                            id={`country-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} lignes</span>
                        {/* Ni un pays, ni une cible : une localité ou un libellé hors tables.
                            Montré pour révéler le trou, jamais proposé — ces lignes seront
                            comptées « pays non reconnu » dans le rapport. */}
                        <Badge variant="outline">non reconnu</Badge>
                      </li>
                    ))}
                  </ul>
                  {plan.droppedTarget ? (
                    <p className="text-muted-foreground text-xs">
                      Aucune ligne pour {plan.droppedTarget} : aucun import ne sera lancé pour cette
                      cible.
                    </p>
                  ) : null}
                </div>
              ) : null}

              <div className="max-w-md space-y-1">
                {/* Un seul champ : ce qui est tapé filtre les listes de la cible, et devient le
                    nom d'une liste à créer si rien n'est choisi (décision 2026-09-02). Pas de
                    bascule « nouvelle liste » à actionner — la création est le cas par défaut. */}
                <SearchCombobox
                  id="wizard-list"
                  label="Liste de destination"
                  placeholder="Nom de la liste"
                  className="sm:max-w-none"
                  local
                  value={listQuery}
                  onValueChange={(next) => {
                    setListQuery(next);
                    // Toute frappe défait le choix explicite : la saisie redevient un nom.
                    setListId(null);
                  }}
                  onSubmit={() => setListId(null)}
                  onSelect={(suggestion) => {
                    setListQuery(suggestion.label);
                    setListId(suggestion.id);
                  }}
                  loadSuggestions={canPickExistingList ? loadListSuggestions : NO_SUGGESTION}
                  //  Inerte tant que les listes de la cible n'ont pas répondu : saisir dans
                  //  cette fenêtre laisserait croire à une création alors que le nom tapé
                  //  désigne peut-être une liste existante — et le lancement finirait sur un
                  //  409 que rien n'annonçait.
                  disabled={lists === null}
                />
                <p className="text-muted-foreground text-xs">
                  {lists === null
                    ? "Chargement des listes de la cible…"
                    : !canPickExistingList
                      ? "Une liste par cible, chacune suffixée de son pays — une liste appartient à une seule cible. Un nom déjà pris sur l'une d'elles fera refuser cet import : changez-le pour relancer le même lot le même jour."
                      : destination !== null
                        ? `Les mots-clés rejoindront « ${destination.name} » (${destination.item_count}).`
                        : listQuery.trim() === ""
                          ? "Tapez un nom : la liste sera créée, ou choisissez-en une existante."
                          : `« ${listQuery.trim()} » sera créée pour cette cible.`}
                </p>
              </div>

              {plan.imports.length > 1 ? (
                <p className="text-sm">
                  {plan.imports.length} imports partiront
                  {usable.length > 1 ? ` depuis ${usable.length} fichiers` : ""}
                  {plan.creations > 0 ? `, dont ${plan.creations} après création de la cible` : ""}.
                </p>
              ) : null}

              {plan.skipped.length > 0 ? (
                <p className="text-muted-foreground text-sm">
                  Sans import, faute de pays coché qu'ils contiennent : {plan.skipped.join(", ")}.
                </p>
              ) : null}
            </div>
          ) : null}

          {step === 3 && plan.blockers.length > 0 ? (
            //  `role="alert"` et **tous** les motifs : c'est un avertissement bloquant (§4.3),
            //  pas une note de bas de page — en n'en montrant qu'un, l'utilisateur corrigeait
            //  au compte-gouttes, et un lecteur d'écran n'en entendait aucun.
            <div role="alert" className="space-y-2 rounded-lg border p-4 text-sm">
              <ul className="space-y-1">
                {plan.blockers.map((blocker) => (
                  <li key={blocker}>{blocker}</li>
                ))}
              </ul>
              {plan.unreadable.length > 0 ? (
                <div className="flex flex-wrap gap-2">
                  {plan.unreadable.map((item) => (
                    <Button
                      key={item.sha256}
                      type="button"
                      variant="outline"
                      size="sm"
                      disabled={pending}
                      onClick={() =>
                        setFiles((previous) => removeImportFile(previous, item.sha256))
                      }
                    >
                      Retirer {item.filename}
                    </Button>
                  ))}
                </div>
              ) : null}
            </div>
          ) : null}

          {progress ? (
            // Règle du 2026-09-01 : jamais d'écran figé pendant un traitement. Le suivi de
            // chaque job prend le relais dès l'écran suivant, et le toast global les reprend
            // tous où que l'utilisateur navigue.
            <div className="flex flex-wrap items-center gap-2 text-sm">
              <Spinner size="sm" />
              {progress.what} — {progress.done + 1} sur {progress.total}…
            </div>
          ) : null}

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

          {files.some((file) => file.error !== null) ? (
            <ul role="alert" className="space-y-1 rounded-lg border p-4 text-sm">
              {files
                .filter((file) => file.error !== null)
                .map((file) => (
                  <li key={file.sha256} className="flex flex-wrap items-start gap-2">
                    <Badge variant="destructive">{file.filename}</Badge>
                    <span className="text-destructive min-w-0">{file.error}</span>
                  </li>
                ))}
            </ul>
          ) : null}

          {refused.length > 0 && launched.length === 0 ? (
            <div role="alert" className="space-y-2 rounded-lg border p-4 text-sm">
              <ul className="space-y-1">
                {refused.map((item) => (
                  <li 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.reason}</span>
                  </li>
                ))}
              </ul>
              {retryable.length > 0 ? (
                // Le doublon ne se révèle pas toujours à l'envoi : sur une cible que
                // `POST /imports/files` n'a pas interrogée, il n'apparaît qu'au 409. Sans ce
                // bouton, « Lancer » rejouait le même refus indéfiniment.
                <div className="flex flex-wrap items-center gap-2">
                  {retryButton}
                  <span className="text-muted-foreground">
                    Les métriques de la même date seront mises à jour, sans doublon.
                  </span>
                </div>
              ) : null}
            </div>
          ) : null}
        </CardContent>

        <CardFooter className="flex-wrap gap-2">
          {step === 1 ? (
            <Button type="button" disabled={pending || files.length === 0} onClick={runPreviews}>
              Continuer
            </Button>
          ) : null}
          {step === 2 ? (
            <>
              <Button type="button" variant="outline" onClick={() => setStep(1)}>
                Retour
              </Button>
              {unanalysed.length > 0 ? (
                //  Un fichier arrivé pendant la boucle d'analyse n'a pas d'aperçu, et sans
                //  aperçu il ne peut pas entrer dans le plan. Plutôt que de l'écarter en
                //  silence, on refuse d'avancer et on offre le geste qui manque.
                <Button type="button" disabled={pending} onClick={runPreviews}>
                  Analyser {unanalysed.length > 1 ? `les ${unanalysed.length} ajoutés` : "l'ajouté"}
                </Button>
              ) : (
                <Button
                  type="button"
                  disabled={pending || usable.length === 0 || !usable.every(mappingReady)}
                  onClick={() => {
                    setStep(3);
                    loadLists(targetId);
                  }}
                >
                  Continuer
                </Button>
              )}
            </>
          ) : null}
          {step === 3 ? (
            <>
              <Button type="button" variant="outline" disabled={pending} onClick={() => setStep(2)}>
                Retour
              </Button>
              <Button type="button" disabled={pending || plan.blockers.length > 0} onClick={submit}>
                {plan.imports.length > 1
                  ? `Lancer ${plan.imports.length} imports`
                  : "Lancer l'import"}
              </Button>
            </>
          ) : null}
        </CardFooter>
      </Card>

      <Dialog
        open={confirmOpen}
        onOpenChange={(next) => {
          // Fermer en vol laisserait la création aboutir dans le vide : le dialogue reste
          // tant que la Server Action n'a pas répondu (pattern des dialogues du réservoir).
          if (!next && pending) return;
          setConfirmOpen(next);
        }}
      >
        <DialogContent className="sm:max-w-md">
          <DialogHeader>
            <DialogTitle>
              {duplicates.length > 1 ? "Fichiers déjà importés" : "Fichier déjà importé"}
            </DialogTitle>
            <DialogDescription>
              {duplicates.length > 1
                ? `${duplicates.length} de ces fichiers ont déjà été importés sur cette cible.`
                : "Ce fichier a déjà été importé sur cette cible."}
            </DialogDescription>
          </DialogHeader>
          <ul className="space-y-1 text-sm">
            {duplicates.map((file) => (
              <li key={file.sha256} className="flex flex-wrap items-center gap-2">
                <span className="min-w-0 truncate font-medium">{file.filename}</span>
                {file.duplicateAt === null ? null : (
                  <span className="text-muted-foreground text-xs">
                    le {importedAtLabel(file.duplicateAt)}
                  </span>
                )}
                {file.duplicateOf === null ? null : (
                  <Link
                    href={keywordsHref(siteId, `imports/${file.duplicateOf}`)}
                    className="text-xs underline underline-offset-4"
                  >
                    Voir le rapport
                  </Link>
                )}
              </li>
            ))}
          </ul>
          <p className="text-sm">
            Relancer mettra à jour les métriques de la même date (aucun doublon ne sera créé).
          </p>
          <DialogFooter>
            <Button variant="outline" disabled={pending} onClick={() => setConfirmOpen(false)}>
              Annuler
            </Button>
            <Button disabled={pending} onClick={() => launch(true)}>
              {pending ? "En cours…" : "Mettre à jour"}
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </div>
  );
}
