"""
Écho applicatif dans le tunnel : savoir si un pair est VIVANT sans dépendre de l'ICMP.

**Pourquoi ce module existe, et ce n'est pas du confort.** La vivacité d'un pair
ne reposait que sur le ping ICMP. Or c'est précisément ce qu'un pare-feu tiers
jette par défaut, et le 2026-08-02 on a mesuré pire que « bloqué par défaut »
(point 67bis) : sur une machine Windows sous Bitdefender, **sept manipulations
administrateur** n'ont pas suffi à rétablir l'ICMP — mode furtif déjà désactivé,
profil réseau corrigé, règle Windows ALLOW explicite sans aucune règle BLOCK
concurrente, exception d'analyse de ports, protection d'analyse de ports coupée
en entier, et une règle Bitdefender ICMP « toutes applications » en Autoriser qui
existait déjà et que le pare-feu ignore. Seule la coupure complète du pare-feu
rétablissait le ping.

Conséquences mesurées sur ce poste : le salon affichait « Injoignable » pendant
que 1,9 Mo transitaient réellement, les trois pairs demandaient chacun un relais
de secours pour une machine parfaitement joignable, et **168,2 Mo ont transité
par le relais aveugle** (224 207 paquets) alors que le chemin direct était viable
tout du long. Le produit se dégradait tout seul, sur une preuve fausse.

**Pourquoi l'UDP passe là où l'ICMP est jeté.** Mesuré le 2026-08-02 à 18 s
d'intervalle sur la même machine, la même carte, le même instant :

    20:44:02  UDP entrant non sollicité vers l'IP virtuelle -> écho reçu
    20:44:20  ICMP vers la même IP virtuelle               -> 100 % de perte

Le test était volontairement PLUS DUR que la réalité : l'écoute était portée par
`powershell.exe`, qui n'a aucune règle dans l'antivirus, alors qu'ici elle sera
portée par le processus Meshamoto, qui en a une. Un résultat positif dans ces
conditions est donc conclusif.

**L'écho ne remplace pas le ping, il le complète.** Le ping reste la mesure de
LATENCE (c'est lui qui alimente `connection_quality`) ; l'écho répond à la seule
question « ce pair est-il vivant ? », qui décide de l'affichage du salon et du
droit de demander un relais. Un pair qui ne répond pas à l'écho n'est jamais
déclaré injoignable pour autant : il tourne peut-être une version antérieure, et
c'est l'ICMP qui tranche alors, exactement comme avant. Conclure sur le silence
d'un protocole que l'autre côté ne parle pas reproduirait le bug qu'on corrige,
en pire — il frapperait cette fois TOUS les pairs plus anciens.

Logique de protocole pure ici (construction et lecture des datagrammes),
testable sans réseau ; les sockets sont dans `_Serveur` et `sonder`.
"""

from __future__ import annotations

import errno
import ipaddress
import logging
import os
import secrets
import select
import socket
import subprocess
import threading
import time

import config

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

# Port d'écoute de l'écho, sur l'IP VIRTUELLE uniquement.
#
# Choisi loin de la plage WireGuard configurable (51820 par défaut, 51821 en
# cohabitation — point 32) pour qu'un utilisateur qui déplace son port de tunnel
# ne tombe jamais dessus, et loin de 27015-27030 qu'occupent les jeux à moteur
# Source. Surchargeable pour les mêmes raisons que le port WireGuard.
#
# **SOUS 49152, et ce n'est pas cosmétique.** Le choix d'origine (50820) tombait
# dans une plage RÉSERVÉE PAR WINDOWS, mesuré sur la machine Hyper-V du parc le
# 2026-08-03 : `netsh int ipv4 show excludedportrange protocol=udp` y montrait
# `50814-50913`, et le bind échouait en `AccessDenied` (WSAEACCES) alors que
# **personne ne détenait le port**. L'écho n'a donc jamais fonctionné sur cette
# machine, sans que rien ne le dise.
#
# Ces réservations sont allouées au DÉMARRAGE, par blocs de 100, et **changent
# d'un boot à l'autre** : un autre port fixe dans la même zone ne serait pas un
# correctif, juste une probabilité différente. Toutes se situent au-dessus du
# début de la plage dynamique (49152 par défaut, `netsh int ipv4 show dynamicport
# udp`) — un port en dessous y échappe donc par construction. C'est la seule
# propriété qui rend ce choix sûr, pas le chiffre lui-même.
#
# Reste hors de portée : une exclusion ADMINISTRÉE, qu'un produit tiers peut
# poser n'importe où. C'est ce que traitera l'annonce du port par le pair.
PROBE_PORT = int(os.environ.get("MESHAMOTO_PROBE_PORT", "40820"))

