"""
Prouver qu'une adresse LAN annoncée par un pair est BIEN la sienne, avant de
router le tunnel dessus.

**Le problème, et pourquoi l'inférence ne suffit pas.** Un pair annonce désormais
toutes ses adresses locales (`local_addresses`). Reste à choisir. La tentation est
de préférer celle qui partage notre `/24` — et c'est faux : `192.168.0.0/24` est la
plage par défaut de la quasi-totalité des box grand public, donc deux machines à
900 km s'y trouvent toutes les deux. C'est exactement le bug du point 43, où chaque
client visait l'IP privée de l'autre SUR SON PROPRE RÉSEAU : le tunnel se montait
normalement, ne portait aucun paquet, et **rien ne le signalait**.

Le garde-fou qui a corrigé ce bug — exiger la même adresse publique — ne peut pas
servir ici : un pair bi-domicilié sort par sa route par défaut, donc son adresse
publique est celle du cellulaire alors qu'il est sur notre LAN (point 90). C'est
précisément le cas qu'on veut traiter.

**D'où une preuve au lieu d'une déduction.** On interroge l'adresse candidate et on
demande « qui es-tu ? » ; la machine qui répond décline son IP virtuelle
(`tunnel_probe.sonder_identite`). On ne préfère cette adresse que si elle déclare
l'IP virtuelle du pair attendu. Les trois issues possibles sont toutes sûres :

- le pair répond avec la bonne IP virtuelle -> adjacence PROUVÉE, on prend le LAN ;
- personne ne répond, ou une autre machine répond avec une autre IP virtuelle
  -> on ne change rien, le pair garde son chemin actuel ;
- le pair tourne une version antérieure -> il ignore le datagramme (format de
  taille différente, voir `tunnel_probe.TAILLE_ID`) -> même issue que ci-dessus.

Aucune de ces issues ne dégrade quoi que ce soit par rapport à l'existant : la
vérification ne peut qu'AJOUTER un chemin, jamais en retirer un.
"""

from __future__ import annotations

import ipaddress
import logging
import threading
from typing import Callable, Iterable, Optional

import tunnel_probe

logger = logging.getLogger("meshamoto-client.lan_verification")

# Au-delà, on cesse de sonder pour ce pair. Une machine avec Hyper-V, WSL et
# Docker annonce facilement cinq adresses ; les essayer toutes coûterait le
# timeout de chacune, et au-delà de trois candidats dans NOTRE sous-réseau on est
# de toute façon dans un cas qui mérite un diagnostic humain, pas plus de sondes.
MAX_CANDIDATS_PAR_PAIR = 3


def _ip_de(endpoint: str) -> str:
    return endpoint.rsplit(":", 1)[0]


def _dans(adresse: str, cidr: str | None) -> bool:
    """Vrai si l'adresse est dans ce réseau. Faux si l'un des deux est illisible."""
    if not cidr:
        return False
    try:
        return ipaddress.IPv4Address(adresse) in ipaddress.ip_network(cidr, strict=False)
    except ValueError:
        return False


def meme_sous_reseau(ip_a: str, ip_b: str, prefix_len: int = 24) -> bool:
    """Vrai si les deux IPv4 partagent le même préfixe. Faux si l'une est illisible."""
    try:
        reseau = ipaddress.ip_network(f"{ip_a}/{prefix_len}", strict=False)
        return ipaddress.ip_address(ip_b) in reseau
    except ValueError:
        return False


