"""Table de cas de `normalize_url` (spec Imports §3.8) — **le contrat inter-modules**.

Ce fichier fait foi : le module Crawl adoptera `normalize_url` telle quelle pour
`pages.url_normalized`, et `keyword_page_map.url_normalized` est produit par la même
fonction. Toute modification d'une de ces attentes est un changement de contrat, pas un
détail d'implémentation — la jointure de résolution (§3.9) repose entièrement dessus.

Les cas vivent dans des constantes de module et non dans les décorateurs : `ALL_CASES` les
rassemble pour deux **propriétés** vérifiées sur la table entière plutôt que sur un
échantillon choisi à la main — l'idempotence (`normalize(normalize(x)) == normalize(x)`) et
l'équivalence (deux écritures d'une même page rendent la même forme canonique).

**Une propriété ne vaut que ce que vaut son domaine.** La leçon a coûté deux rounds de
review : la propriété d'idempotence existait déjà quand I1-bis est passé au travers, parce
qu'aucun cas de la table ne portait une clé de query à échapper (non-ASCII, espace,
`{}|^[]"<>`) **accompagnée d'une seconde clé** — sans deuxième clé, il n'y a pas d'ordre à
faire basculer. Ajouter un cas à la table, c'est étendre les deux propriétés : c'est là
qu'on paie sa dette, pas dans un test de plus.
"""

import pytest

from app.core.urls import UrlError, normalize_url

# --- table de cas de la spec §3.8 (les cinq lignes du tableau, à la lettre) -----------

SPEC_TABLE = [
    ("HTTPS://Exemple.FR/Page#x", "https://exemple.fr/Page"),
    ("http://exemple.fr:80/", "https://exemple.fr/"),
    ("exemple.fr/page/", "https://exemple.fr/page/"),
    ("https://éxemple.fr/a", "https://xn--xemple-9ua.fr/a"),
    ("https://exemple.fr/p?utm_source=x&b=2&a=1", "https://exemple.fr/p?a=1&b=2"),
]

# --- schéma : http ≡ https, https forcé ----------------------------------------------

SCHEME = [
    ("http://exemple.fr/a", "https://exemple.fr/a"),
    ("https://exemple.fr/a", "https://exemple.fr/a"),
    ("HTTP://exemple.fr/a", "https://exemple.fr/a"),
    # Pas de schéma du tout : https ajouté (saisie manuelle « exemple.fr/page »).
    ("exemple.fr/a", "https://exemple.fr/a"),
    ("//exemple.fr/a", "https://exemple.fr/a"),
    # Un « :// » dans la query ne fait pas un schéma : seul compte ce qui précède le
    # premier `/`, `?` ou `#` (review T1 M1 — ces URL de redirection sont courantes).
    ("exemple.fr/out?to=https://autre.fr/x", "https://exemple.fr/out?to=https://autre.fr/x"),
    ("exemple.fr?to=https://autre.fr", "https://exemple.fr/?to=https://autre.fr"),
]

# --- hôte : minuscules, punycode, ports par défaut ------------------------------------

HOST_AND_PORT = [
    ("https://EXEMPLE.FR/a", "https://exemple.fr/a"),
    ("https://Sous.Domaine.Exemple.FR/a", "https://sous.domaine.exemple.fr/a"),
    ("https://éxemple.fr/", "https://xn--xemple-9ua.fr/"),
    ("https://ÉXEMPLE.fr/", "https://xn--xemple-9ua.fr/"),
    # Déjà en punycode : idempotent.
    ("https://xn--xemple-9ua.fr/", "https://xn--xemple-9ua.fr/"),
    ("http://exemple.fr:80/a", "https://exemple.fr/a"),
    ("https://exemple.fr:443/a", "https://exemple.fr/a"),
    # Port par défaut de la *famille* http : http:443 et https:80 tombent aussi.
    ("http://exemple.fr:443/a", "https://exemple.fr/a"),
    # Port non standard : conservé (c'est une autre origine).
    ("https://exemple.fr:8080/a", "https://exemple.fr:8080/a"),
    # Hôte nu avec port, sans schéma : un port n'est pas un schéma.
    ("exemple.fr:8080/a", "https://exemple.fr:8080/a"),
]

# --- chemin : casse préservée, slash final préservé, vide → « / » ---------------------

PATH = [
    ("https://exemple.fr", "https://exemple.fr/"),
    ("https://exemple.fr/", "https://exemple.fr/"),
    ("https://exemple.fr?a=1", "https://exemple.fr/?a=1"),
    # Slash final strict : `/page` et `/page/` sont deux URL distinctes (coût assumé §3.8).
    ("https://exemple.fr/page", "https://exemple.fr/page"),
    ("https://exemple.fr/page/", "https://exemple.fr/page/"),
    # Casse du chemin significative (Nginx, S3, la plupart des CMS).
    ("https://exemple.fr/Page/Sous-Page", "https://exemple.fr/Page/Sous-Page"),
]

