"""Adaptateurs de source et détection par signature (spec Imports §5, §11).

Ce que ces tests protègent : **la promesse que le wizard ne se trompe pas de format et
n'invente rien**. La détection ne regarde que les en-têtes — jamais un nom de fichier — et
les deux exports Semrush ne se distinguent que par la présence de `URL`/`Position`.

Fixtures :

- `real/semrush_magic_us.csv` — **export réel** (500 premières lignes d'un export Keyword
  Magic de 50 000, plus son pied de page commercial tel quel). C'est lui qui fait foi.
- `real/kwfinder_usa_top.csv` — **export réel Mangools KWFinder** (21 lignes, 147 colonnes
  dont 133 volumes mensuels). Il servait de contre-exemple tant qu'aucun adaptateur ne le
  reconnaissait ; depuis T7 il fait foi pour `mangools`. Le contre-exemple, lui, est
  maintenant un fichier **inventé** : un export réel qu'on sait lire ne prouve plus rien sur
  le refus.
- `semrush_positions_{en,fr}.csv` — **construites** depuis le format documenté (§5), le
  temps qu'un export Positions réel soit fourni. Elles couvrent les deux séparateurs, les
  deux encodages et les deux conventions décimales.
"""

from decimal import Decimal
from pathlib import Path

import pytest

from app.keywords import sources
from app.keywords.sources import csvfile
from app.keywords.sources.base import (
    AmbiguousHeaderError,
    CanonicalKeywordRow,
    RowError,
    normalize_header,
    to_decimal,
    to_intent,
    to_physical_lines,
    to_trend,
)
from app.keywords.sources.generic import MappingError

FIXTURES = Path(__file__).parent / "fixtures"
REAL = FIXTURES / "real"
MAGIC = REAL / "semrush_magic_us.csv"
KWFINDER = REAL / "kwfinder_usa_top.csv"
POSITIONS_EN = FIXTURES / "semrush_positions_en.csv"
POSITIONS_FR = FIXTURES / "semrush_positions_fr.csv"


def _parse(source: str, path: Path, **options: object) -> list[CanonicalKeywordRow | RowError]:
    adapter = sources.get(source)
    assert adapter is not None
    return list(adapter.parse_csv(path, options=options or None))


# --- verrou de nommage du registre ---------------------------------------------------------


def test_registry_names_are_frozen() -> None:
    """Les noms d'adaptateurs sont un contrat de données, pas un détail d'implémentation.

    Ils atterrissent dans `keyword_metrics.source` et `keyword_lists.source` : les renommer,
    ou en introduire un de travers, coupe la provenance des lignes déjà écrites en deux
    sources qui ne se recolleront jamais sans migration. La suppression de la hiérarchie
    (spec 2026-09-01 §3.1) a emporté les ensembles `KNOWN_SOURCES` /
    `CONSOLIDATION_SOURCES` / `FALLBACK_SOURCES` qui jouaient ce rôle par ricochet — voici le
    verrou honnête promis dans `sources/__init__.py`, à mettre à jour **sciemment** le jour
    où un adaptateur s'ajoute (T7/T8 DataForSEO).
    """
    assert sources.names() == [
        "generic_csv",
        "mangools",
        "semrush_magic",
        "semrush_positions",
    ]


# --- export réel Keyword Magic -----------------------------------------------------------


def test_real_magic_export_is_detected_by_its_columns() -> None:
    detection = sources.best(csvfile.read_header(MAGIC))
    assert detection is not None
    assert (detection.source, detection.language) == ("semrush_magic", "en")
    # La colonne CPC porte la devise du compte : elle n'est reconnue que par motif.
    assert detection.columns["cpc"] == "CPC (USD)"
    assert detection.columns["volume"] == "Volume"


