"""
Logique pure de l'énumération des adresses IPv4 locales : filtrage et analyse.

Séparée des appels système pour être testable sans réseau ni sous-processus,
comme `_build_ruleset` dans `firewall_backend_linux.py`.

**Pourquoi ce module existe.** Le client ne découvrait qu'UNE adresse locale,
celle de la route par défaut (`_detect_local_ip`, socket UDP connecté vers
l'extérieur). Sur une machine bi-domiciliée, cette adresse n'est pas forcément
celle qui compte : le 2026-08-05, un poste branché **au LAN et au partage de
connexion cellulaire en même temps** annonçait son adresse cellulaire
(`10.64.136.31`) et son endpoint public CGNAT, alors qu'il était joignable en
**0,42 ms** sur `192.168.0.14`. Ses pairs, à quelques mètres, transitaient par un
relais à Beauharnois — 74,8 ms, et l'hôte du relais payant le trafic deux fois
(point 90).

Ce n'est pas un montage de laboratoire : un portable qui garde son partage de
connexion actif en rentrant chez lui produit exactement ça.
"""

from __future__ import annotations

import ipaddress


def est_privee_utilisable(adresse: str) -> bool:
    """
    Vrai si cette IPv4 est une adresse privée qu'on peut annoncer à un pair.

    **On n'annonce QUE du privé, et jamais une adresse publique.** Une machine
    directement adressable annoncerait sinon son adresse publique en double, et
    surtout l'écho de vérification se lierait dessus (voir `tunnel_probe`) — donc
    exposerait sur Internet un service qui n'a de sens que sur un lien local.

    Sont écartées, dans cet ordre :
    - la boucle locale : tous les pairs en ont une, `127.0.0.1` serait « partagé »
      par tout le monde et ferait croire à une adjacence universelle ;
    - le lien-local `169.254.0.0/16` : c'est l'aveu d'un DHCP en échec, l'adresse
      ne route vers rien ;
    - tout ce qui n'est pas une IPv4 valide, plutôt que de laisser une chaîne
      malformée traverser jusqu'au rendu de la configuration WireGuard.
    """
    try:
        ip = ipaddress.IPv4Address(adresse)
    except (ipaddress.AddressValueError, ValueError):
        return False
    if ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_unspecified:
        return False
    return ip.is_private


def filtrer_et_ordonner(adresses: list[str]) -> list[str]:
    """
    Garde les adresses privées utilisables, sans doublon, dans l'ordre reçu.

    **L'ordre reçu est préservé volontairement.** Les backends renvoient les
    adresses dans l'ordre du système, qui place en général l'interface physique
    avant les interfaces virtuelles (Hyper-V, WSL, Docker). Trier alphabétiquement
    mettrait `10.x` (WSL, cellulaire) devant `192.168.x` (LAN domestique), ce qui
    est exactement le mauvais ordre pour le cas qu'on cherche à corriger.
    Rien ne dépend de cet ordre pour la CORRECTION — la vérification par sonde
    tranche — mais il décide de l'ordre des essais, donc du temps de convergence.
    """
    vues: set[str] = set()
    retenues: list[str] = []
    for adresse in adresses:
        adresse = adresse.strip()
        if adresse in vues or not est_privee_utilisable(adresse):
            continue
        vues.add(adresse)
        retenues.append(adresse)
    return retenues


def parser_ip_addr_linux(sortie: str) -> list[str]:
    """
    Extrait les IPv4 de la sortie de `ip -4 -o addr show`.

    Format d'une ligne (stable, non traduit — `ip` n'est pas localisé) :
        2: enp5s0    inet 192.168.0.142/24 brd ... scope global enp5s0

    On s'ancre sur le jeton `inet` suivi d'un `adresse/préfixe`, et on ignore
    tout le reste de la ligne. Une ligne qu'on ne comprend pas est sautée plutôt
    que de faire échouer l'énumération entière : perdre une interface dégrade la
    découverte LAN, planter ici empêcherait le client de se connecter.
    """
    adresses: list[str] = []
    for ligne in sortie.splitlines():
        jetons = ligne.split()
        for i, jeton in enumerate(jetons):
            if jeton != "inet" or i + 1 >= len(jetons):
                continue
            adresses.append(jetons[i + 1].split("/")[0])
            break
    return adresses


def parser_get_netipaddress_json(charge: object) -> list[str]:
    """
    Extrait les IPv4 du JSON de `Get-NetIPAddress`.

    **On lit le format que PowerShell produit pour être relu, jamais son
    affichage** : les libellés de colonnes sont traduits sur un Windows
    francophone, les noms de propriétés ne le sont pas. Quatrième application de
    la règle après `sc query` (point 32), la sortie de `ping` (point 34) et
    `schtasks` (point 45).

    `ConvertTo-Json` ne produit un tableau qu'à partir de DEUX éléments : avec une
    seule interface la charge est un objet. Ne pas traiter ce cas ferait rater les
    machines à une seule carte — exactement le piège attrapé au point 72.
    """
    if isinstance(charge, dict):
        charge = [charge]
    if not isinstance(charge, list):
        return []
    adresses: list[str] = []
    for entree in charge:
        if isinstance(entree, dict) and isinstance(entree.get("IPAddress"), str):
            adresses.append(entree["IPAddress"])
    return adresses


def endpoints_annonces(adresses: list[str], port: int) -> list[str]:
    """Adresses locales au format `ip:port`, tel qu'annoncé dans `local_endpoints`."""
    return [f"{adresse}:{port}" for adresse in adresses]


def dans_le_reseau(adresse: str, cidr: str) -> bool:
    """
    Vrai si cette adresse appartient au réseau donné. Faux si l'un des deux est
    illisible — on ne retire jamais une adresse sur la foi d'une valeur qu'on ne
    comprend pas.
    """
    try:
        return ipaddress.IPv4Address(adresse) in ipaddress.ip_network(cidr, strict=False)
    except ValueError:
        return False
