"""Capacité pays des adaptateurs et recensement DuckDB (spec §4.1/§4.2, unité B1).

Tests **purs** : aucune base, aucune requête HTTP. Ce qui est éprouvé ici, c'est le contrat
entre les adaptateurs et le module Imports — quelle colonne porte le pays, et ce que le
`GROUP BY` en tire du fichier entier. Le bout en bout (résolution, cibles existantes,
sérialisation) vit dans `test_imports_api.py`.

Trois choses valent d'être dites avant de modifier ce fichier :

- **la capacité est déclarative, jamais devinée** : un adaptateur dit quelle colonne il lit,
  ou il dit qu'il n'en a pas. Aucune heuristique du genre « la première colonne qui ressemble
  à un pays » — c'est elle qui enverrait un export Semrush entier « hors cible » ;
- **le recensement lit tout le fichier**, alors que l'aperçu ne lit que vingt lignes. Le
  témoin `test_the_fixture_hides_markets_beyond_the_preview_window` garde cette différence
  vivante : sans lui, une fixture régénérée avec ses onze pays dans les vingt premières
  lignes laisserait tous les autres tests passer sans plus rien prouver ;
- **le coût est mesuré, pas supposé** : le wizard rejoue l'aperçu à chaque changement de
  format et à chaque « Appliquer le mapping ».
"""

import csv
import time
from pathlib import Path

import duckdb
import pytest

from app.imports.schemas import PREVIEW_ROWS
from app.keywords import sources
from app.keywords.sources.csvfile import count_by_column, read_header

FIXTURES = Path(__file__).parent / "fixtures"
MULTI_COUNTRY = FIXTURES / "real" / "kwfinder_multi_country.csv"
KWFINDER = FIXTURES / "real" / "kwfinder_usa_top.csv"
# 50 000 lignes, 5,2 Mio : le plus gros export réel du dépôt, et le pire cas d'un `GROUP BY`
# (une valeur distincte par ligne quand on le passe sur la colonne des mots-clés).
SEMRUSH_FULL = FIXTURES / "real" / "semrush_magic_us_full.csv"

# Répartition de l'export multi-marchés, dans l'ordre rendu (lignes décroissantes, puis
# libellé). C'est la table de vérité de la fixture : la recopier ailleurs la ferait diverger.
EXPECTED = [
    ("United States", 74),
    ("United Kingdom", 55),
    ("Australia", 32),
    ("Canada", 13),
    ("Singapore", 12),
    ("Hong Kong", 11),
    ("Germany", 10),
    ("New Zealand", 8),
    ("Spain", 8),
    ("New York", 3),
    ("Israel", 2),
    ("Japan", 1),
]


# --- capacité déclarée par adaptateur -----------------------------------------------------


def test_mangools_reads_its_location_column() -> None:
    assert sources.get("mangools").country_header() == "Location"


@pytest.mark.parametrize("name", ["semrush_positions", "semrush_magic"])
def test_semrush_declares_no_country_column(name: str) -> None:
    """Dette assumée, pas oubli (Q3) : aucun export Positions réel n'existe pour la valider.

    Keyword Magic, lui, n'écrit tout simplement pas son pays — vérifié sur l'export réel.
    Déclarer `Database` sur la foi de la documentation violerait la règle §5 du module
    (« fixture d'export réel obligatoire avant d'écrire le parser ») et pourrait se tromper à
    la fois sur le nom de l'en-tête et sur la forme de la valeur.
    """
    assert sources.get(name).country_header() is None
    assert sources.get(name).country_header({"country_column": "Database"}) is None


def test_generic_csv_reads_the_column_it_is_given() -> None:
    """Canal parallèle (Q4) : la colonne pays ne passe pas par le mapping canonique."""
    generic = sources.GENERIC
    assert generic.country_header() is None
    assert generic.country_header({}) is None
    assert generic.country_header({"columns": {"keyword": "Requete"}}) is None
    assert generic.country_header({"country_column": "Marche"}) == "Marche"
    # Une chaîne vide ou blanche est une absence de choix, pas une colonne sans nom.
    assert generic.country_header({"country_column": "   "}) is None


def test_the_country_column_stays_out_of_the_canonical_fields() -> None:
    """`country` n'est pas un champ canonique, et ne doit pas le devenir par inadvertance.

    `GenericCsv.parse_csv` refuse tout champ hors `CANONICAL_FIELDS` : y glisser `country`
    ferait passer la colonne pays par le mapping (six endroits à changer, dont le miroir Zod)
    au lieu du canal parallèle.
    """
    assert "country" not in sources.base.CANONICAL_FIELDS
    with pytest.raises(sources.MappingError, match="country"):
        next(sources.GENERIC.parse_csv(KWFINDER, options={"columns": {"country": "Location"}}))


