"""`normalize_url` — forme canonique d'une URL de page (spec Imports §3.8).

**Contrat inter-modules figé** : `keyword_page_map.url_normalized` (Imports) et
`pages.url_normalized` (module Crawl) sont produits par cette seule fonction — la
résolution de l'attache (§3.9) est une jointure ensembliste sur cette chaîne, elle ne
tolère aucune variante. Le module Crawl l'adopte telle quelle, jamais par copie.

`tests/test_normalize_url.py` porte la table de cas exhaustive : elle fait foi.

Politiques (spec §3.8) :

- **schéma forcé `https`** — `http://x` et `https://x` sont la même page ; entrée sans
  schéma (« exemple.fr/page », saisie manuelle) préfixée en `https://` ;
- **hôte** en minuscules et punycode, via `normalize_domain` — même politique de domaine
  que le module Sites (pas d'IP, pas de `localhost`, TLD exigé, identifiants refusés) ;
- **ports par défaut de la famille http** (80, 443) supprimés, tout autre port conservé
  (c'est une autre origine) ;
- **fragment supprimé** (jamais une page distincte côté serveur) ;
- **chemin** : casse préservée, vide → `/`, **slash final préservé** (`/page` ≠ `/page/`,
  coût assumé §3.8 : les deux ne se rattachent pas entre eux) ;
- **query** : paramètres de tracking supprimés (`utm_*`, `gclid`, `fbclid`, `msclkid`),
  reste **trié par clé** avec un tri stable (l'ordre des valeurs d'une clé dupliquée est
  préservé : `a=1&a=2` ≠ `a=2&a=1`), query vide supprimée ;
- **percent-encoding** : les espaces et le non-ASCII sont encodés, les échappements déjà
  présents ne sont jamais ré-encodés, leur hexadécimal est mis en majuscules (RFC 3986
  §6.2.2.1). Les segments `.`/`..` ne sont **pas** résolus (aucune source n'en produit et
  les résoudre changerait la page pour un serveur qui les traite littéralement).

**Limite assumée du contrat** : les clés de query ne sont jamais décodées, donc une clé de
tracking percent-encodée (`%75tm_source=x` pour `utm_source=x`) n'est pas reconnue comme
telle et survit à la normalisation. C'est la contrepartie directe de la politique « jamais
décodé/ré-encodé » — décoder pour comparer rouvrirait la question de la ré-encodage et
ferait diverger deux URL équivalentes. Aucune source connue n'encode ses clés de tracking.

Entrée invalide → `UrlError` : ligne en erreur à l'import, 422 au geste manuel.
"""

import re
from urllib.parse import quote, urlsplit

from app.sites.domain import DomainError, normalize_domain

# Ports par défaut de la famille http : `http://x:443` comme `https://x:80` désignent la
# même page une fois le schéma forcé — les deux tombent.
DEFAULT_PORTS = frozenset({80, 443})

TRACKING_PREFIXES = ("utm_",)
TRACKING_KEYS = frozenset({"gclid", "fbclid", "msclkid"})

# `%` est « sûr » : un échappement déjà écrit par la source n'est jamais ré-encodé en `%25`.
_PATH_SAFE = "%/!$&'()*+,;=:@~-._"
_QUERY_SAFE = "%/!$&'()*+,;=:@?~-._"

_PERCENT = re.compile(r"%([0-9a-fA-F]{2})")
# Un schéma ne compte que s'il est en tête (RFC 3986 §3.1) ; `_AUTHORITY_END` borne
# l'examen à ce qui précède le chemin, la query et le fragment.
_SCHEME = re.compile(r"^([A-Za-z][A-Za-z0-9+.\-]*)://")
_AUTHORITY_END = re.compile(r"[/?#]")


class UrlError(ValueError):
    """URL inexploitable — mappée en 422 Problem Details, ou en ligne d'erreur à l'import."""


def _percent_upper(value: str) -> str:
    """Hexadécimal des échappements en majuscules : `%2f` et `%2F` sont la même page."""
    return _PERCENT.sub(lambda m: f"%{m.group(1).upper()}", value)


def _is_tracking(key: str) -> bool:
    lowered = key.lower()
    return lowered in TRACKING_KEYS or lowered.startswith(TRACKING_PREFIXES)