def test_real_magic_export_parses_with_exact_counts() -> None:
    parsed = _parse("semrush_magic", MAGIC)
    rows = [row for row in parsed if isinstance(row, CanonicalKeywordRow)]
    errors = [row for row in parsed if isinstance(row, RowError)]
    assert len(rows) == 500
    # Le pied de page commercial réel : trois lignes, trois erreurs numérotées — jamais un
    # import qui échoue, jamais trois mots-clés fantômes.
    assert [error.line for error in errors] == [502, 503, 504]
    assert "You can export only first 50000 rows" in errors[0].reason

    first = rows[0]
    assert first.keyword == "jewelry manufacturing"
    assert (first.volume, first.intent, first.cpc) == (170, "commercial", Decimal("2.26"))
    assert first.kd == Decimal("20")
    assert first.trend == [17, 33, 33, 43, 43, 27, 67, 53, 33, 100, 53, 80]
    # Ni URL ni position dans cet export : tous les mots-clés seront orphelins (§0).
    assert first.url is None and first.position is None
    # `Relevance` n'est pas documentée en spec §5 et n'a pas d'équivalent canonique :
    # rangée dans `raw`, jamais perdue.
    assert first.raw["Relevance"] == "100"


def test_real_magic_export_tolerates_empty_cells() -> None:
    """Intent, Keyword Difficulty et Number of Results manquent sur des milliers de lignes :
    une cellule vide vaut `None`, jamais une ligne en erreur."""
    rows = [row for row in _parse("semrush_magic", MAGIC) if isinstance(row, CanonicalKeywordRow)]
    holed = next(row for row in rows if row.keyword == "jewelery manufacturing")
    assert (holed.intent, holed.kd) == (None, None)
    assert holed.volume == 0


# --- export réel Mangools KWFinder --------------------------------------------------------


def test_real_kwfinder_export_is_detected_by_its_columns() -> None:
    """Le fichier ne tombe plus dans `generic_csv` : il a sa signature (T7).

    Il partage `Keyword`, `Keyword Difficulty`, `Search Intent` et `Serp Features` avec
    Semrush — d'où l'exigence de l'en-tête verbeux `Avg. Search Volume (Last 12 months)`, que
    Semrush n'écrit jamais : les signatures restent mutuellement exclusives.
    """
    headers = csvfile.read_header(KWFINDER)
    detection = sources.best(headers)
    assert detection is not None
    assert (detection.source, detection.language) == ("mangools", "en")
    assert [candidate.source for candidate in sources.detect(headers)] == ["mangools"]
    # La moyenne douze mois gagne sur les trois autres ; la devise est reconnue par motif.
    assert detection.columns["volume"] == "Avg. Search Volume (Last 12 months)"
    assert detection.columns["cpc"] == "CPC/USD"
    # `trend` ne vient pas d'une colonne mais de 133 : l'aperçu nomme celle qui les termine,
    # pour que la série mensuelle ne soit pas comptée parmi les colonnes ignorées.
    assert detection.columns["trend"] == "Search Volume 7/2026"


def test_real_kwfinder_export_parses_with_exact_values() -> None:
    parsed = _parse("mangools", KWFINDER)
    rows = [row for row in parsed if isinstance(row, CanonicalKeywordRow)]
    assert [row for row in parsed if isinstance(row, RowError)] == []
    assert len(rows) == 21

    first = rows[0]
    assert (first.line, first.keyword) == (2, "manufacturer jewelry")
    assert first.volume == 2300  # la moyenne 12 mois, pas les 2400 de « Last Known Values »
    assert (first.kd, first.cpc) == (Decimal("17"), None)  # CPC vide sur cette ligne
    assert first.intent == "commercial"
    assert first.serp_features == ["Map Pack", "People Also Ask"]
    # KWFinder n'observe aucun classement : orphelins par nature (§0).
    assert first.url is None and first.position is None
    # Les colonnes sans équivalent canonique restent dans `raw`, jamais perdues.
    assert first.raw["Location"] == "United States"
    assert first.raw["Content Type"] == "REDDIT, HOMEPAGES"
    assert first.raw["Keyword interest growth (%)"] == "-28"

    second = rows[1]
    assert (second.keyword, second.volume, second.cpc) == (
        "jewelry manufacturing",
        2200,
        Decimal("2.97"),
    )
    # Intention vide : `None`, jamais une ligne en erreur.
    assert rows[2].intent is None
    # Plusieurs intentions : la forme canonique est figée (minuscules, virgules sans espace).
    multiple = next(row for row in rows if row.keyword == "jewelry cad")
    assert multiple.intent == "informational,commercial,transactional"


