"""
Porteur WebSocket/TLS vers le relais de secours — **dernier recours quand l'UDP est
totalement bloqué** (Wi-Fi d'hôtel, réseau d'entreprise, portail captif).

PRINCIPE. On n'ajoute rien à WireGuard et on ne le modifie pas : ce module ouvre un
socket UDP sur la BOUCLE LOCALE, et l'`Endpoint` du pair pointe dessus. Tout ce que
WireGuard y envoie part en trame WebSocket vers le relais ; tout ce qui en revient est
réinjecté vers WireGuard. Montage classique (udp2raw, wstunnel) — et le projet a déjà
cette forme de code dans `relay.py`.

CE N'EST JAMAIS LE DÉFAUT, et l'invariant doit tenir : chaque paquet traverse Python,
alors que le tunnel direct a soutenu ≥ 1086 Mbit/s (point 83). Ce porteur ne sert que
là où l'alternative est « rien ne marche ».

ASYMÉTRIQUE : seul le pair bloqué s'en sert. L'autre continue d'envoyer de l'UDP au
port du pool, sans rien savoir de tout ceci — c'est ce qui évite d'avoir à négocier
quoi que ce soit entre les deux (point 108b).

Une trame binaire = un datagramme, sans préfixe de longueur : une WebSocket est
orientée MESSAGE, contrairement à un TCP brut.
"""

from __future__ import annotations

import logging
import socket
import threading
from typing import Callable, Optional

import websocket  # package "websocket-client"

logger = logging.getLogger(__name__)

# Le socket local n'écoute QUE sur la boucle locale. Sur `0.0.0.0` il accepterait des
# datagrammes du LAN physique — donc n'importe qui sur le réseau pourrait injecter du
# trafic dans la session de relais. Même raisonnement que l'écho de vivacité lié à
# l'IP virtuelle (point 71b) et que le serveur de signalisation lié à 127.0.0.1
# derrière Caddy (point 42).
HOTE_LOCAL = "127.0.0.1"

# Au-delà, ce n'est pas un datagramme WireGuard. Borne, pas réglage de performance.
TAILLE_MAX = 65536

# Délai de lecture de la WebSocket. Ce n'est PAS un délai de panne : le silence est
# l'état normal d'un porteur de relais, et la boucle descendante le traite comme tel.
# Il n'existe que pour rendre l'arrêt possible, comme celui du socket local.
DELAI_LECTURE_SECONDES = 10


def url_du_porteur(base_serveur: str, port_de_relais: int) -> str:
    """
    URL WebSocket du porteur, dérivée de l'adresse du serveur de signalisation.

    Dérivée et non configurée à part : le porteur vit derrière le MÊME reverse proxy,
    sur le même nom et le même certificat — c'est précisément ce qui lui permet de
    passer là où l'UDP ne passe pas. Un réglage distinct finirait par diverger, et
    l'utilisateur n'a aucun moyen de connaître cette valeur.

    Fonction pure : c'est la seule partie qu'on peut vérifier sans réseau.
    """
    base = base_serveur.rstrip("/")
    if base.startswith("https://"):
        base = "wss://" + base[len("https://"):]
    elif base.startswith("http://"):
        base = "ws://" + base[len("http://"):]
    return f"{base}/relay-ws?port={port_de_relais}"