# Préfixes de protocole. Le chiffre est un numéro de version : si le format doit
# changer un jour, un pair d'une autre version ne répondra tout simplement pas et
# le repli ICMP s'appliquera, ce qui est le comportement voulu.
_REQUETE = b"MSHMTO1?"
_REPONSE = b"MSHMTO1!"

# Second échange, porté par les adresses LAN et non par le tunnel : « qui es-tu ? ».
# Sert à PROUVER qu'une adresse locale annoncée par un pair est bien la sienne,
# avant de router le tunnel dessus (voir lan_verification).
_REQUETE_ID = b"MSHMTO1i"
_REPONSE_ID = b"MSHMTO1I"

# Taille du témoin aléatoire renvoyé tel quel par le pair.
_TAILLE_NONCE = 8

# Un datagramme plus grand n'est jamais une requête valide. Borne l'appétit du
# serveur : il ne lit jamais plus que ce que le protocole peut contenir.
TAILLE_MAX = len(_REQUETE) + _TAILLE_NONCE

# L'échange d'identité porte en plus une IPv4 (4 octets) dans sa RÉPONSE.
#
# **La requête est rembourrée à la même taille que la réponse, et c'est une
# décision de sécurité.** Une réponse plus grosse que la question ferait de cet
# écho un amplificateur de réflexion — le défaut que Valve a dû corriger sur A2S
# en 2020 (point 47), et la raison pour laquelle `construire_reponse` renvoie déjà
# exactement la taille reçue. Le rembourrage coûte quatre octets et supprime la
# question.
_TAILLE_IPV4 = 4
TAILLE_ID = len(_REQUETE_ID) + _TAILLE_NONCE + _TAILLE_IPV4

# Ce que le serveur lit au plus. Les deux formats ayant des tailles différentes,
# un pair d'une version antérieure reçoit un datagramme de 20 octets là où
# `lire_requete` en exige exactement 16 : il l'ignore SANS répondre, et la
# vérification échoue proprement au lieu de casser. C'est le repli voulu.
TAILLE_LECTURE = max(TAILLE_MAX, TAILLE_ID)


def commande_ufw_a_suggerer(virtual_cidr: str, port: int = PROBE_PORT) -> str:
    """
    Commande `ufw` à afficher à l'utilisateur. Ne l'exécute JAMAIS.

    Règle du projet, posée après les trois heures perdues au point 36 puis la
    fausse mesure UPnP du point 1bis-c : Meshamoto gère sa propre table nftables,
    `ufw` est un système séparé — on le détecte et on le dit, on n'y touche pas.

    Scopée à la plage virtuelle et non à `Anywhere`, sur le modèle de la ligne
    `27015/udp ALLOW IN 192.168.192.0/24` déjà présente chez l'utilisateur :
    l'écho n'a aucune raison d'être joignable depuis le LAN physique. C'est la
    deuxième couche, la première étant la liaison à l'IP virtuelle.
    """
    return f"sudo ufw allow from {virtual_cidr} to any port {port} proto udp"


def ufw_actif() -> bool:
    """
    Vrai si `ufw` tourne sur cette machine (Linux uniquement).

    **Ne dit PAS que le port est bloqué**, et c'est délibéré : lire les règles
    exige d'être root (`/etc/ufw/user.rules` est en 0640 root:root), donc on ne
    peut pas distinguer un utilisateur qui a déjà posé sa règle d'un autre qui ne
    l'a pas. D'où une formulation conditionnelle côté appelant — affirmer un
    blocage non mesuré enverrait chercher au mauvais endroit, ce qui est pire que
    de se taire (même prudence qu'au point 45 pour la redirection UPnP).
    """
    if config.OS_FAMILY != "linux":
        return False
    try:
        acheve = subprocess.run(
            ["systemctl", "is-active", "ufw"],
            capture_output=True,
            timeout=2.0,
        )
    except (OSError, subprocess.SubprocessError):
        return False
    return acheve.stdout.decode("utf-8", errors="replace").strip() == "active"


# WSAEACCES — « tentative d'accès à un socket de manière interdite par ses
# autorisations d'accès ». Sur Windows, un bind UDP refusé ainsi alors que
# PERSONNE ne détient le port désigne une plage réservée par le système, pas un
# défaut de privilèges (voir PROBE_PORT).
_WSAEACCES = 10013