def test_kwfinder_trend_comes_from_the_monthly_columns() -> None:
    """LA raison d'être de cet adaptateur : la vraie sparkline, que rien ne pouvait construire.

    `Keyword interest growth (%)` ne porte qu'**une** croissance ; les douze valeurs viennent
    des colonnes `Search Volume M/YYYY`, ramenées en part du maximum (0–100) comme les douze
    flottants de Semrush.
    """
    rows = [row for row in _parse("mangools", KWFINDER) if isinstance(row, CanonicalKeywordRow)]
    # 6/2025 → 5/2026 : les douze derniers mois **connus** de la ligne, 6 et 7/2026 étant
    # encore vides. Maximum 4400 (6/2025) → 100 ; 1900 → 43 ; 3600 → 82 ; 1600 → 36.
    assert rows[0].trend == [100, 43, 43, 43, 82, 66, 43, 82, 43, 43, 36, 36]
    # La ligne suivante a un mois de plus (jusqu'à 7/2026) : sa fenêtre est décalée d'autant.
    assert rows[1].trend == [53, 53, 100, 81, 53, 100, 53, 53, 44, 44, 44, 53]


def test_kwfinder_gives_up_on_a_stale_or_empty_series() -> None:
    """Une sparkline de 2019 dans un fichier de 2026 serait un **mensonge**, pas un ornement.

    À l'écran, rien ne distingue une tendance ancienne d'une tendance actuelle : un mot-clé
    éteint paraîtrait vivant. La fenêtre doit se terminer à moins de six mois du mois le plus
    récent du fichier, sinon pas de tendance du tout (les volumes mensuels restent dans `raw`).
    """
    rows = [row for row in _parse("mangools", KWFINDER) if isinstance(row, CanonicalKeywordRow)]
    # Mot-clé éteint : douze mois connus, mais qui s'arrêtent en 1/2020 quand le fichier va
    # jusqu'à 7/2026. Son volume est vide, sa tendance doit l'être aussi.
    stale = next(row for row in rows if row.keyword == "jewlery casting")
    assert (stale.volume, stale.trend) == (None, None)
    # Les autres sans tendance n'ont **aucun** volume mensuel — et restent des lignes valides.
    assert [row.keyword for row in rows if row.trend is None] == [
        "jewlery casting",
        "jewel manufacturing factory",
        "wax injection jewelry manufacturer",
        "jewelry cad manufacturer",
    ]


def _mangools_csv(*months: tuple[str, str]) -> str:
    """Un export KWFinder minimal : une ligne, et les mois qu'on lui donne."""
    headers = ["Keyword", "Avg. Search Volume (Last 12 months)", "Keyword Difficulty"]
    values = ["chaussures rouges", "1300", "17"]
    for header, value in months:
        headers.append(f"Search Volume {header}")
        values.append(value)
    return f"{','.join(headers)}\n{','.join(values)}\n"