class PorteurWebSocket:
    """
    Pont entre un socket UDP local (côté WireGuard) et une WebSocket (côté relais).

    Deux fils, et c'est le minimum : `socket.recvfrom` et la lecture WebSocket sont
    tous deux BLOQUANTS. Des fils plutôt qu'`asyncio` pour la même raison qu'au
    point 53 — le gain recherché est du temps d'attente recouvert, pas du calcul, et
    ajouter une boucle d'événements dans un programme qui en a déjà une (Qt) coûterait
    plus que ça ne rapporte.
    """

    def __init__(self, url: str, ouvrir_websocket: Optional[Callable] = None) -> None:
        self.url = url
        self._ouvrir_websocket = ouvrir_websocket or self._ouvrir_par_defaut
        self._socket: Optional[socket.socket] = None
        self._ws = None
        self._arret = threading.Event()
        self._fils: list[threading.Thread] = []
        # Dernière adresse d'où WireGuard nous a écrit. On ne la connaît qu'après son
        # premier paquet : c'est lui qui parle en premier (handshake), toujours.
        self._adresse_wireguard: Optional[tuple[str, int]] = None
        self.port_local: Optional[int] = None

    # -- ouverture ---------------------------------------------------------------

    def _ouvrir_par_defaut(self, url: str):
        return websocket.create_connection(url, timeout=DELAI_LECTURE_SECONDES)

    def demarrer(self) -> int:
        """
        Ouvre la WebSocket PUIS le socket local, et renvoie le port local à viser.

        **L'ordre compte.** Ouvrir d'abord le socket local donnerait à l'appelant un
        port à inscrire dans la configuration WireGuard alors que le porteur peut
        encore échouer à se connecter — WireGuard enverrait alors ses handshakes dans
        le vide, et la panne serait muette. Ici, un échec de connexion remonte avant
        que quoi que ce soit n'ait été configuré.
        """
        self._ws = self._ouvrir_websocket(self.url)
        self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self._socket.bind((HOTE_LOCAL, 0))
        self._socket.settimeout(0.5)
        self.port_local = self._socket.getsockname()[1]
        self._arret.clear()
        for cible, nom in ((self._boucle_montante, "montant"), (self._boucle_descendante, "descendant")):
            fil = threading.Thread(target=cible, name=f"meshamoto-ws-{nom}", daemon=True)
            fil.start()
            self._fils.append(fil)
        logger.info(
            "Porteur WebSocket actif : WireGuard doit viser %s:%d, le trafic passera par %s",
            HOTE_LOCAL, self.port_local, self.url,
        )
        return self.port_local

    # -- les deux sens -----------------------------------------------------------

    def _boucle_montante(self) -> None:
        """WireGuard -> relais."""
        while not self._arret.is_set():
            try:
                data, adresse = self._socket.recvfrom(TAILLE_MAX)
            except socket.timeout:
                # Le délai n'est pas de l'attente perdue : c'est ce qui rend l'arrêt
                # possible. Un `recvfrom` bloquant sans limite ne se réveille jamais,
                # et le fil survivrait au porteur — la famille de fuites des points 45,
                # 70bis et 94.
                continue
            except OSError:
                break
            self._adresse_wireguard = adresse
            try:
                self._ws.send_binary(data)
            except Exception:
                logger.debug("Émission WebSocket sans effet", exc_info=True)
                break
        self._arret.set()

    def _boucle_descendante(self) -> None:
        """Relais -> WireGuard."""
        while not self._arret.is_set():
            try:
                data = self._ws.recv()
            except websocket.WebSocketTimeoutException:
                # **LE SILENCE EST L'ÉTAT NORMAL D'UN PORTEUR DE RELAIS**, et le
                # prendre pour une panne coûtait cher : `create_connection(timeout=…)`
                # pose ce délai sur les LECTURES, donc `recv()` lève dès qu'il ne se
                # passe rien. Mesuré en conditions réelles le 2026-08-06 : le porteur
                # mourait au bout de 10 s et était recréé au cycle suivant, avec un
                # port local différent à chaque fois — WireGuard visait alors un socket
                # qui n'existait plus.
                # Le délai n'est pas retiré pour autant : c'est lui qui rend l'arrêt
                # possible, exactement comme dans la boucle montante.
                continue
            except Exception:
                break
            if not data:
                break
            if isinstance(data, str):
                # Le porteur ne transporte que des datagrammes. Une trame texte ne
                # peut pas en être un : l'ignorer plutôt que de la réinjecter, sans
                # quoi on remettrait n'importe quoi dans WireGuard.
                continue
            if self._adresse_wireguard is None:
                # Rien n'est encore sorti d'ici, donc ce paquet ne peut répondre à
                # rien. Le jeter est le seul choix sûr — on ne sait pas où l'envoyer.
                continue
            try:
                self._socket.sendto(data, self._adresse_wireguard)
            except OSError:
                break
        self._arret.set()

    # -- arrêt -------------------------------------------------------------------

    def arreter(self) -> None:
        """Idempotent : appelé à la fermeture et sur erreur, parfois les deux."""
        self._arret.set()
        for fermable in (self._ws, self._socket):
            try:
                if fermable is not None:
                    fermable.close()
            except Exception:
                logger.debug("Fermeture sans effet", exc_info=True)
        self._ws = None
        self._socket = None

    @property
    def actif(self) -> bool:
        return not self._arret.is_set()


