import {
  census,
  COUNTRY_CODE,
  LANGUAGE_CODE,
  launchPlan,
  unreadableCountriesMessage,
  MAX_TARGETS_PER_SITE,
  type CensusCountry,
  type CensusUnresolved,
  type CountryChoice,
  type PlannedImport,
} from "@/lib/import-countries";
import {
  GENERIC_FORMAT,
  mappingEquals,
  type ImportPreview,
  type UploadResult,
} from "@/lib/imports";
import { REQUIRED_FIELD, type KeywordList } from "@/lib/keywords";
import { formatTarget } from "@/lib/locales";
import type { Target } from "@/lib/sites";

// Le wizard d'import accepte **N fichiers d'un coup** (SEOINSIGHT-57). Ce module tient l'état
// d'un fichier envoyé, et surtout la seule chose que le cloisonnement par pays rend délicate :
// additionner N plans de lancement sans se tromper de compte.
//
// Il n'y a aucun changement d'api derrière tout cela — N appels aux endpoints existants, le
// fichier ne traverse le réseau qu'une fois (c'est le `sha256` qui circule ensuite).
//
// Trois décisions gouvernent le module :
//
// 1. **Le `sha256` EST l'identité d'un fichier.** Deux dépôts du même contenu ne font qu'une
//    ligne : côté api, le second import prendrait de toute façon un 409 sur la même cible.
// 2. **Les décisions de l'étape 3 sont communes** (cible, pays cochés, liste), les détections
//    de l'étape 2 sont par fichier (format, mapping, colonne pays). Le recensement affiché est
//    donc la FUSION des pays de tous les fichiers, et chaque fichier n'importe que ceux qu'il
//    contient vraiment.
// 3. **Les compteurs globaux ne sont pas des sommes de compteurs locaux.** Deux fichiers qui
//    portent tous deux les États-Unis ne créent qu'une cible et ne remplissent qu'une case du
//    plafond du site : c'est le couple (pays, langue) qui est dédoublonné, pas l'import.

/** Un fichier envoyé, avec ce que l'aperçu en a dit et ce que l'utilisateur y a corrigé. */
export type ImportFile = {
  /** Empreinte rendue par `POST /imports/files` — l'identité du fichier, et sa clé de rendu. */
  sha256: string;
  filename: string;
  size: number;
  /** Import existant portant le même contenu sur la cible d'envoi, ou `null`. */
  duplicateOf: string | null;
  /** Date de cet import, quand elle a pu être lue — le dialogue de doublon l'annonce. */
  duplicateAt: string | null;
  /** `null` tant que l'aperçu n'a pas répondu, ou s'il a échoué (`error` le dit alors). */
  preview: ImportPreview | null;
  /** Motif d'un aperçu refusé : ce fichier-là est écarté, les autres continuent. */
  error: string | null;
  /** Format forcé par l'utilisateur ; `null` = celui que la détection a rendu. */
  format: string | null;
  /** Mapping manuel `generic_csv` : champ canonique → en-tête réel. */
  mapping: Record<string, string>;
  /** En-tête qui porte le pays de chaque ligne (`generic_csv` seulement). */
  countryColumn: string | null;
  /** Colonne pays avec laquelle l'aperçu affiché a été calculé. */
  appliedCountryColumn: string | null;
};

export function newImportFile(upload: UploadResult): ImportFile {
  return {
    sha256: upload.sha256,
    filename: upload.filename,
    size: upload.size,
    duplicateOf: upload.duplicate_of,
    duplicateAt: null,
    preview: null,
    error: null,
    format: null,
    mapping: {},
    countryColumn: null,
    appliedCountryColumn: null,
  };
}

/**
 * Ajoute un fichier envoyé, ou **remplace** celui qui portait déjà la même empreinte.
 *
 * Le même contenu déposé deux fois ne fait qu'une ligne : deux imports du même `sha256` sur la
 * même cible se solderaient par un 409, et l'écran promettrait un travail qui n'aura pas lieu.
 * L'entrée est remplacée *en place* pour que le nom de fichier le plus récent l'emporte sans
 * réordonner la liste sous les yeux de l'utilisateur.
 */
export function addImportFile(files: readonly ImportFile[], upload: UploadResult): ImportFile[] {
  const fresh = newImportFile(upload);
  const known = files.findIndex((file) => file.sha256 === upload.sha256);
  if (known === -1) return [...files, fresh];
  return files.map((file, index) => (index === known ? fresh : file));
}

