"""Adaptateurs Semrush : « Organic Positions » et « Keyword Magic » (spec Imports §5).

Les deux exports partagent l'essentiel de leur vocabulaire de colonnes et **ne se
distinguent que par la présence de `Position` et `URL`** — jamais par le nom du fichier
(§5). C'est exprimé ici de la seule façon qui tienne : `semrush_positions` **exige** ces
deux colonnes, `semrush_magic` les déclare **disqualifiantes**. Les deux signatures sont
donc mutuellement exclusives par construction, et un fichier ne peut pas matcher les deux.

État des fixtures (règle dure §5 : « fixture d'export réel obligatoire avant d'écrire le
parser ») :

- `semrush_magic` — **export réel** (`tests/fixtures/real/semrush_magic_us.csv`, extrait
  d'un export Keyword Magic de 50 000 lignes). En-têtes constatés :
  `Keyword,Intent,Relevance,Volume,Trend,Keyword Difficulty,CPC (USD),Competitive Density,
  Number of Results`. Deux écarts avec la spec §5, à annoter : la colonne **`Relevance`**
  n'y est pas documentée (elle est propre au Keyword Magic Tool et n'a pas d'équivalent
  canonique — rangée en `raw`, jamais perdue), et **`CPC (USD)`** porte la devise du compte
  en suffixe, donc aucune liste close d'en-têtes ne peut la couvrir (d'où `patterns`).
- `semrush_positions` — **aucun export réel à ce jour**. Les en-têtes ci-dessous viennent de
  la documentation de l'API Semrush et des manuels EN/FR, où deux dénominations coexistent
  selon le chemin d'export (`Volume`/`Search Volume`, `Competition`/`Competitive Density`,
  `Intent`/`Intents`, `Trend`/`Trends`). Elles sont **toutes** acceptées, et les champs
  exigés sont réduits au strict discriminant (mot-clé + position + URL) pour que la
  détection survive à une variante non observée. **La validation sur un export réel de
  l'utilisateur reste due** — c'est le seul point de ce module qui n'est pas adossé à un
  fichier constaté.

**Dette assumée — la colonne pays** (spec « Multi-sources et cloisonnement pays » §4.1,
question Q3). Les deux adaptateurs déclarent `country_column = None` : leurs imports ne sont
donc pas cloisonnés par pays, la cible choisie fait autorité comme avant. Ce n'est pas un
oubli. « Keyword Magic » **n'écrit pas** son pays (en-tête de l'export réel vérifié : ni
`Database`, ni `Location`, ni `Country`) — il n'y a rien à lire. « Organic Positions »,
lui, en écrit probablement un — la documentation parle d'une colonne `Database` — mais
**aucun export réel n'existe dans le dépôt** pour valider ni le nom exact de l'en-tête, ni
la forme de sa valeur (`us` ? `en-US` ? `United States` ?), et la règle §5 du module Imports
interdit d'écrire un parser sur la foi d'une documentation. Le jour où l'utilisateur fournit
un export Positions réel, la capacité tient en une ligne — `country_column = "<en-tête
constaté>"` — plus, si la valeur est un code et non un libellé, la vérification que
`countries.resolve` le lit (il lit déjà `us` → `US`).

Les valeurs vides sont la règle, pas l'exception, dans l'export réel (Intent, Keyword
Difficulty et Number of Results manquent sur des dizaines de milliers de lignes) : seule
l'absence de mot-clé fait une ligne en erreur.
"""

from collections.abc import Iterator, Mapping
from pathlib import Path
from typing import Any

from app.keywords.normalize import normalize_keyword
from app.keywords.sources.base import (
    Adapter,
    CanonicalKeywordRow,
    ColumnSignature,
    ParsedRow,
    RowError,
    to_decimal,
    to_int,
    to_intent,
    to_serp_features,
    to_trend,
)
from app.keywords.sources.csvfile import CsvScan

