"""Proposition automatique de mapping pour `generic_csv` (spec Imports §5, décision 2026-09-01).

« Le mapping, nous on le fait — on assimile tous les types de CSV. » Un export dont aucune
signature ne reconnaît les colonnes reste importable par mapping manuel (§10.3), mais le
mapping manuel ne doit pas être un formulaire vide : l'utilisateur **confirme** ce qu'on a
reconnu, il ne câble pas neuf `Select` à la main.

Ce module ne détecte pas une source — il n'en nomme aucune et n'en devine aucune. Il fait une
seule chose : associer les en-têtes du fichier aux champs canoniques par une table de synonymes.
Trois propriétés le rendent utilisable sur des fichiers réels :

- **Comparaison repliée** (`_fold`) : casse, accents et ponctuation disparaissent, si bien que
  `CPC (USD)`, `CPC/USD` et `cpc usd` sont le même en-tête, et que « Mot-clé » n'a pas besoin
  d'être écrit en quatre orthographes. C'est plus permissif que `normalize_header` (qui, lui,
  sert la **détection** et garde les accents à dessein) : ici, un faux positif se corrige d'un
  clic, alors qu'un champ vide coûte à l'utilisateur un aller-retour dans son tableur.
- **Priorité explicite, jamais de sous-chaîne.** Un synonyme est exact, un motif est ancré
  (`fullmatch`). Sans cela `Keyword Difficulty` deviendrait un mot-clé et `Competitive Density`
  une position. Les synonymes l'emportent sur les motifs, et l'ordre de la table départage :
  `Avg. Search Volume (Last 12 months)` est un synonyme exact du volume, ses trois voisines
  (`Last Known Values`, `Last 6/3 months`) ne sont que des motifs — c'est ce qui fait gagner la
  bonne colonne sur l'export KWFinder réel.
- **Ambiguïté rendue, jamais tranchée en silence** (§5). Plusieurs candidates pour un champ :
  la meilleure est retenue **et** les autres sont nommées, pour que l'écran le dise et que
  l'utilisateur corrige d'un geste.

Une colonne ne sert qu'un champ, et un champ sans candidate est simplement absent de la
proposition — `keyword` excepté : sans lui la proposition est inapplicable (`generic_csv` lève
`MappingError`), d'où `usable`.
"""

import re
import unicodedata
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass, field

from app.keywords.sources.base import CANONICAL_FIELDS

# Synonymes **repliés** par champ canonique, du plus spécifique au plus générique : l'index
# dans le tuple est la priorité. EN et FR cohabitent dans la même liste — un export ne mélange
# pas les langues, et les distinguer ici n'apporterait qu'une table de plus.
_SYNONYMS: Mapping[str, tuple[str, ...]] = {
    "keyword": (
        "keyword",
        "mot cle",
        "keywords",
        "mots cles",
        "requete",
        "requetes",
        "query",
        "search term",
        "terme",
        "expression",
        "kw",
    ),
    "volume": (
        #  KWFinder écrit quatre moyennes ; celle de douze mois est la seule qui vaut un
        #  volume mensuel comparable aux autres sources, d'où sa place en tête.
        "avg search volume last 12 months",
        "volume",
        "search volume",
        "volume de recherche",
        "monthly volume",
        "monthly search volume",
        "avg monthly searches",
        "recherches mensuelles",
        "volume mensuel",
        "vol mensuel",
        "global volume",
        "msv",
        "sv",
    ),
    "kd": (
        "keyword difficulty",
        "difficulte du mot cle",
        "keyword difficulty index",
        "seo difficulty",
        "difficulte seo",
        "difficulty",
        "difficulte",
        "kd",
        "sd",
        "seo",
    ),
    "cpc": (
        "cpc",
        "cpc usd",
        "cpc eur",
        "avg cpc",
        "cpc moyen",
        "cout par clic",
        "cost per click",
    ),
    "intent": (
        "intent",
        "search intent",
        "intention",
        "intention de recherche",
        "intents",
        "intentions",
        "keyword intents",
    ),
    "serp_features": (
        "serp features",
        "serp feature",
        "serp features by keyword",
        "fonctionnalites serp",
        "fonctionnalites serp par mot cle",
        "serp",
    ),
    #  Pas de `keyword interest growth` (KWFinder) ici, quoique ce soit bien une tendance : la
    #  colonne porte **une** croissance en pourcentage, là où le canonique attend les douze
    #  valeurs d'une sparkline (`to_trend`, §3.2). La proposer mapperait un champ dont chaque
    #  ligne rendrait `None` — une promesse que rien ne peut tenir, et que l'utilisateur ne peut
    #  pas corriger d'un clic puisque aucune colonne correcte n'existe dans son fichier. T7 a
    #  tranché sans y toucher : l'adaptateur `mangools` tire la vraie tendance des colonnes
    #  mensuelles `Search Volume M/YYYY` — une chose qu'un mapping colonne-à-champ ne peut pas
    #  faire, et qui laisse donc cette croissance hors de la proposition pour de bon.
    "trend": ("trend", "trends", "tendance", "tendances"),
    "position": (
        "position",
        "positions",
        "prev position",
        "previous position",
        "position precedente",
        "classement",
        "rank",
        "ranking",
        "pos",
    ),
    "url": (
        "url",
        "urls",
        "ranked url",
        "page url",
        "landing page",
        "page",
        "adresse",
        "lien",
    ),
}