export function patchImportFile(
  files: readonly ImportFile[],
  sha256: string,
  patch: Partial<ImportFile>,
): ImportFile[] {
  return files.map((file) => (file.sha256 === sha256 ? { ...file, ...patch } : file));
}

export function removeImportFile(files: readonly ImportFile[], sha256: string): ImportFile[] {
  return files.filter((file) => file.sha256 !== sha256);
}

/** Format retenu : celui que l'utilisateur a forcé, sinon celui de la détection. */
export function effectiveFormat(file: ImportFile): string | null {
  return file.format ?? file.preview?.format ?? null;
}

export function needsMapping(file: ImportFile): boolean {
  return effectiveFormat(file) === GENERIC_FORMAT;
}

/** Un `generic_csv` sans colonne mot-clé ne peut rien produire : le wizard n'avance pas. */
export function mappingReady(file: ImportFile): boolean {
  return !needsMapping(file) || Boolean(file.mapping[REQUIRED_FIELD]);
}

/**
 * L'aperçu affiché ne correspond plus aux `Select` : « Appliquer le mapping » redevient
 * l'action principale. La colonne pays en fait partie — elle ne change pas le tableau, mais
 * elle change le recensement, donc les cibles proposées à l'étape 3.
 */
export function mappingDirty(file: ImportFile): boolean {
  return (
    needsMapping(file) &&
    (!mappingEquals(file.mapping, file.preview?.columns) ||
      file.countryColumn !== file.appliedCountryColumn)
  );
}

/** Les fichiers dont l'aperçu a abouti : les seuls qui puissent être importés. */
export function readyFiles(files: readonly ImportFile[]): ImportFile[] {
  return files.filter((file) => file.preview !== null);
}

/**
 * Recensement de TOUS les fichiers, fusionné par code pays (§4.2 étendu au multi-fichiers).
 *
 * `census` fusionne déjà deux libellés bruts qui rendent le même code ; lui donner la
 * concaténation des fichiers additionne au passage les lignes de chaque marché, ce qui est
 * exactement ce que l'écran doit montrer. Les libellés non résolus sont fusionnés ici, faute
 * de quoi « New York » présent dans trois exports ferait trois lignes identiques.
 */
export function mergedCensus(files: readonly ImportFile[]): {
  countries: CensusCountry[];
  unresolved: CensusUnresolved[];
} {
  const { countries, unresolved } = census(files.flatMap((file) => file.preview?.countries ?? []));
  const merged: CensusUnresolved[] = [];
  const byRaw = new Map<string, CensusUnresolved>();
  for (const item of unresolved) {
    const known = byRaw.get(item.raw);
    if (known === undefined) {
      const entry = { ...item };
      byRaw.set(item.raw, entry);
      merged.push(entry);
      continue;
    }
    known.rows += item.rows;
  }
  return { countries, unresolved: merged };
}

/** Un import à lancer, et le fichier dont il sort. */
export type PlannedFileImport = PlannedImport & { sha256: string; filename: string };

export type MultiLaunchPlan = {
  imports: PlannedFileImport[];
  /** Cibles à créer, **dédoublonnées** : deux fichiers sur le même marché n'en créent qu'une. */
  creations: number;
  blockers: string[];
  /** Libellé de la cible du sélecteur quand aucun fichier ne porte son pays. */
  droppedTarget: string | null;
  /** Fichiers qui ne produisent aucun import alors que d'autres en produisent. */
  skipped: string[];
  /**
   * Fichiers dont la colonne pays ne résout **aucun** marché (§4.3) : ils bloquent le lot
   * jusqu'à ce qu'ils en soient retirés, et l'écran a besoin de leur empreinte pour offrir
   * ce geste.
   */
  unreadable: { sha256: string; filename: string; message: string }[];
};

/**
 * Ce que « Lancer » va faire, tous fichiers confondus.
 *
 * Le plan de chaque fichier reste celui de `launchPlan` — c'est lui qui sait résoudre un couple
 * (pays, langue) en cible existante ou en création. Ce qui est refait ici, et **seulement** ici,
 * c'est tout ce qui ne se somme pas :
 *
 * - le **plafond de cibles**, compté sur les couples distincts et non par fichier ;
 * - le **suffixe de liste** (« — US »), qui dépend du nombre total de cibles visées : deux
 *   fichiers sur une seule cible partagent une liste, et ne doivent donc pas être suffixés ;
 * - les **blocages**, énoncés une fois pour l'écran commun plutôt qu'une fois par fichier.
 *
 * Les pays viennent du recensement FUSIONNÉ, y compris pour les plans par fichier : sans cela,
 * un marché dont la langue n'est proposée que par un export sortirait sans langue dans l'autre,
 * et son import disparaîtrait en silence.
 */