def _clean_query(raw_query: str) -> str:
    """Tracking retiré, reste trié par clé (tri stable : valeurs d'une clé dans l'ordre).

    Les paires ne sont jamais décodées puis ré-encodées : la valeur est transportée telle
    quelle (un `+` reste un `+`), seul l'ordre change.

    **Chaque paire est entièrement normalisée avant le tri** — échappement `quote` compris,
    et pas seulement la casse de l'hexadécimal. C'est l'invariant, et il se viole des deux
    côtés : `%2a` et `%2A` sont la même clé sans être la même chaîne, et `é` (U+00E9) trie
    après `z` là où sa forme encodée `%C3%A9` commence par `%` et trie avant. Normaliser
    après le tri — pour l'un ou pour l'autre — donnerait un ordre différent au premier et
    au second passage, et surtout **deux formes canoniques pour une même page** selon
    l'écriture reçue. Le module Crawl re-normalise des URL déjà normalisées (hrefs
    découverts) : la jointure de résolution (§3.9) ne se ferait jamais, sans la moindre
    erreur visible. Les clés à échapper ne sont pas un cas d'école — une facette accentuée
    (`?catégorie=…`) est la norme sur un site e-commerce FR.
    """
    pairs: list[tuple[str, str]] = []
    for raw_part in raw_query.split("&"):
        if not raw_part:
            continue
        part = _percent_upper(quote(raw_part, safe=_QUERY_SAFE))
        key = part.partition("=")[0]
        if _is_tracking(key):
            continue
        pairs.append((key, part))
    # `sorted` est stable : deux paires de même clé gardent leur ordre d'origine.
    return "&".join(part for _, part in sorted(pairs, key=lambda kv: kv[0]))


def normalize_url(raw: str) -> str:
    value = raw.strip()
    if not value:
        raise UrlError("URL vide")
    # Protocol-relative (« //exemple.fr/a ») : le schéma manquant est https comme ailleurs.
    if value.startswith("//"):
        value = value[2:]
    leading_scheme = _SCHEME.match(value)
    if leading_scheme:
        scheme = leading_scheme.group(1).lower()
        if scheme not in ("http", "https"):
            raise UrlError(f"Schéma « {scheme} » refusé (http ou https uniquement)")
    else:
        # Pas de schéma en tête : soit un hôte nu (« exemple.fr/a », « exemple.fr:8080/a »),
        # soit un schéma opaque (« mailto: », « tel: », « javascript: ») qu'il faut refuser.
        # L'examen s'arrête au premier `/`, `?` ou `#` : un « :// » dans la query (URL de
        # redirection, « exemple.fr/out?to=https://… ») n'est pas un schéma. Le deux-points
        # suivi d'un nombre est un port, tout le reste est un schéma.
        head = _AUTHORITY_END.split(value, maxsplit=1)[0]
        maybe_scheme, colon, rest = head.partition(":")
        if colon and not rest.isdigit():
            raise UrlError(f"Schéma « {maybe_scheme} » refusé (http ou https uniquement)")
        value = f"https://{value}"

    # urlsplit lève un ValueError brut sur les crochets déséquilibrés (« https://[::1/a ») :
    # sans ce garde-fou, l'appelant verrait un 500 au lieu d'un 422.
    try:
        parts = urlsplit(value)
    except ValueError as exc:
        raise UrlError("Entrée illisible comme URL") from exc
    if "@" in parts.netloc:
        raise UrlError("Identifiants dans l'URL refusés")
    try:
        port = parts.port
    except ValueError as exc:
        raise UrlError("Port illisible") from exc
    if not parts.hostname:
        raise UrlError("Aucun hôte dans l'URL")
    # Politique de domaine unique avec le module Sites : minuscules, punycode, TLD exigé.
    try:
        host = normalize_domain(parts.hostname)
    except DomainError as exc:
        raise UrlError(str(exc)) from exc

    authority = host if port is None or port in DEFAULT_PORTS else f"{host}:{port}"
    path = _percent_upper(quote(parts.path or "/", safe=_PATH_SAFE))
    # `_clean_query` rend une query déjà entièrement normalisée : la ré-échapper ici serait
    # au mieux un no-op, au pire la porte par laquelle l'ordre du tri se remettrait à
    # dépendre de l'écriture reçue. Une paire se normalise à un seul endroit.
    query = _clean_query(parts.query)
    return f"https://{authority}{path}?{query}" if query else f"https://{authority}{path}"