def test_declaring_a_country_column_does_not_move_the_detection() -> None:
    """La colonne pays est hors signature : la confiance de détection ne bouge pas (§4.1).

    L'ajouter aux `signatures` diviserait la confiance de *tous* les fichiers par un
    dénominateur plus grand, y compris ceux qui ne portent pas la colonne — et
    `sources.best()` rend `None` dès que les deux meilleures candidatures sont ex æquo, ce qui
    basculerait des fichiers reconnus vers `generic_csv` sans un mot.
    """
    best = sources.best(read_header(KWFINDER))
    assert best is not None
    assert (best.source, best.confidence) == ("mangools", 1.0)
    assert "Location" not in best.columns.values()


# --- recensement DuckDB -------------------------------------------------------------------


def test_count_by_column_counts_the_whole_file() -> None:
    assert count_by_column(MULTI_COUNTRY, "Location") == EXPECTED
    assert count_by_column(KWFINDER, "Location") == [("United States", 21)]


def test_count_by_column_survives_a_punctuated_header() -> None:
    """`CPC/USD` : un en-tête à slash ne peut pas être un paramètre lié, il est interpolé.

    Le test garde l'échappement : sans les guillemets doubles, DuckDB lirait `CPC` divisé par
    `USD` et l'aperçu tomberait en 422 sur un fichier parfaitement valide.
    """
    counted = count_by_column(KWFINDER, "CPC/USD")
    # Sept lignes de l'export n'ont pas de CPC : la valeur nulle est rendue telle quelle, et
    # c'est l'appelant qui décide de l'écarter (une cellule vide n'est pas un libellé).
    assert (None, 7) in counted
    assert sum(rows for _, rows in counted) == 21


def test_count_by_column_refuses_a_column_the_file_has_not() -> None:
    """Une colonne absente est une erreur DuckDB, que le module Imports traduit en 422."""
    with pytest.raises(duckdb.Error):
        count_by_column(KWFINDER, "Pays")


def test_the_fixture_hides_markets_beyond_the_preview_window() -> None:
    """Le témoin anti-vacuité : la fixture doit **cacher** des marchés loin dans le fichier.

    L'aperçu ne parse que `PREVIEW_ROWS` lignes et la création cinq. Si tous les pays de la
    fixture tenaient dans cette fenêtre, tous les tests de recensement passeraient aussi bien
    avec un scan tronqué — ils ne prouveraient plus rien. On exige donc, noir sur blanc, que
    plusieurs marchés n'apparaissent qu'au-delà de la centième ligne de données.
    """
    with MULTI_COUNTRY.open(newline="") as handle:
        rows = list(csv.reader(handle))
    location = rows[0].index("Location")
    first_seen: dict[str, int] = {}
    for rank, row in enumerate(rows[1:], start=1):
        first_seen.setdefault(row[location], rank)

    assert set(first_seen) == {market for market, _ in EXPECTED}
    assert len(rows) - 1 == sum(rows_count for _, rows_count in EXPECTED)
    hidden = {market for market, rank in first_seen.items() if rank > 100}
    assert hidden >= {"Singapore", "Hong Kong", "New Zealand", "Spain", "Israel", "Japan"}
    # Et la fenêtre de l'aperçu ne voit qu'un seul marché : sans le recensement, le wizard
    # n'aurait proposé que celui-là.
    assert {row[location] for row in rows[1 : 1 + PREVIEW_ROWS]} == {"United States"}


def test_the_census_stays_cheap_enough_to_be_replayed(capsys: pytest.CaptureFixture[str]) -> None:
    """Le wizard le rejoue à chaque changement de format : le coût est borné, et mesuré (Q11).

    Pire cas d'un `GROUP BY` : 50 000 lignes, 5,5 Mio, une valeur distincte par ligne (la
    colonne des mots-clés), donc une table de hachage maximale. Mesuré ~100 ms ici, et 0,45 s
    hors suite sur un fichier de 250 Mio — le plafond de l'upload — dont 0,29 s de validation
    d'encodage que l'aperçu paie déjà par `read_header`.

    Le plafond de l'assertion est large : il n'attrape pas une régression de quelques pour
    cent, il attrape le jour où quelqu'un remplacera l'agrégat par un parcours de lignes en
    Python — c'est-à-dire deux ordres de grandeur, pas dix pour cent.
    """
    started = time.perf_counter()
    counted = count_by_column(SEMRUSH_FULL, "Keyword")
    elapsed = time.perf_counter() - started
    assert sum(rows for _, rows in counted) == 50_000
    with capsys.disabled():
        print(
            f"\nrecensement de {SEMRUSH_FULL.name} ({SEMRUSH_FULL.stat().st_size / 1e6:.1f} Mo) "
            f"sur 50 000 lignes : {elapsed * 1000:.0f} ms"
        )
    assert elapsed < 5.0