export function multiLaunchPlan({
  files,
  targets,
  targetId,
  countries,
  choice,
  listName,
  existingListId,
}: {
  files: readonly ImportFile[];
  targets: readonly Target[];
  targetId: string;
  /** Recensement fusionné — celui que l'écran affiche et que l'utilisateur coche. */
  countries: readonly CensusCountry[];
  choice: (country: CensusCountry) => CountryChoice;
  listName: string;
  /** Liste existante retenue, ou `null` — n'est honorée que s'il n'y a qu'un seul import. */
  existingListId: string | null;
}): MultiLaunchPlan {
  const current = targets.find((target) => target.id === targetId);
  const usable = readyFiles(files);
  const byCode = new Map(countries.map((country) => [country.code, country]));

  //  Un fichier dont la colonne pays ne résout RIEN (§4.3) : la colonne est là, elle ne dit
  //  rien d'exploitable. Il ne rejoint pas le plan — son import ignorerait toutes ses lignes
  //  — et il bloque le lot tant qu'il n'en est pas retiré, ce que l'écran propose.
  const unreadable: MultiLaunchPlan["unreadable"] = [];
  const perFile = [];
  for (const file of usable) {
    // Les objets du recensement fusionné, pas ceux du fichier : c'est la même ligne à l'écran
    // qui doit décider, quelle que soit la moitié du lot qui l'a fait apparaître.
    const own = census(file.preview?.countries ?? []);
    const countriesOfFile = own.countries.map((country) => byCode.get(country.code) ?? country);
    if (countriesOfFile.length === 0 && own.unresolved.length > 0) {
      unreadable.push({
        sha256: file.sha256,
        filename: file.filename,
        message: unreadableCountriesMessage(own.unresolved),
      });
      continue;
    }
    perFile.push({
      file,
      plan: launchPlan({
        targets,
        targetId,
        countries: countriesOfFile,
        choice,
        // Le nom et la liste existante sont recalculés plus bas, globalement : ce que
        // `launchPlan` en dit ne vaut que pour un fichier seul.
        listName,
        existingListId: null,
      }),
    });
  }

  const imports: PlannedFileImport[] = perFile.flatMap(({ file, plan }) =>
    plan.imports.map((entry) => ({
      ...entry,
      // La clé doit rester unique sur tout le lot : deux fichiers visant les États-Unis
      // portent tous deux la clé « US ».
      key: `${file.sha256}:${entry.key}`,
      sha256: file.sha256,
      filename: file.filename,
    })),
  );

  // Un couple (pays, langue) = une cible, quel que soit le nombre de fichiers qui le visent.
  const couples = new Map<string, string | null>();
  for (const entry of imports) couples.set(`${entry.country}/${entry.language}`, entry.targetId);
  const creations = [...couples.values()].filter((id) => id === null).length;

  // Une liste appartient à une cible : plusieurs cibles = un nom par cible, suffixé du pays.
  // Un seul marché, même arrosé par cinq fichiers, garde le nom saisi — les cinq imports
  // atterrissent alors dans la MÊME liste, ce que le lancement obtient en réutilisant
  // l'identifiant rendu par le premier d'entre eux.
  const suffixed = couples.size > 1;
  // La liste existante n'est chargée que pour la cible du sélecteur : elle ne peut servir que
  // si TOUS les imports y atterrissent. C'est le cas de N fichiers sur un seul marché — ils
  // remplissent alors la même liste, ce qui est précisément ce qu'on attend d'un lot.
  const single = couples.size === 1 && imports.every((entry) => entry.targetId === targetId);
  const reused = single && existingListId !== null ? existingListId : null;
  const named: PlannedFileImport[] = imports.map((entry) => ({
    ...entry,
    list:
      reused !== null
        ? { id: reused }
        : { name: suffixed ? `${listName} — ${entry.country}` : listName },
  }));

  const blockers: string[] = [];
  for (const country of countries) {
    const picked = choice(country);
    if (!picked.selected) continue;
    const language = picked.language.trim().toLowerCase();
    if (!LANGUAGE_CODE.test(language) || !COUNTRY_CODE.test(country.code)) {
      blockers.push(`Choisissez une langue pour ${country.label}.`);
    }
  }
  // Le nom est vérifié **avant** le suffixe : « — GB » suffirait sinon à le rendre non vide.
  if (listName.trim().length === 0 && named.some((entry) => "name" in entry.list)) {
    blockers.push("Nommez la liste de destination.");
  }
  const targetMissing =
    current !== undefined &&
    countries.length > 0 &&
    !countries.some((country) => country.code === current.country) &&
    named.every((entry) => entry.targetId !== targetId);
  if (usable.length === 0) {
    blockers.unshift("Ajoutez au moins un fichier lisible.");
  } else if (named.length === 0 && perFile.length > 0) {
    blockers.unshift(
      targetMissing
        ? `${perFile.length > 1 ? "Aucun de ces fichiers ne contient de ligne" : "Le fichier ne contient aucune ligne"} pour ${formatTarget(current)} : cet import serait vide. Cochez un pays présent dans les fichiers.`
        : "Cochez au moins un pays à importer.",
    );
  }
  //  En tête, et un message par fichier : c'est le seul blocage que l'utilisateur ne peut pas
  //  lever en cochant une case — il doit corriger la colonne ou retirer le fichier.
  for (const item of unreadable) {
    blockers.unshift(
      unreadable.length === 1 && usable.length === 1
        ? item.message
        : `${item.filename} — ${item.message}`,
    );
  }
  if (targets.length + creations > MAX_TARGETS_PER_SITE) {
    blockers.push(
      `Ce site atteindrait le plafond de ${MAX_TARGETS_PER_SITE} cibles (${targets.length} existantes, ${creations} à créer) : décochez des pays.`,
    );
  }

  return {
    imports: named,
    creations,
    blockers,
    droppedTarget: targetMissing && current !== undefined ? formatTarget(current) : null,
    // Dit seulement quand le lancement part quand même : sinon c'est un blocage, pas une note.
    skipped:
      named.length === 0
        ? []
        : perFile.filter(({ plan }) => plan.imports.length === 0).map(({ file }) => file.filename),
    unreadable,
  };
}