def test_trend_skips_holes_instead_of_inventing_zeroes(tmp_path: Path) -> None:
    """Un mois manquant est sauté, jamais comblé : un zéro se lirait comme un effondrement.

    Sur un fichier construit, parce que la seule ligne trouée de l'export réel est justement
    celle que la borne de fraîcheur écarte — et que la règle, elle, vaut pour toutes.
    """
    path = tmp_path / "trous.csv"
    #  Quinze colonnes, deux trous internes et une queue vide : douze mois connus quand même,
    #  et la fenêtre se termine à un mois de la fin du fichier.
    path.write_text(
        _mangools_csv(
            ("5/2025", "100"),
            ("6/2025", ""),
            ("7/2025", "200"),
            ("8/2025", "200"),
            ("9/2025", ""),
            ("10/2025", "400"),
            ("11/2025", "200"),
            ("12/2025", "200"),
            ("1/2026", "200"),
            ("2/2026", "200"),
            ("3/2026", "200"),
            ("4/2026", "200"),
            ("5/2026", "200"),
            ("6/2026", "100"),
            ("7/2026", ""),
        ),
        encoding="utf-8",
    )
    rows = [row for row in _parse("mangools", path) if isinstance(row, CanonicalKeywordRow)]
    # Douze valeurs, pas quinze : les deux trous sont sautés, la queue vide ignorée. Pic 400.
    assert rows[0].trend == [25, 50, 50, 100, 50, 50, 50, 50, 50, 50, 50, 25]


def test_trend_tolerates_a_tail_of_unpublished_months(tmp_path: Path) -> None:
    """Six mois de queue vide passent encore : c'est la borne, et elle est franche.

    Un mot-clé de faible volume peut n'avoir aucune donnée récente sans être mort ; au-delà,
    on préfère ne rien montrer.
    """
    months = [(f"{month}/2025", "100") for month in range(1, 13)]
    tail = [(f"{month}/2026", "") for month in range(1, 8)]

    within = tmp_path / "queue_courte.csv"
    within.write_text(_mangools_csv(*months, *tail[:6]), encoding="utf-8")
    rows = [row for row in _parse("mangools", within) if isinstance(row, CanonicalKeywordRow)]
    assert rows[0].trend == [100] * 12

    beyond = tmp_path / "queue_longue.csv"
    beyond.write_text(_mangools_csv(*months, *tail), encoding="utf-8")
    rows = [row for row in _parse("mangools", beyond) if isinstance(row, CanonicalKeywordRow)]
    assert rows[0].trend is None


def test_kwfinder_stays_importable_through_an_explicit_generic_mapping() -> None:
    """La détection ne ferme aucune porte : un format explicite fait autorité (§5, cas §10.3).

    C'est ainsi qu'on importe la moyenne « Last Known Values » plutôt que celle de douze mois.
    """
    rows = _parse(
        "generic_csv",
        KWFINDER,
        columns={
            "keyword": "Keyword",
            "volume": "Avg. Search Volume (Last Known Values)",
            "kd": "Keyword Difficulty",
            "intent": "Search Intent",
        },
    )
    canonical = [row for row in rows if isinstance(row, CanonicalKeywordRow)]
    assert len(canonical) == 21
    assert canonical[0].keyword == "manufacturer jewelry"
    assert (canonical[0].volume, canonical[0].kd) == (2400, Decimal("17"))


# --- export inconnu : refus propre --------------------------------------------------------


def test_unknown_export_is_not_detected(tmp_path: Path) -> None:
    """Un export dont aucune signature ne reconnaît les colonnes n'a **aucune** candidature.

    Le fichier est inventé, et à dessein piégeux : il porte `Keyword Difficulty` et
    `Search Intent` (Semrush **et** KWFinder), une moyenne de volume verbeuse mais qui n'est
    aucune des quatre de KWFinder, un CPC à la ponctuation d'un troisième outil — et des
    colonnes mensuelles, qui ne déclenchent rien à elles seules. Aucune candidature =
    `generic_csv` et son mapping manuel (§5), jamais un parse silencieusement faux sous une
    mauvaise étiquette de source.
    """
    path = tmp_path / "outil_inconnu.csv"
    path.write_text(
        "Keyword,Avg. Search Volume (Last 24 months),Keyword Difficulty,CPC~USD,Search Intent,"
        "Search Volume 6/2026,Search Volume 7/2026\n"
        "chaussures rouges,1300,17,0.55,commercial,1200,1400\n",
        encoding="utf-8",
    )
    headers = csvfile.read_header(path)
    assert "Keyword Difficulty" in headers  # le piège est bien présent
    assert sources.detect(headers) == []
    assert sources.best(headers) is None