class GestionnaireDePorteurs:
    """
    **Un porteur par pair relayé**, et pas un porteur partagé.

    Chaque paire de pairs reçoit son PROPRE port du pool (voir server/relay.py), et un
    porteur est attaché à un port : le mutualiser demanderait de multiplexer plusieurs
    sessions dans une seule WebSocket, donc d'inventer un en-tête de destination — ce
    que le relais aveugle refuse par conception, puisque WireGuard ne peut pas préfixer
    ses paquets. N connexions pour N pairs relayés est le prix de cette conception, et
    il est modeste : le cas normal est zéro, le cas dégradé un ou deux.

    Ne décide RIEN : c'est `network_client._pick_endpoint` qui choisit, ici on ne fait
    qu'ouvrir, réutiliser et fermer.
    """

    def __init__(self, ouvrir_websocket: Optional[Callable] = None) -> None:
        self._ouvrir_websocket = ouvrir_websocket
        # id de pair -> (port de relais servi, porteur)
        self._porteurs: dict[int, tuple[int, PorteurWebSocket]] = {}
        self._verrou = threading.Lock()

    def endpoint_pour(self, peer_id: int, base_serveur: str, port_de_relais: int) -> Optional[str]:
        """
        Endpoint local à donner à WireGuard pour ce pair, ou None si le porteur n'a pas
        pu s'ouvrir.

        **Rendre None plutôt que lever** : l'appelant retombe alors sur l'endpoint UDP
        du relais, c'est-à-dire sur le comportement d'avant. Un porteur injoignable ne
        doit pas priver le pair du seul chemin qu'il avait — le mode restreint est un
        supplément, pas un remplacement.
        """
        with self._verrou:
            existant = self._porteurs.get(peer_id)
            if existant is not None:
                port_servi, porteur = existant
                if port_servi == port_de_relais and porteur.actif:
                    return f"{HOTE_LOCAL}:{porteur.port_local}"
                # Port de relais changé, ou porteur mort : on repart à neuf. Réutiliser
                # une WebSocket attachée à l'ancien port enverrait le trafic dans une
                # session qui ne concerne plus ce pair.
                porteur.arreter()
                del self._porteurs[peer_id]

            url = url_du_porteur(base_serveur, port_de_relais)
            porteur = PorteurWebSocket(url, ouvrir_websocket=self._ouvrir_websocket)
            try:
                port_local = porteur.demarrer()
            except Exception:
                logger.warning(
                    "Mode réseau restreint : impossible d'ouvrir le porteur WebSocket vers "
                    "%s — repli sur le relais UDP, qui ne passera pas si l'UDP est bloqué. "
                    "Vérifier que le serveur publie bien /relay-ws sur 443.",
                    url, exc_info=True,
                )
                return None
            self._porteurs[peer_id] = (port_de_relais, porteur)
            return f"{HOTE_LOCAL}:{port_local}"

    def synchroniser(self, pairs_encore_relayes: set[int]) -> None:
        """
        Ferme les porteurs des pairs qui ne sont plus relayés.

        Sans ça, un pair revenu en direct laisserait sa WebSocket ouverte pour toujours,
        avec ses deux fils — la famille de fuites des points 45, 70bis et 94, et sous Qt
        elle finit en segfault. Le retour au direct est un évènement NORMAL depuis la
        réévaluation périodique du point 104 : ça arrive, et régulièrement.
        """
        with self._verrou:
            for peer_id in [i for i in self._porteurs if i not in pairs_encore_relayes]:
                _, porteur = self._porteurs.pop(peer_id)
                porteur.arreter()
                logger.info("Porteur WebSocket fermé pour le pair %s : plus de relais", peer_id)

    def tout_arreter(self) -> None:
        self.synchroniser(set())

    @property
    def pairs_servis(self) -> set[int]:
        with self._verrou:
            return set(self._porteurs)