// --- liste de destination : un champ de recherche qui crée ce qu'il ne trouve pas -------------

/** Compare sans accents ni casse : « Été » doit trouver « ete ». */
function fold(value: string): string {
  return value
    .normalize("NFD")
    .replace(/\p{Diacritic}/gu, "")
    .trim()
    .toLowerCase();
}

/** Plafond de suggestions : au-delà, la saisie est le meilleur filtre. */
const SUGGESTION_LIMIT = 8;

/**
 * Listes proposées sous le champ pour une saisie donnée.
 *
 * Une saisie vide propose tout le catalogue de la cible (le champ arrive prérempli du nom
 * automatique : sans cela, les listes existantes seraient invisibles jusqu'à ce que
 * l'utilisateur efface). Sinon, filtrage par sous-chaîne, insensible aux accents.
 */
export function listSuggestions(lists: readonly KeywordList[], query: string): KeywordList[] {
  const needle = fold(query);
  const found =
    needle === "" ? [...lists] : lists.filter((list) => fold(list.name).includes(needle));
  return found.slice(0, SUGGESTION_LIMIT);
}

/**
 * La liste existante que la destination désigne, ou `null` — auquel cas la saisie deviendra le
 * NOM d'une liste à créer, sans que l'utilisateur ait rien d'autre à faire (décision 2026-09-02).
 *
 * L'égalité de nom compte autant que la sélection : taper à la lettre près le nom d'une liste
 * existante puis lancer déclencherait sinon un 409 « Une liste « X » existe déjà pour cette
 * cible » — un refus incompréhensible pour qui a simplement écrit ce qu'il voyait.
 */
export function resolveDestination(
  lists: readonly KeywordList[],
  selected: string | null,
  query: string,
): KeywordList | null {
  const picked = selected === null ? undefined : lists.find((list) => list.id === selected);
  if (picked !== undefined && fold(picked.name) === fold(query)) return picked;
  const needle = fold(query);
  if (needle === "") return null;
  return lists.find((list) => fold(list.name) === needle) ?? null;
}