# --- Positions vs Magic : le discriminant est la colonne, pas le nom du fichier ----------


def test_positions_and_magic_are_mutually_exclusive() -> None:
    positions = sources.detect(csvfile.read_header(POSITIONS_EN))
    magic = sources.detect(csvfile.read_header(MAGIC))
    assert [detection.source for detection in positions] == ["semrush_positions"]
    assert [detection.source for detection in magic] == ["semrush_magic"]


def test_magic_signature_is_disqualified_by_url_and_position() -> None:
    """La règle en clair : les colonnes d'observation excluent le Keyword Magic."""
    magic = sources.get("semrush_magic")
    assert magic is not None
    assert magic.detect(["Keyword", "Volume", "Trend", "Keyword Difficulty"]) is not None
    assert magic.detect(["Keyword", "Volume", "Trend", "Position", "URL"]) is None


def test_positions_requires_url_and_position() -> None:
    positions = sources.get("semrush_positions")
    assert positions is not None
    assert positions.detect(["Keyword", "Volume", "Position"]) is None
    assert positions.detect(["Keyword", "Volume", "URL"]) is None
    assert positions.detect(["Keyword", "Volume", "URL", "Position"]) is not None


# --- signature multilingue, séparateur, encodage -----------------------------------------


def test_french_semicolon_utf16_export_is_read_like_the_english_one() -> None:
    """Un export FR « CSV Semicolon » en UTF-16 : trois pièges d'un coup (§4.1)."""
    assert csvfile.detect_encoding(POSITIONS_FR) == "utf-16"
    detection = sources.best(csvfile.read_header(POSITIONS_FR))
    assert detection is not None
    assert (detection.source, detection.language) == ("semrush_positions", "fr")

    rows = [
        row
        for row in _parse("semrush_positions", POSITIONS_FR)
        if isinstance(row, CanonicalKeywordRow)
    ]
    first = rows[0]
    assert first.keyword == "chaussures rouges"
    assert first.volume == 1300  # « 1 300 » : espace de milliers, insécable compris
    assert first.cpc == Decimal("0.55")  # « 0,55 » : virgule décimale d'un compte européen
    assert first.position == Decimal("3")


def test_utf8_export_is_never_taken_for_latin1(tmp_path: Path) -> None:
    """Un « é » lu en latin-1 deviendrait un mot-clé distinct — l'unicité n'en protège pas."""
    path = tmp_path / "accents.csv"
    path.write_text("Keyword,Volume,Trend\nchaussures été,100,\n", encoding="utf-8")
    assert csvfile.detect_encoding(path) == "utf-8"
    rows = [row for row in _parse("semrush_magic", path) if isinstance(row, CanonicalKeywordRow)]
    assert rows[0].keyword == "chaussures été"


def test_latin1_export_is_still_readable(tmp_path: Path) -> None:
    path = tmp_path / "latin1.csv"
    path.write_bytes("Mot clé;Volume;Tendance\nchaussures été;100;\n".encode("latin-1"))
    # Aucun encodage que `read_csv` accepte : le fichier passe par la transcription.
    assert csvfile.detect_encoding(path) == ""
    rows = [row for row in _parse("semrush_magic", path) if isinstance(row, CanonicalKeywordRow)]
    assert rows[0].keyword == "chaussures été"