# En-têtes acceptés par champ canonique et par langue. Les synonymes d'une même langue
# cohabitent : Semrush n'écrit pas les mêmes libellés selon la version et le chemin d'export
# (constaté sur l'export réel, documenté pour le reste — voir l'en-tête du module).
_EN = {
    "keyword": ("keyword", "keywords"),
    "volume": ("volume", "search volume", "global volume"),
    "kd": ("keyword difficulty", "kd", "kd %", "difficulty", "keyword difficulty index"),
    "intent": ("intent", "intents", "keyword intents", "search intent"),
    "serp_features": ("serp features", "serp features by keyword"),
    "trend": ("trend", "trends"),
    "url": ("url",),
    "position": ("position",),
}
_FR = {
    "keyword": ("mot clé", "mots clés", "mot cle"),
    "volume": ("volume", "volume de recherche"),
    "kd": ("difficulté du mot clé", "difficulté du mot clé (kd %)", "kd %", "difficulté"),
    "intent": ("intention", "intentions", "intentions du mot clé", "intention de recherche"),
    "serp_features": ("fonctionnalités serp", "fonctionnalités serp par mot clé"),
    "trend": ("tendance", "tendances"),
    "url": ("url",),
    "position": ("position",),
}
# `CPC (USD)`, `CPC (EUR)`, `CPC (en USD)` : la devise du compte est dans l'en-tête. Aucune
# énumération ne tient, d'où un motif — appliqué seulement si aucun en-tête exact ne matche.
_CPC_PATTERN = {"cpc": r"cpc(\s*\(.*\))?"}


def _signatures(
    required: frozenset[str], absent: tuple[str, ...] = ()
) -> tuple[ColumnSignature, ...]:
    return tuple(
        ColumnSignature(
            language=language,
            columns={**columns, "cpc": ()},
            required=required,
            absent=absent,
            patterns=_CPC_PATTERN,
        )
        for language, columns in (("en", _EN), ("fr", _FR))
    )


def _row(
    line: int, cells: Mapping[str, str | None], columns: Mapping[str, str], raw: dict[str, Any]
) -> ParsedRow:
    """Traduit une ligne lue vers le canonique — la partie commune aux deux exports."""

    def cell(field: str) -> str | None:
        header = columns.get(field)
        return None if header is None else cells.get(header)

    keyword = normalize_keyword(cell("keyword") or "")
    if not keyword:
        return RowError(line, "mot-clé vide")
    return CanonicalKeywordRow(
        line=line,
        keyword=keyword,
        volume=to_int(cell("volume")),
        kd=to_decimal(cell("kd")),
        cpc=to_decimal(cell("cpc")),
        intent=to_intent(cell("intent")),
        serp_features=to_serp_features(cell("serp_features")),
        trend=to_trend(cell("trend")),
        url=(cell("url") or "").strip() or None,
        position=to_decimal(cell("position")),
        raw=raw,
    )


class _SemrushAdapter(Adapter):
    def parse_csv(
        self, path: Path, *, options: Mapping[str, Any] | None = None
    ) -> Iterator[ParsedRow]:
        with CsvScan(path) as scan:
            detection = self.detect(scan.headers)
            if detection is None:
                yield RowError(1, f"en-têtes non reconnus par l'adaptateur {self.name}")
                return
            headers = scan.headers
            # Ligne 1 = en-tête : la première ligne de données est la ligne 2. Ce numéro
            # est celui du **rang de données** ; il diverge du numéro physique après une
            # ligne écartée par le lecteur CSV en amont (les rejets, eux, portent leur vrai
            # numéro). Les pieds de page réels étant en fin de fichier, l'écart est nul dans
            # la pratique — et un numéro approché vaut mieux qu'un second passage sur 500 k
            # lignes pour le rendre exact.
            line = 1
            for batch in scan.batches():
                for values in batch:
                    line += 1
                    raw = {header: value for header, value in zip(headers, values, strict=False)}
                    yield _row(line, raw, detection.columns, raw)
            for rejected in scan.rejects():
                # Numéro **physique**, rendu par le lecteur : c'est le repère de référence.
                yield RowError(rejected.line, rejected.reason, physical=True)


class SemrushPositions(_SemrushAdapter):
    """Organic Research → Positions : **avec** URL et position (§5).

    Une ligne par (mot-clé, URL) : un mot-clé qui classe sur trois pages produit trois
    lignes. C'est le doublon intra-fichier **attendu** que le rapport distingue (§4.5) — la
    cannibalisation vit dans les maps, pas dans les métriques.
    """

    name = "semrush_positions"
    signatures = _signatures(required=frozenset({"keyword", "url", "position"}))
    #  Explicite plutôt qu'hérité : c'est une dette datée, pas un défaut d'attention. Voir
    #  l'en-tête du module — la capacité est due dès qu'un export Positions réel existe.
    country_column = None


class SemrushMagic(_SemrushAdapter):
    """Keyword Magic Tool / Keyword Overview : ni URL ni position (§5)."""

    name = "semrush_magic"
    signatures = _signatures(required=frozenset({"keyword", "volume"}), absent=("url", "position"))
    #  L'export réel ne porte aucune colonne pays : il n'y a rien à déclarer, et c'est
    #  définitif tant que Semrush n'en ajoute pas une.
    country_column = None