def candidats_lan(
    peer: dict, nos_adresses: Iterable[str], cidr_virtuel: str | None = None
) -> list[str]:
    """
    Adresses annoncées par ce pair qui valent la peine d'être sondées.

    **Le filtre par sous-réseau est une OPTIMISATION, pas une sécurité.** Il évite
    d'envoyer des datagrammes vers des adresses qui ne peuvent pas être sur notre
    lien ; c'est la vérification d'identité qui décide. Le relâcher coûterait des
    sondes inutiles, pas de la correction — ne pas le durcir en croyant renforcer
    quelque chose.

    Les endpoints sont dédupliqués par ADRESSE : un pair peut annoncer la même IP
    sur deux ports (tunnel déplacé, point 32), la sonder deux fois n'apprendrait
    rien de plus.
    """
    nos_adresses = list(nos_adresses)
    if not nos_adresses:
        return []
    # Défense en profondeur : une adresse de la plage VIRTUELLE n'est jamais un
    # chemin local. Elle est déjà écartée à l'annonce (voir
    # `local_addresses.adresses_locales_avec_repli`), mais un pair d'une version
    # intermédiaire pourrait l'annoncer — et la sonde d'identité RÉUSSIRAIT, l'écho
    # répondant sur l'IP virtuelle. On router alors le tunnel sur lui-même.
    nos_adresses = [a for a in nos_adresses if not _dans(a, cidr_virtuel)]
    annonces = peer.get("local_endpoints") or []
    if not annonces and peer.get("local_endpoint"):
        # Pair d'une version antérieure : il n'annonce qu'une adresse, mais elle
        # mérite d'être vérifiée comme les autres.
        annonces = [peer["local_endpoint"]]
    retenus: list[str] = []
    vues: set[str] = set()
    for endpoint in annonces:
        if not isinstance(endpoint, str) or ":" not in endpoint:
            continue
        ip = _ip_de(endpoint)
        if ip in vues:
            continue
        if _dans(ip, cidr_virtuel):
            continue
        if not any(meme_sous_reseau(nous, ip) for nous in nos_adresses):
            continue
        vues.add(ip)
        retenus.append(endpoint)
        if len(retenus) >= MAX_CANDIDATS_PAR_PAIR:
            break
    return retenus