def test_cp1252_export_is_read_not_rejected(tmp_path: Path) -> None:
    """Le cas réel du tableur FR : apostrophe courbe, œ, € — tous dans la plage C1.

    `read_csv(encoding='latin-1')` ne se contente pas de décoder, il **valide**, et rejette le
    fichier entier sur ces octets (« File is not latin-1 encoded »). Un export passé par Excel
    ferait donc échouer l'import complet sur une erreur DuckDB opaque. La transcription les
    rend exactement, sans extension DuckDB à installer.
    """
    path = tmp_path / "cp1252.csv"
    path.write_bytes("Keyword,Volume,Trend\nchaussures d’été,100,\ncœur €,50,\n".encode("cp1252"))
    assert csvfile.detect_encoding(path) == ""
    rows = [row for row in _parse("semrush_magic", path) if isinstance(row, CanonicalKeywordRow)]
    assert [row.keyword for row in rows] == ["chaussures d’été", "cœur €"]


def test_undefined_cp1252_bytes_never_break_the_import(tmp_path: Path) -> None:
    """0x81, 0x8D, 0x8F, 0x90 et 0x9D n'existent pas en cp1252 : latin-1 ferme la chaîne."""
    path = tmp_path / "exotique.csv"
    path.write_bytes(b"Keyword,Volume,Trend\nchaussures \x81\x9d,100,\n")
    rows = [row for row in _parse("semrush_magic", path) if isinstance(row, CanonicalKeywordRow)]
    assert len(rows) == 1
    assert rows[0].keyword.startswith("chaussures")


# --- en-têtes ambigus : erreur claire, jamais un écrasement silencieux --------------------


def test_homonymous_headers_raise_instead_of_overwriting(tmp_path: Path) -> None:
    """« Mot-clé » et « Mot clé » se confondent après normalisation.

    Sans garde, la seconde colonne écrase la première dans l'index et l'import lit la mauvaise
    colonne **sans que rien ne le dise** — le pire des deux mondes.
    """
    path = tmp_path / "homonymes.csv"
    path.write_text("Mot-clé;Mot clé;Volume;Tendance\na;b;100;\n", encoding="utf-8")
    headers = csvfile.read_header(path)
    # Le lecteur les rend tels quels : ce sont bien deux colonnes distinctes du fichier.
    assert headers[:2] == ["Mot-clé", "Mot clé"]
    # C'est l'interprétation qui refuse de trancher — détection comme mapping manuel.
    with pytest.raises(AmbiguousHeaderError, match="même en-tête"):
        sources.detect(headers)
    with pytest.raises(AmbiguousHeaderError):
        _parse("generic_csv", path, columns={"keyword": "Mot clé"})
    with pytest.raises(AmbiguousHeaderError):
        _parse("semrush_magic", path)


def test_exactly_duplicated_headers_are_disambiguated_by_the_reader(tmp_path: Path) -> None:
    """Deux colonnes strictement identiques : DuckDB suffixe la seconde, rien ne collisionne."""
    path = tmp_path / "doublons.csv"
    path.write_text("Keyword,Keyword,Volume,Trend\na,b,100,\n", encoding="utf-8")
    assert csvfile.read_header(path) == ["Keyword", "Keyword_1", "Volume", "Trend"]
    rows = [row for row in _parse("semrush_magic", path) if isinstance(row, CanonicalKeywordRow)]
    assert rows[0].keyword == "a"


# --- numéros de ligne : le repère est celui du fichier de l'utilisateur -------------------