# --- fragment ------------------------------------------------------------------------

FRAGMENT = [
    ("https://exemple.fr/a#section", "https://exemple.fr/a"),
    ("https://exemple.fr/a#", "https://exemple.fr/a"),
    ("https://exemple.fr/a?b=1#section", "https://exemple.fr/a?b=1"),
    # Fragment « hashbang » : supprimé comme les autres, jamais interprété.
    ("https://exemple.fr/#!/route", "https://exemple.fr/"),
]

# --- query : tracking supprimé, tri stable par clé ------------------------------------

QUERY = [
    # Query vide (« ? » seul) : supprimée.
    ("https://exemple.fr/a?", "https://exemple.fr/a"),
    ("https://exemple.fr/a?b=2&a=1", "https://exemple.fr/a?a=1&b=2"),
    ("https://exemple.fr/a?a=1&b=2", "https://exemple.fr/a?a=1&b=2"),
    # Tracking : utm_*, gclid, fbclid, msclkid.
    ("https://exemple.fr/a?utm_source=x&utm_medium=y", "https://exemple.fr/a"),
    ("https://exemple.fr/a?gclid=x", "https://exemple.fr/a"),
    ("https://exemple.fr/a?fbclid=x", "https://exemple.fr/a"),
    ("https://exemple.fr/a?msclkid=x", "https://exemple.fr/a"),
    ("https://exemple.fr/a?UTM_SOURCE=x&b=1", "https://exemple.fr/a?b=1"),
    ("https://exemple.fr/a?utm_id=x&b=1&utm_term=y", "https://exemple.fr/a?b=1"),
    # `utm` seul n'est pas `utm_*` : conservé (jamais de préfixe approximatif).
    ("https://exemple.fr/a?utm=1", "https://exemple.fr/a?utm=1"),
    # Paramètre sans valeur : conservé tel quel (c'est parfois signifiant).
    ("https://exemple.fr/a?flag&b=1", "https://exemple.fr/a?b=1&flag"),
    ("https://exemple.fr/a?b=&a=1", "https://exemple.fr/a?a=1&b="),
    # Valeur pertinente pour le SEO (pagination, filtre) : jamais supprimée.
    ("https://exemple.fr/a?page=2", "https://exemple.fr/a?page=2"),
]

QUERY_DUPLICATE_KEYS = [
    # Clés dupliquées : tri **stable** par clé, ordre des valeurs préservé.
    ("https://exemple.fr/a?a=1&a=2", "https://exemple.fr/a?a=1&a=2"),
    ("https://exemple.fr/a?a=2&a=1", "https://exemple.fr/a?a=2&a=1"),
    ("https://exemple.fr/a?b=1&a=2&a=1", "https://exemple.fr/a?a=2&a=1&b=1"),
    ("https://exemple.fr/a?c=3&a=2&b=1&a=1", "https://exemple.fr/a?a=2&a=1&b=1&c=3"),
]

# --- espaces et percent-encoding ------------------------------------------------------

ENCODING = [
    # Espaces autour de l'entrée (copier-coller de tableur) : rognés.
    ("  https://exemple.fr/a  ", "https://exemple.fr/a"),
    # Espace littéral dans le chemin : encodé.
    ("https://exemple.fr/a b", "https://exemple.fr/a%20b"),
    ("https://exemple.fr/a?q=b c", "https://exemple.fr/a?q=b%20c"),
    # Percent-encoding existant : jamais ré-encodé (pas de « %25 »).
    ("https://exemple.fr/a%20b", "https://exemple.fr/a%20b"),
    # Hexadécimal des échappements en majuscules (RFC 3986 §6.2.2.1).
    ("https://exemple.fr/a%2fb", "https://exemple.fr/a%2Fb"),
    ("https://exemple.fr/caf%c3%a9", "https://exemple.fr/caf%C3%A9"),
    # Non-ASCII dans le chemin : encodé en UTF-8 (l'hôte, lui, passe en punycode).
    ("https://exemple.fr/café", "https://exemple.fr/caf%C3%A9"),
    # Idempotence sur la forme encodée.
    ("https://exemple.fr/caf%C3%A9", "https://exemple.fr/caf%C3%A9"),
]