def expliquer_echec_bind(exc: OSError, virtual_ip: str, port: int,
                         os_family: str | None = None) -> str:
    """
    Message d'échec de mise en écoute de l'écho. Logique pure, testable.

    **Le motif système est TOUJOURS reporté.** La version d'origine avalait
    l'`OSError` : le journal disait que le bind avait échoué, jamais pourquoi.
    Le 2026-08-03, ça a coûté un diagnostic entier — on a soupçonné le pare-feu,
    l'antivirus, puis l'adresse, avant qu'un `bind` manuel en PowerShell donne
    `AccessDenied` en une commande. La ligne du projet est de nommer les causes,
    et un code d'erreur système est la cause la moins chère à reporter.

    `AccessDenied` sous Windows est NOMMÉ, avec la commande qui le confirme :
    c'est contre-intuitif (ça évoque un défaut de privilèges, alors que le
    client Windows tourne déjà élevé) et la vraie cause — une plage de ports
    réservée — ne vient à l'esprit de personne.
    """
    base = (
        f"Écho de vivacité indisponible sur {virtual_ip}:{port} "
        f"({exc.__class__.__name__}: {exc}) — les autres pairs retomberont "
        f"sur le ping ICMP."
    )
    refus_windows = (
        (os_family or config.OS_FAMILY) == "windows"
        and (getattr(exc, "winerror", None) == _WSAEACCES or exc.errno == errno.EACCES)
    )
    if not refus_windows:
        return base
    return base + (
        f" Ce refus signale presque toujours que le port {port} tombe dans une "
        f"plage RÉSERVÉE par Windows (Hyper-V/WinNAT en allouent au démarrage), "
        f"et non un défaut de privilèges. Pour le vérifier : "
        f"netsh int ipv4 show excludedportrange protocol=udp — puis choisir un "
        f"autre port avec MESHAMOTO_PROBE_PORT."
    )


def nouveau_nonce() -> bytes:
    """Témoin aléatoire d'une requête, renvoyé à l'identique par le pair."""
    return secrets.token_bytes(_TAILLE_NONCE)


def construire_requete(nonce: bytes) -> bytes:
    """Datagramme de sonde."""
    return _REQUETE + nonce


def lire_requete(datagramme: bytes) -> bytes | None:
    """
    Témoin d'une requête valide, ou None si ce datagramme n'en est pas une.

    Tout ce qui n'est pas exactement au format attendu est ignoré SANS réponse :
    un service qui répond à n'importe quoi devient un outil de sondage pour qui
    balaie le réseau virtuel.
    """
    if len(datagramme) != TAILLE_MAX or not datagramme.startswith(_REQUETE):
        return None
    return datagramme[len(_REQUETE):]


def construire_reponse(nonce: bytes) -> bytes:
    """
    Datagramme de réponse.

    **Même taille que la requête, délibérément.** Une réponse plus grosse que la
    question ferait de cet écho un amplificateur utilisable pour de la réflexion,
    exactement le défaut que Valve a dû corriger sur A2S en 2020 (point 47).
    """
    return _REPONSE + nonce


def reponse_valide(datagramme: bytes, nonce: bytes) -> bool:
    """
    Vrai si ce datagramme est la réponse à NOTRE requête.

    Le témoin est vérifié pour ne pas prendre la réponse d'une sonde précédente
    (ou un datagramme égaré) pour une preuve de vivacité actuelle : c'est
    précisément la fraîcheur qu'on cherche à établir.
    """
    return (
        len(datagramme) == TAILLE_MAX
        and datagramme.startswith(_REPONSE)
        and datagramme[len(_REPONSE):] == nonce
    )


def construire_requete_identite(nonce: bytes) -> bytes:
    """Requête « qui es-tu ? », rembourrée à la taille de sa réponse (voir TAILLE_ID)."""
    return _REQUETE_ID + nonce + b"\x00" * _TAILLE_IPV4


def lire_requete_identite(datagramme: bytes) -> bytes | None:
    """Témoin d'une requête d'identité valide, ou None. Même sévérité que `lire_requete`."""
    if len(datagramme) != TAILLE_ID or not datagramme.startswith(_REQUETE_ID):
        return None
    return datagramme[len(_REQUETE_ID):len(_REQUETE_ID) + _TAILLE_NONCE]


def construire_reponse_identite(nonce: bytes, virtual_ip: str) -> bytes:
    """
    Réponse d'identité : le témoin, puis NOTRE IP virtuelle en quatre octets.

    C'est cette IP virtuelle qui fait toute la valeur de l'échange : elle permet à
    celui qui sonde de vérifier que la machine qui répond sur cette adresse LAN est
    bien LE pair attendu, et pas une autre machine qui occupe la même adresse sur
    un autre réseau. Sans elle, on retomberait sur une simple coïncidence de
    sous-réseau, c'est-à-dire sur le bug du point 43.
    """
    return _REPONSE_ID + nonce + ipaddress.IPv4Address(virtual_ip).packed


