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

POURQUOI CE N'EST PAS UN TRANSPORT PAIR-À-PAIR (point 108). Deux contraintes se
composent et ne laissent aucun choix : si l'UDP est bloqué, le pair ne joint plus RIEN
en UDP — ni l'autre pair, ni le relais aveugle — et une WebSocket exige que quelqu'un
ÉCOUTE, ce qu'aucun pair derrière un NAT ne peut faire. Le seul point terminal
joignable est donc ce serveur, sur le 443 où un reverse proxy termine déjà TLS.

IL EST ASYMÉTRIQUE, et c'est ce qui le rend bon marché : seul le pair bloqué parle
WebSocket ; l'autre continue d'envoyer de l'UDP sur le port du pool, exactement comme
avant. Rien à négocier entre les deux pairs, rien à transporter dans le protocole de
signalisation.

CE N'EST PAS UN SECOND RELAIS. La connexion se présente comme **l'une des deux
adresses** de `BlindPairRelayProtocol` (voir `attacher_porteur`) : une seule logique
d'appariement, un seul TTL, un seul compteur de trafic. Écrire un relais parallèle
aurait fait diverger les deux, et le porteur WebSocket aurait contourné la protection
contre un tiers qui devinerait le port.

Une trame binaire = un datagramme. Aucun préfixe de longueur n'est nécessaire : une
WebSocket est orientée MESSAGE, contrairement à un TCP brut. Le blocage de tête de file
reste, lui, le coût inhérent de TCP — mesuré au point 108 comme la seule vraie réserve.

Ce module n'expose aucune surface publique : il écoute sur la boucle locale et c'est le
reverse proxy qui le publie, exactement comme le serveur de signalisation (point 42).
"""

from __future__ import annotations

import asyncio
import itertools
import logging
from typing import Iterable
from urllib.parse import parse_qs, urlparse

import config

logger = logging.getLogger("meshamoto-relay")

# Numérote les adresses virtuelles. Un compteur, et non l'adresse TCP du client :
# derrière un reverse proxy elles viennent toutes de la boucle locale, donc deux pairs
# distincts partageraient la même adresse et se voleraient leur place.
_numeros = itertools.count(1)


def adresse_virtuelle(numero: int) -> tuple:
    """Adresse d'un porteur WebSocket, dans la forme attendue par le relais."""
    return ("ws", numero)


def port_demande(chemin: str, ports_valides: Iterable[int]) -> int | None:
    """
    Extrait le port de pool demandé dans l'URL, ou None si la demande est irrecevable.

    Le client connaît ce port : le serveur de signalisation le lui a annoncé dans la
    liste de pairs (`relay_endpoint`). Il n'y a donc rien à allouer ici — refuser une
    valeur hors du pool suffit, et **c'est le seul contrôle possible** : le relais est
    aveugle par conception, il ne peut pas authentifier un pair sans déchiffrer ce
    qu'il transporte.

    Fonction pure, testée à part des entrées-sorties réseau — le contrôle qui compte
    est ici, pas dans la plomberie WebSocket.
    """
    requete = urlparse(chemin)
    valeurs = parse_qs(requete.query).get("port")
    if not valeurs:
        return None
    try:
        port = int(valeurs[0])
    except ValueError:
        return None
    return port if port in set(ports_valides) else None


def fabriquer_gestionnaire(protocoles: dict[int, "object"]):
    """
    Construit le gestionnaire de connexions, lié au dictionnaire port -> protocole.

    Prend les protocoles en paramètre plutôt que de les importer : c'est ce qui permet
    de l'exercer sans lancer le relais UDP complet, et ça évite un import circulaire
    avec relay.py.
    """

    async def gestionnaire(websocket) -> None:
        chemin = getattr(websocket, "request", None)
        chemin = getattr(chemin, "path", None) or getattr(websocket, "path", "") or ""
        port = port_demande(chemin, protocoles.keys())
        if port is None:
            # Fermeture explicite avec un motif applicatif : un code de fermeture seul
            # se perd selon la bibliothèque cliente (leçon du point 70).
            await websocket.close(code=1008, reason="port de relais inconnu")
            logger.info("WebSocket refusée : port de relais absent ou hors du pool (%s)", chemin)
            return

        protocole = protocoles[port]
        adresse = adresse_virtuelle(next(_numeros))
        boucle = asyncio.get_running_loop()

        def emettre(data: bytes) -> None:
            # Appelé depuis le relais, qui tourne dans cette même boucle : on programme
            # l'envoi sans attendre, pour ne jamais bloquer la réexpédition d'un
            # datagramme sur l'écriture d'une socket TCP encombrée. Un pair lent doit
            # perdre SES paquets, pas ralentir la session d'en face.
            boucle.create_task(_envoyer_sans_bruit(websocket, data))

        protocole.attacher_porteur(adresse, emettre)
        logger.info("Porteur WebSocket %s attaché au port de relais %d", adresse, port)
        try:
            async for message in websocket:
                if isinstance(message, str):
                    # Le relais ne transporte que des datagrammes WireGuard. Ignorer
                    # sans répondre : un service qui répond à n'importe quoi devient un
                    # outil de sondage (même règle qu'à l'écho de vivacité, point 71e).
                    continue
                protocole.paquet_recu(message, adresse)
        finally:
            protocole.detacher_porteur(adresse)
            logger.info("Porteur WebSocket %s détaché du port de relais %d", adresse, port)

    return gestionnaire


async def _envoyer_sans_bruit(websocket, data: bytes) -> None:
    try:
        await websocket.send(data)
    except Exception:
        # Une WebSocket qui se ferme pendant qu'on lui écrit est un cas NORMAL, pas une
        # panne : la journaliser en erreur ferait chercher un défaut à chaque
        # déconnexion. Le détachement est fait par le `finally` du gestionnaire.
        logger.debug("Envoi WebSocket sans effet, porteur probablement fermé", exc_info=True)


async def servir(protocoles: dict[int, "object"], host: str, port: int):
    """
    Ouvre le point d'écoute WebSocket local. Renvoie le serveur, ou None si la
    bibliothèque manque.

    **L'absence de `websockets` ne doit pas empêcher le relais UDP de démarrer** : le
    porteur est un supplément, et une installation sans lui fonctionne exactement comme
    avant. Même principe que le composant privilégié et le relais lui-même, tous deux
    optionnels avec repli propre (points 38 et 49).
    """
    try:
        import websockets
    except ImportError:
        logger.warning(
            "Porteur WebSocket indisponible : la bibliothèque `websockets` n'est pas "
            "installée. Le relais UDP fonctionne normalement ; seuls les pairs dont le "
            "réseau bloque l'UDP resteront injoignables."
        )
        return None

    serveur = await websockets.serve(
        fabriquer_gestionnaire(protocoles), host, port,
        # Un datagramme WireGuard tient largement dans le défaut, mais le fixer évite
        # qu'une trame surdimensionnée fasse fermer la connexion côté bibliothèque.
        max_size=config.RELAY_WS_MAX_FRAME,
    )
    logger.info(
        "Porteur WebSocket en écoute sur %s:%d — à publier sur 443 par le reverse proxy",
        host, port,
    )
    return serveur