# Clés de query percent-encodées : le tri se fait sur la clé **déjà normalisée en
# majuscules**, sinon `%2a` et `%2A` — la même clé — se trient différemment selon qu'on
# normalise une URL brute ou déjà normalisée (review T1 I1). Les clés encodées ne sont pas
# une curiosité : les URL de SERP exportées par Semrush en portent (`filter%5Bx%5D=…`).
ENCODED_QUERY_KEYS = [
    ("https://exemple.fr/a?%2F=1&%2a=2", "https://exemple.fr/a?%2A=2&%2F=1"),
    ("https://exemple.fr/a?%2F=1&%2A=2", "https://exemple.fr/a?%2A=2&%2F=1"),
    ("https://exemple.fr/a?%2A=2&%2F=1", "https://exemple.fr/a?%2A=2&%2F=1"),
    ("https://exemple.fr/a?filter%5bx%5d=1&b=2", "https://exemple.fr/a?b=2&filter%5Bx%5D=1"),
    # La valeur aussi est normalisée, sans changer l'ordre des clés.
    ("https://exemple.fr/a?b=%c3%a9&a=1", "https://exemple.fr/a?a=1&b=%C3%A9"),
]


# Clés de query que `quote` doit échapper : tout non-ASCII, l'espace, et `{}|^[]"<>`. Elles
# ferment la seconde moitié de I1 (review du fix round 1, « I1-bis ») : la paire doit être
# **entièrement** normalisée avant le tri, `quote` compris. Sinon `\u00e9` (qui trie après
# `z`) et `%C3%A9` (qui commence par `%`, donc trie avant) font basculer l'ordre entre deux
# passages — et, pire, deux écritures de la même page ne collapsent pas. Les URL à facettes
# accentuées (`?cat\u00e9gorie=…`, `?marque=…`) sont la norme sur un e-commerce FR.
ESCAPED_QUERY_KEYS = [
    ("https://exemple.fr/p?z=1&\u00e9=2", "https://exemple.fr/p?%C3%A9=2&z=1"),
    ("https://exemple.fr/p?z=1&%C3%A9=2", "https://exemple.fr/p?%C3%A9=2&z=1"),
    ("https://exemple.fr/p?a!x=1&a b=2", "https://exemple.fr/p?a!x=1&a%20b=2"),
    ("https://exemple.fr/p?b=1&a{x}=2", "https://exemple.fr/p?a%7Bx%7D=2&b=1"),
    ("https://exemple.fr/p?z=1&a[b]|c=2", "https://exemple.fr/p?a%5Bb%5D%7Cc=2&z=1"),
    ('https://exemple.fr/p?z=1&a"b^c=2', "https://exemple.fr/p?a%22b%5Ec=2&z=1"),
    ("https://exemple.fr/p?z=1&a<b>=2", "https://exemple.fr/p?a%3Cb%3E=2&z=1"),
]

ALL_CASES = (
    SPEC_TABLE
    + SCHEME
    + HOST_AND_PORT
    + PATH
    + FRAGMENT
    + QUERY
    + QUERY_DUPLICATE_KEYS
    + ENCODING
    + ENCODED_QUERY_KEYS
    + ESCAPED_QUERY_KEYS
)


@pytest.mark.parametrize(("raw", "expected"), SPEC_TABLE)
def test_spec_table(raw: str, expected: str) -> None:
    assert normalize_url(raw) == expected


@pytest.mark.parametrize(("raw", "expected"), SCHEME)
def test_scheme(raw: str, expected: str) -> None:
    assert normalize_url(raw) == expected


@pytest.mark.parametrize(("raw", "expected"), HOST_AND_PORT)
def test_host_and_port(raw: str, expected: str) -> None:
    assert normalize_url(raw) == expected


@pytest.mark.parametrize(("raw", "expected"), PATH)
def test_path(raw: str, expected: str) -> None:
    assert normalize_url(raw) == expected


def test_trailing_slash_pages_differ() -> None:
    """Explicite : le slash final n'est jamais rattrapé (impact assumé sur la résolution)."""
    assert normalize_url("https://exemple.fr/page") != normalize_url("https://exemple.fr/page/")


@pytest.mark.parametrize(("raw", "expected"), FRAGMENT)
def test_fragment(raw: str, expected: str) -> None:
    assert normalize_url(raw) == expected


@pytest.mark.parametrize(("raw", "expected"), QUERY)
def test_query(raw: str, expected: str) -> None:
    assert normalize_url(raw) == expected


@pytest.mark.parametrize(("raw", "expected"), QUERY_DUPLICATE_KEYS)
def test_query_duplicate_keys(raw: str, expected: str) -> None:
    assert normalize_url(raw) == expected


@pytest.mark.parametrize(("raw", "expected"), ENCODING)
def test_spaces_and_percent_encoding(raw: str, expected: str) -> None:
    assert normalize_url(raw) == expected