def lire_reponse_identite(datagramme: bytes, nonce: bytes) -> str | None:
    """IP virtuelle déclarée par le répondant, ou None si ce n'est pas notre réponse."""
    if len(datagramme) != TAILLE_ID or not datagramme.startswith(_REPONSE_ID):
        return None
    debut = len(_REPONSE_ID)
    if datagramme[debut:debut + _TAILLE_NONCE] != nonce:
        return None
    return str(ipaddress.IPv4Address(datagramme[debut + _TAILLE_NONCE:]))


def sonder_identite(adresse: str, timeout: float = 0.4, port: int = PROBE_PORT) -> str | None:
    """
    Demande son IP virtuelle à la machine qui occupe cette adresse LAN.

    Renvoie l'IP virtuelle déclarée, ou None (personne, pair d'une version
    antérieure, ou datagramme non conforme). C'est à l'appelant de comparer cette
    valeur à celle du pair qu'il croit joindre — voir `lan_verification`.

    **Timeout court par défaut**, bien plus que pour l'écho de vivacité : on
    interroge ici une adresse du réseau LOCAL, où une machine présente répond en
    une fraction de milliseconde. Attendre une seconde n'apporterait rien et
    plusieurs candidats sont essayés à la suite.
    """
    nonce = nouveau_nonce()
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
            sock.settimeout(timeout)
            sock.sendto(construire_requete_identite(nonce), (adresse, port))
            datagramme, _ = sock.recvfrom(TAILLE_LECTURE)
    except (OSError, socket.timeout):
        return None
    return lire_reponse_identite(datagramme, nonce)


def sonder(virtual_ip: str, timeout: float = 1.0, port: int = PROBE_PORT) -> float | None:
    """
    Interroge l'écho d'un pair -> aller-retour en millisecondes, ou None.

    None ne signifie PAS « pair injoignable » : il signifie « ce pair n'a pas
    répondu à l'écho », ce qui couvre aussi le cas d'un client plus ancien qui ne
    sert pas ce protocole. C'est à l'appelant de retomber sur l'ICMP — voir
    l'en-tête du module.
    """
    debut = time.monotonic()
    nonce = nouveau_nonce()
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
            sock.settimeout(timeout)
            sock.sendto(construire_requete(nonce), (virtual_ip, port))
            # Une seule lecture : le pair renvoie exactement un datagramme, et
            # boucler ici ferait dépasser le budget de temps du cycle de lobby.
            datagramme, _ = sock.recvfrom(TAILLE_MAX)
    except (OSError, socket.timeout):
        return None
    if not reponse_valide(datagramme, nonce):
        return None
    return (time.monotonic() - debut) * 1000.0


