"""`generic_csv` — mapping manuel des colonnes, adaptateur **de premier rang** (spec §5).

Ce n'est pas un filet de sécurité v1.1 : c'est la réponse permanente à toute source dont
les colonnes n'ont pas encore été constatées (Keysearch, LowFruits, Mangools… — cas
résiduel §10.3). L'utilisateur associe lui-même les colonnes du fichier aux champs
canoniques à l'étape 2 du wizard ; l'adaptateur ne devine rien et **ne se détecte jamais
tout seul** — il n'a pas de signature, il est proposé par le registre quand aucune autre
n'a matché (§5).

Le mapping arrive dans le payload du job, sous `options['columns']` :
`{"keyword": "Requête", "volume": "Vol. mensuel", "url": "Page"}`. Les champs absents du
mapping sont simplement absents du canonique ; `keyword` est le seul obligatoire.

**La colonne pays passe à côté du mapping**, par `options['country_column']` (spec
« Multi-sources et cloisonnement pays » §4.1, question Q4). Elle n'entre pas dans
`CANONICAL_FIELDS` : le pays n'est pas une métrique du mot-clé mais une propriété de la
ligne, il n'a ni colonne canonique, ni type, ni place dans `PreviewRow`. L'y faire entrer
demanderait de toucher six endroits (le contrôle de `parse_csv` ci-dessous, `_SYNONYMS` et
`_FIELD_ORDER` de `suggest.py` et son double filtrage final, `_FIELD_LABELS` du module
Imports, `PreviewRow` et son miroir Zod côté web) pour élargir le modèle canonique à un
attribut qui n'y appartient pas. Le canal parallèle est local, testable seul, et laisse
`CANONICAL_FIELDS` dire exactement ce qu'il dit aujourd'hui.
"""

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 (
    CANONICAL_FIELDS,
    Adapter,
    CanonicalKeywordRow,
    ParsedRow,
    RowError,
    header_index,
    normalize_header,
    to_decimal,
    to_int,
    to_intent,
    to_serp_features,
    to_trend,
)
from app.keywords.sources.csvfile import CsvScan


class MappingError(ValueError):
    """Mapping de colonnes inutilisable : erreur d'appel, pas une ligne en erreur.

    Elle fait échouer le job (et donc l'import), à raison : un mapping faux produirait un
    fichier entier de lignes en erreur, ce qui est un échec déguisé. Le T3 la transformera
    en 422 au moment du POST, avant même d'enfiler le job.
    """


class GenericCsv(Adapter):
    name = "generic_csv"
    # Aucune signature : `generic_csv` ne se reconnaît pas, il se choisit.
    signatures = ()

    def country_header(self, options: Mapping[str, Any] | None = None) -> str | None:
        """La colonne pays **désignée par l'utilisateur**, en-tête réel du fichier.

        Rien n'est deviné : un `generic_csv` sans `country_column` n'a pas de pays, son
        import n'est pas cloisonné, et c'est la cible qui fait autorité — le comportement
        d'avant le cloisonnement, conservé par défaut.
        """
        chosen = str((options or {}).get("country_column") or "").strip()
        return chosen or None

    def parse_csv(
        self, path: Path, *, options: Mapping[str, Any] | None = None
    ) -> Iterator[ParsedRow]:
        mapping = dict((options or {}).get("columns") or {})
        unknown = sorted(set(mapping) - set(CANONICAL_FIELDS))
        if unknown:
            raise MappingError(f"champs canoniques inconnus : {', '.join(unknown)}")
        if not mapping.get("keyword"):
            raise MappingError("le mapping doit associer une colonne au champ « keyword »")
        with CsvScan(path) as scan:
            headers = scan.headers
            index = header_index(headers)
            # Les en-têtes du mapping sont confrontés à ceux du fichier **avant** de lire une
            # seule ligne : une colonne mal nommée est une erreur d'appel immédiate, pas
            # 500 000 lignes en erreur découvertes à la fin.
            resolved: dict[str, str] = {}
            for canonical, header in mapping.items():
                real = index.get(normalize_header(str(header)))
                if real is None:
                    raise MappingError(f"colonne absente du fichier : {header!r}")
                resolved[canonical] = real
            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, resolved)
            for rejected in scan.rejects():
                yield RowError(rejected.line, rejected.reason, physical=True)


def _row(line: int, raw: dict[str, Any], columns: Mapping[str, str]) -> ParsedRow:
    def cell(field: str) -> str | None:
        header = columns.get(field)
        return None if header is None else raw.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,
    )