# Motifs **ancrés** pour les en-têtes à suffixe libre (devise, période) qu'aucune énumération
# ne couvre. Appliqués après les synonymes, donc toujours perdants contre eux.
#  Pas de motif `serp .*` : il attraperait `SERP Position` (un **rang**), dont
#  `to_serp_features("3")` ferait la fonctionnalité SERP « 3 » — une métrique fausse, persistée,
#  et que l'aperçu ne montre même pas. Les synonymes exacts couvrent les en-têtes réels ; le
#  motif n'ajoutait que ce risque.
_PATTERNS: Mapping[str, tuple[str, ...]] = {
    "volume": (r"avg search volume .*", r"search volume .*", r"volume .*"),
    "cpc": (r"cpc .*",),
    "trend": (r"trend .*",),
    "kd": (r"kd .*",),
}

# En-têtes qu'aucun champ ne doit réclamer, quel que soit ce qui précède. Les volumes mensuels
# de KWFinder (`Search Volume 7/2015`, 133 colonnes) sont une **série temporelle** : en retenir
# une comme volume donnerait un chiffre d'un mois arbitraire d'il y a dix ans.
_TIME_SERIES = re.compile(r".*\b\d{1,2} \d{4}")

_FIELD_ORDER = {name: index for index, name in enumerate(CANONICAL_FIELDS)}
_SYNONYM_WEIGHT = 10_000
_PATTERN_WEIGHT = 1_000


def _fold(raw: str) -> str:
    """Forme de comparaison permissive : sans accent, sans casse, sans ponctuation.

    `CPC (USD)` et `CPC/USD` se replient tous deux sur `cpc usd`, `Mot-clé` sur `mot cle`.
    Les chiffres sont conservés : ils portent du sens (`Last 12 months`, `7/2015`).
    """
    decomposed = unicodedata.normalize("NFKD", raw.replace("﻿", ""))
    stripped = "".join(char for char in decomposed if not unicodedata.combining(char))
    return " ".join(re.sub(r"[^0-9a-z]+", " ", stripped.casefold()).split())


@dataclass(frozen=True, slots=True)
class Suggestion:
    """Ce qu'on propose à l'étape 2 du wizard, et ce dont on n'est pas sûr.

    `columns` a la forme du mapping d'entrée de `generic_csv` (champ canonique → en-tête réel).
    `alternatives` porte, par champ, les autres colonnes qui auraient pu convenir, dans l'ordre
    du fichier : c'est la matière des avertissements de l'aperçu.
    """

    columns: dict[str, str] = field(default_factory=dict)
    alternatives: dict[str, list[str]] = field(default_factory=dict)

    @property
    def usable(self) -> bool:
        """`generic_csv` exige `keyword` : sans lui, appliquer la proposition ferait un 422."""
        return "keyword" in self.columns


def _candidates(headers: Sequence[str]) -> Iterator[tuple[int, str, int, str]]:
    """`(score, champ, rang de l'en-tête, en-tête)` pour toute paire plausible."""
    folded = [(_fold(header), header) for header in headers]
    for rank, (key, header) in enumerate(folded):
        if not key or _TIME_SERIES.fullmatch(key):
            continue
        for name, synonyms in _SYNONYMS.items():
            if key in synonyms:
                yield _SYNONYM_WEIGHT - synonyms.index(key), name, rank, header
                continue
            for index, pattern in enumerate(_PATTERNS.get(name, ())):
                if re.fullmatch(pattern, key):
                    yield _PATTERN_WEIGHT - index, name, rank, header
                    break


def suggest_columns(headers: Sequence[str]) -> Suggestion:
    """Le mapping `generic_csv` que ce fichier appelle, et les hésitations qui restent.

    Attribution gloutonne sur le score : la meilleure paire (champ, colonne) est fixée, puis la
    suivante parmi celles dont ni le champ ni la colonne ne sont pris. C'est ce qui garantit
    qu'une colonne ne sert qu'un champ — `Keyword Difficulty` ne peut pas être à la fois la
    difficulté et le mot-clé — sans qu'un ordre de traitement arbitraire ne décide pour nous.
    """
    #  Tri décroissant sur le score, puis croissant sur l'ordre canonique du champ et sur la
    #  position de la colonne dans le fichier : à score égal, la proposition est déterministe.
    ranked = sorted(
        _candidates(headers),
        key=lambda item: (-item[0], _FIELD_ORDER.get(item[1], len(_FIELD_ORDER)), item[2]),
    )
    columns: dict[str, str] = {}
    taken: set[str] = set()
    alternatives: dict[str, list[str]] = {}
    for _score, name, _rank, header in ranked:
        if name in columns:
            #  Le champ est déjà servi : cette colonne reste une candidate légitime, sauf si
            #  elle a été retenue ailleurs — proposer `URL` comme alternative de `position`
            #  n'aiderait personne.
            if header not in taken:
                alternatives.setdefault(name, []).append(header)
            continue
        if header in taken:
            continue
        columns[name] = header
        taken.add(header)
    #  Les alternatives sont listées dans l'ordre du fichier, pas dans celui des scores :
    #  c'est ainsi que l'utilisateur les voit dans son tableur.
    order = {header: rank for rank, header in enumerate(headers)}
    for name, others in alternatives.items():
        alternatives[name] = sorted(dict.fromkeys(others), key=lambda h: order.get(h, 0))
    return Suggestion(
        columns={name: columns[name] for name in CANONICAL_FIELDS if name in columns},
        alternatives={
            name: alternatives[name] for name in CANONICAL_FIELDS if name in alternatives
        },
    )