@pytest.mark.parametrize(("raw", "expected"), ENCODED_QUERY_KEYS)
def test_encoded_query_keys(raw: str, expected: str) -> None:
    assert normalize_url(raw) == expected


def test_encoded_query_key_case_does_not_change_the_order() -> None:
    """Repro de la review T1 I1 : `%2a` et `%2A` doivent produire exactement la même URL."""
    assert normalize_url("https://exemple.fr/a?%2F=1&%2a=2") == normalize_url(
        "https://exemple.fr/a?%2F=1&%2A=2"
    )


@pytest.mark.parametrize(("raw", "expected"), ESCAPED_QUERY_KEYS)
def test_escaped_query_keys(raw: str, expected: str) -> None:
    assert normalize_url(raw) == expected


@pytest.mark.parametrize(
    ("written_one_way", "written_the_other_way"),
    [
        # Clé non-ASCII : brute ou déjà encodée.
        ("https://exemple.fr/p?z=1&\u00e9=2", "https://exemple.fr/p?z=1&%C3%A9=2"),
        # Valeur non-ASCII.
        ("https://exemple.fr/p?q=caf\u00e9", "https://exemple.fr/p?q=caf%C3%A9"),
        # Chemin non-ASCII.
        ("https://exemple.fr/caf\u00e9", "https://exemple.fr/caf%C3%A9"),
        # Espace dans une clé.
        ("https://exemple.fr/p?a b=2&z=1", "https://exemple.fr/p?a%20b=2&z=1"),
        # Casse de l'hexadécimal.
        ("https://exemple.fr/p?%2a=1&z=2", "https://exemple.fr/p?%2A=1&z=2"),
    ],
)
def test_two_writings_of_the_same_url_collapse(
    written_one_way: str, written_the_other_way: str
) -> None:
    """Propriété d'**équivalence** : une page a une seule forme canonique.

    Plus forte que l'idempotence, et c'est celle qui compte pour §3.9 : l'import écrit
    l'URL telle que la source la donne, le crawl telle que le href la porte. Si les deux
    écritures ne collapsent pas, `keyword_page_map.url_normalized` et `pages.url_normalized`
    ne se joignent jamais — sans erreur, sans trace.
    """
    assert normalize_url(written_one_way) == normalize_url(written_the_other_way)


# --- propriété : normalize_url(normalize_url(x)) == normalize_url(x) -------------------


@pytest.mark.parametrize("raw", [raw for raw, _ in ALL_CASES])
def test_idempotent_on_every_case_of_the_table(raw: str) -> None:
    """Propriété vérifiée sur **toute** la table, pas sur un échantillon.

    Le module Crawl re-normalisera des URL déjà normalisées (hrefs découverts) : une
    fonction non idempotente produirait deux formes de la même page, et l'attache ne se
    résoudrait jamais — silencieusement.
    """
    once = normalize_url(raw)
    assert normalize_url(once) == once


@pytest.mark.parametrize("expected", [expected for _, expected in ALL_CASES])
def test_expected_outputs_are_fixed_points(expected: str) -> None:
    """L'autre face de la même propriété : une sortie de la table est déjà normalisée."""
    assert normalize_url(expected) == expected


@pytest.mark.parametrize(
    "raw",
    [
        "https://exemple.fr/a?b=2&a=1&a=3",
        "https://exemple.fr:8080/a b/",
        "https://éxemple.fr/café?q=x y",
        "exemple.fr",
    ],
)
def test_idempotent(raw: str) -> None:
    once = normalize_url(raw)
    assert normalize_url(once) == once


# --- entrées invalides → UrlError (ligne en erreur à l'import, 422 au geste manuel) ---


@pytest.mark.parametrize(
    "raw",
    [
        "",
        "   ",
        "ftp://exemple.fr/a",
        "mailto:contact@exemple.fr",
        "javascript:alert(1)",
        "tel:+33123456789",
        "data:text/plain,x",
        # Pas d'hôte.
        "https:///chemin",
        "/chemin/relatif",
        "https://",
        # Hôte sans TLD, IP, localhost : même politique que `normalize_domain`.
        "https://exemple/a",
        "https://localhost/a",
        "https://127.0.0.1/a",
        # Identifiants dans l'URL.
        "https://user:pw@exemple.fr/a",
        # Port illisible et crochets déséquilibrés (urlsplit lève un ValueError brut).
        "https://exemple.fr:port/a",
        "https://[::1/a",
    ],
)
def test_invalid(raw: str) -> None:
    with pytest.raises(UrlError):
        normalize_url(raw)