def test_torn_line_in_the_middle_does_not_collide_with_a_valid_row(tmp_path: Path) -> None:
    """Sonde de la review : une ligne déchirée **en milieu** de fichier.

    L'adaptateur compte en rang de données — il ne voit jamais les lignes que le lecteur a
    écartées — donc sans recalage, la ligne 3 écartée et la ligne 4 (« vide », en erreur pour
    mot-clé manquant) porteraient **le même numéro**. L'utilisateur qui ouvre son tableur pour
    corriger regarderait la mauvaise ligne.
    """
    path = tmp_path / "dechiree.csv"
    path.write_text(
        "Keyword,Volume,Trend\n"  # ligne 1
        "ok un,100,\n"  # ligne 2
        "ligne;déchirée\n"  # ligne 3 — mauvais nombre de colonnes
        ",50,\n"  # ligne 4 — mot-clé vide
        "ok deux,200,\n",  # ligne 5
        encoding="utf-8",
    )
    parsed = _parse("semrush_magic", path)
    errors = to_physical_lines(
        [row for row in parsed if isinstance(row, RowError)],
        [row.line for row in parsed if isinstance(row, RowError) and row.physical],
    )
    lines = sorted(error.line for error in errors)
    assert lines == [3, 4], "les deux erreurs doivent porter leur vraie ligne, sans collision"
    assert len(set(lines)) == len(lines)
    # Les lignes valides encadrant la déchirure sont bien toutes les deux importées.
    keywords = [row.keyword for row in parsed if isinstance(row, CanonicalKeywordRow)]
    assert keywords == ["ok un", "ok deux"]


# --- lignes inexploitables ----------------------------------------------------------------


def test_empty_keyword_is_an_error_row_not_a_failure() -> None:
    parsed = _parse("semrush_positions", POSITIONS_EN)
    errors = [row for row in parsed if isinstance(row, RowError)]
    assert [(error.line, error.reason) for error in errors] == [(6, "mot-clé vide")]
    # Les lignes qui suivent l'erreur sont bien là : rien n'est abandonné en route.
    assert any(row.keyword == "escarpins" for row in parsed if isinstance(row, CanonicalKeywordRow))


# --- generic_csv : mapping manuel ---------------------------------------------------------


def test_generic_csv_never_detects_itself() -> None:
    assert sources.GENERIC.signatures == ()
    assert sources.GENERIC.detect(["Keyword", "Volume"]) is None


def test_generic_csv_refuses_an_unusable_mapping() -> None:
    with pytest.raises(MappingError, match="keyword"):
        _parse("generic_csv", POSITIONS_EN, columns={"volume": "Search Volume"})
    with pytest.raises(MappingError, match="absente du fichier"):
        _parse("generic_csv", POSITIONS_EN, columns={"keyword": "Requête"})
    with pytest.raises(MappingError, match="inconnus"):
        _parse("generic_csv", POSITIONS_EN, columns={"keyword": "Keyword", "score": "Traffic"})


# --- coercions ------------------------------------------------------------------------------


@pytest.mark.parametrize(
    ("raw", "expected"),
    [
        ("1300", Decimal(1300)),
        ("1 300", Decimal(1300)),  # espace insécable d'un export FR
        ("1,234.5", Decimal("1234.5")),  # milliers anglo-saxons
        ("0,55", Decimal("0.55")),  # virgule décimale européenne
        ("45%", Decimal(45)),
        ("", None),
        ("   ", None),
        ("n/a", None),
        ("pas un nombre", None),
    ],
)
def test_to_decimal(raw: str, expected: Decimal | None) -> None:
    assert to_decimal(raw) == expected


def test_to_trend_scales_to_smallints() -> None:
    assert to_trend("0.17,0.33,0.33,0.43,0.43,0.27,0.67,0.53,0.33,1.00,0.53,0.80") == [
        17,
        33,
        33,
        43,
        43,
        27,
        67,
        53,
        33,
        100,
        53,
        80,
    ]
    assert to_trend("0.17,0.33") is None  # tendance incomplète : ornement, jamais une erreur
    assert to_trend("") is None


def test_to_intent_normalises_multiple_intents() -> None:
    assert to_intent("informational, commercial, Transactional") == (
        "informational,commercial,transactional"
    )
    assert to_intent("") is None


def test_normalize_header_unifies_spacing_and_case() -> None:
    assert normalize_header("﻿ Mot-clé ") == "mot clé"
    assert normalize_header("Search  Volume") == "search volume"
    assert normalize_header("CPC (USD)") == "cpc (usd)"