class ServeurEcho:
    """
    Répond aux sondes des autres pairs, dans un thread démon.

    **Lié à l'IP VIRTUELLE, jamais à 0.0.0.0**, et c'est une décision de sécurité
    et non une optimisation : sur `0.0.0.0` le service serait joignable depuis le
    LAN physique et depuis Internet si un port était redirigé, alors qu'il n'a
    aucune raison d'exister hors du tunnel. Le lier à l'adresse du tunnel le rend
    inatteignable autrement — même raisonnement que le serveur de signalisation
    lié à `127.0.0.1` derrière Caddy (point 42).
    """

    def __init__(
        self,
        virtual_ip: str,
        port: int = PROBE_PORT,
        adresses_lan: list[str] | None = None,
    ) -> None:
        self.virtual_ip = virtual_ip
        self.port = port
        # Adresses PRIVÉES du LAN sur lesquelles répondre aux seules requêtes
        # d'identité. Jamais une adresse publique : `local_addresses_common.
        # est_privee_utilisable` filtre en amont, et un test le verrouille ici.
        self.adresses_lan = list(adresses_lan or [])
        self._sock: socket.socket | None = None
        self._socks_lan: list[socket.socket] = []
        self._thread: threading.Thread | None = None
        self._arret = threading.Event()

    def demarrer(self) -> bool:
        """
        Ouvre le socket et lance la boucle. False si le port est indisponible.

        **Un échec n'est pas fatal et ne doit rien interrompre** : sans écho, les
        autres pairs retombent sur l'ICMP, c'est-à-dire sur le comportement
        d'avant ce module. Faire échouer le montage du tunnel pour ça serait
        remplacer une dégradation par une panne.
        """
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            sock.settimeout(0.5)  # pour que la boucle voie l'ordre d'arrêt
            sock.bind((self.virtual_ip, self.port))
        except OSError as exc:
            logger.warning("%s", expliquer_echec_bind(exc, self.virtual_ip, self.port))
            return False
        self._sock = sock
        self._socks_lan = self._ouvrir_sockets_lan()
        self._arret.clear()
        self._thread = threading.Thread(target=self._boucle, name="meshamoto-echo", daemon=True)
        self._thread.start()
        logger.info("Écho de vivacité en écoute sur %s:%s", self.virtual_ip, self.port)
        if self._socks_lan:
            logger.info(
                "Identification LAN en écoute sur %s (répond « je suis %s » aux seuls "
                "pairs qui le demandent, sur adresses privées uniquement)",
                ", ".join(f"{a}:{self.port}" for a in self.adresses_lan[:len(self._socks_lan)]),
                self.virtual_ip,
            )
        return True

    def _ouvrir_sockets_lan(self) -> list[socket.socket]:
        """
        Ouvre un socket par adresse LAN privée, pour l'échange d'identité.

        **Un échec par adresse est sans gravité et ne doit rien interrompre.** Une
        interface peut disparaître entre l'énumération et le bind (Wi-Fi qui tombe,
        VPN tiers qui se ferme), et le port peut être réservé sur l'une d'elles.
        Chaque adresse est donc tentée séparément : ce qui s'ouvre sert, le reste
        est ignoré. Sans écoute LAN, la vérification échoue et le pair garde son
        chemin actuel — c'est-à-dire le comportement d'avant ce module.
        """
        socks: list[socket.socket] = []
        for adresse in self.adresses_lan:
            if adresse == self.virtual_ip:
                continue  # déjà couvert par le socket du tunnel
            try:
                sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
                sock.settimeout(0.5)
                sock.bind((adresse, self.port))
            except OSError as exc:
                logger.debug("Identification LAN indisponible sur %s : %s", adresse, exc)
                continue
            socks.append(sock)
        return socks

    def arreter(self) -> None:
        """Arrête la boucle et ferme le socket. Rejouable sans effet."""
        self._arret.set()
        thread, self._thread = self._thread, None
        if thread is not None:
            thread.join(timeout=2.0)
        sock, self._sock = self._sock, None
        if sock is not None:
            sock.close()
        socks_lan, self._socks_lan = self._socks_lan, []
        for s in socks_lan:
            s.close()

    def _boucle(self) -> None:
        """
        Une seule boucle pour tous les sockets, via `select`.

        Un thread par adresse serait plus simple à écrire et bien pire à vivre :
        les threads abandonnés sont la cause des segfauls récurrents de la suite Qt
        (points 27, 45, 47, 59), et une machine avec Hyper-V, WSL et Docker a
        facilement cinq interfaces.
        """
        if self._sock is None:
            return
        while not self._arret.is_set():
            socks = ([self._sock] if self._sock is not None else []) + self._socks_lan
            if not socks:
                return
            try:
                prets, _, _ = select.select(socks, [], [], 0.5)
            except (OSError, ValueError):
                # Socket fermé sous nos pieds par arreter() : sortie normale.
                return
            for sock in prets:
                self._traiter(sock, sock is self._sock)

    def _traiter(self, sock: socket.socket, depuis_le_tunnel: bool) -> None:
        """
        Lit un datagramme et y répond s'il est conforme.

        **Les sockets LAN ne servent QUE l'identité**, jamais l'écho de vivacité :
        celui-ci décide de l'affichage du salon et du droit de demander un relais,
        il n'a rien à faire hors du tunnel. Restreindre ce que chaque socket accepte
        limite la surface exposée au réseau local au strict nécessaire.
        """
        try:
            datagramme, expediteur = sock.recvfrom(TAILLE_LECTURE)
        except (socket.timeout, BlockingIOError):
            return
        except OSError:
            return

        nonce = lire_requete_identite(datagramme)
        if nonce is not None:
            reponse = construire_reponse_identite(nonce, self.virtual_ip)
        elif depuis_le_tunnel and (nonce := lire_requete(datagramme)) is not None:
            reponse = construire_reponse(nonce)
        else:
            # Tout ce qui n'est pas exactement au format attendu est ignoré SANS
            # réponse : un service qui répond à n'importe quoi devient un outil de
            # sondage pour qui balaie le réseau.
            return
        try:
            sock.sendto(reponse, expediteur)
        except OSError:
            # Un expéditeur devenu injoignable ne doit pas arrêter le service
            # pour tous les autres.
            logger.debug("Réponse d'écho impossible vers %r", expediteur)