class VerificateurLan:
    """
    Vérifie en arrière-plan, et ne répond que par des faits déjà établis.

    **Rien de bloquant dans le chemin du tunnel.** `_pick_endpoint` est appelé sous
    `_apply_lock`, dans la séquence qui monte l'interface : y placer des sondes
    réseau ferait attendre le tunnel derrière des timeouts. Les leçons des points 72
    (constat des blocages de pare-feu sur un fil dédié) et 80 (`netsh` hors du chemin
    du tunnel) valent telles quelles. La consultation, elle, est un accès mémoire.
    """

    def __init__(
        self,
        sonde: Callable[[str], Optional[str]] | None = None,
        on_verification: Callable[[], None] | None = None,
    ) -> None:
        # Injectables pour les tests : sans ça, la suite partirait réellement sur
        # le réseau — famille de défauts des points 47, 59 et 71.
        self._sonde = sonde or (lambda adresse: tunnel_probe.sonder_identite(adresse))
        self._on_verification = on_verification
        self._verifiees: dict[str, str] = {}
        self._echouees: dict[str, set[str]] = {}
        self._verrou = threading.Lock()
        self._travail: list[tuple[str, list[str]]] = []
        self._reveil = threading.Event()
        self._arret = threading.Event()
        self._thread: threading.Thread | None = None

    # --- consultation, appelée depuis le chemin du tunnel ---------------------

    def adresse_verifiee(self, virtual_ip: str) -> Optional[str]:
        """Endpoint LAN prouvé pour ce pair, ou None. Jamais bloquant."""
        with self._verrou:
            return self._verifiees.get(virtual_ip)

    def oublier(self, virtual_ip: str) -> None:
        """
        Efface ce qu'on croyait savoir de ce pair.

        Appelé quand ses adresses annoncées changent : une preuve porte sur un
        CHEMIN, pas sur un pair. La garder après un déménagement reproduirait le
        défaut du point 87, où une escalade vers le relais survivait au changement
        d'endpoint du pair visé.
        """
        with self._verrou:
            self._verifiees.pop(virtual_ip, None)
            self._echouees.pop(virtual_ip, None)

    # --- soumission et boucle ------------------------------------------------

    def soumettre(
        self, peers: list[dict], nos_adresses: Iterable[str],
        cidr_virtuel: str | None = None,
    ) -> list[tuple[str, list[str]]]:
        """
        Programme la vérification des pairs qui ont un candidat LAN non tranché, et
        rend ce qui vient d'être programmé.

        Ne redemande jamais une sonde déjà tentée sans succès pour la même adresse :
        un pair distant qui partage notre `/24` par coïncidence serait sinon sondé à
        chaque diffusion, indéfiniment.

        **La valeur de retour existe pour être testable.** L'inspecter via la file
        interne ne marche pas : le fil de travail la vide aussitôt, donc une
        assertion « rien n'a été programmé » passe aussi bien quand tout a été
        programmé et déjà consommé. Un test écrit ainsi réussit sans rien vérifier —
        celui qui l'a révélé était son jumeau de sens inverse, qui lui échouait.
        """
        nos_adresses = list(nos_adresses)
        a_faire: list[tuple[str, list[str]]] = []
        with self._verrou:
            for peer in peers:
                virtual_ip = peer.get("virtual_ip")
                if not virtual_ip or virtual_ip in self._verifiees:
                    continue
                deja = self._echouees.setdefault(virtual_ip, set())
                candidats = [
                    c for c in candidats_lan(peer, nos_adresses, cidr_virtuel)
                    if c not in deja
                ]
                if candidats:
                    a_faire.append((virtual_ip, candidats))
        if not a_faire:
            return []
        with self._verrou:
            self._travail.extend(a_faire)
        self._demarrer_si_besoin()
        self._reveil.set()
        return a_faire

    def _demarrer_si_besoin(self) -> None:
        if self._thread is not None and self._thread.is_alive():
            return
        self._arret.clear()
        self._thread = threading.Thread(
            target=self._boucle, name="meshamoto-lan-verif", daemon=True
        )
        self._thread.start()

    def arreter(self) -> None:
        """Arrête la boucle. Rejouable sans effet."""
        self._arret.set()
        self._reveil.set()
        thread, self._thread = self._thread, None
        if thread is not None:
            thread.join(timeout=3.0)

    def _boucle(self) -> None:
        while not self._arret.is_set():
            with self._verrou:
                travail, self._travail = self._travail, []
            if not travail:
                self._reveil.wait(timeout=1.0)
                self._reveil.clear()
                continue
            if any(self._traiter(virtual_ip, candidats) for virtual_ip, candidats in travail):
                if self._on_verification is not None:
                    self._on_verification()

    def _traiter(self, virtual_ip: str, candidats: list[str]) -> bool:
        """Sonde les candidats de ce pair. Vrai si l'un d'eux a été prouvé."""
        for endpoint in candidats:
            if self._arret.is_set():
                return False
            adresse = _ip_de(endpoint)
            declaree = self._sonde(adresse)
            if declaree == virtual_ip:
                with self._verrou:
                    self._verifiees[virtual_ip] = endpoint
                logger.info(
                    "Chemin LAN direct PROUVÉ vers %s : la machine en %s déclare bien "
                    "cette IP virtuelle — le tunnel passera par le réseau local au lieu "
                    "de l'endpoint public ou du relais",
                    virtual_ip, adresse,
                )
                return True
            with self._verrou:
                self._echouees.setdefault(virtual_ip, set()).add(endpoint)
            if declaree is not None:
                # Le cas qui justifie tout le module : quelqu'un répond, mais ce
                # n'est pas le bon. Sans vérification d'identité, on aurait routé
                # le tunnel vers cette machine — c'est le bug du point 43.
                logger.info(
                    "Adresse LAN %s écartée pour %s : une autre machine y répond "
                    "(elle déclare %s). Même sous-réseau, pair différent.",
                    adresse, virtual_ip, declaree,
                )
        return False
