"""
Serveur de signalisation — cœur du "hub" façon Hamachi.

Rôle :
1. REST : créer/rejoindre un réseau virtuel (nom + mot de passe), obtenir une IP virtuelle
2. WebSocket : chaque client connecté maintient une connexion persistante ; le serveur
   pousse la liste des pairs (clé publique WireGuard + endpoint public + IP virtuelle)
   à chaque changement, pour que chaque client mette à jour sa config WireGuard locale.
3. Le serveur ne voit JAMAIS le trafic applicatif des pairs (il ne fait que de la
   signalisation) — le trafic passe en P2P direct via WireGuard, avec relais UDP
   (relay.py) uniquement si le NAT traversal direct échoue.

Lancement :
    uvicorn app:app --host 0.0.0.0 --port 8765

Windows : si vous exposez ce serveur sur Internet, ouvrez le port 8765/TCP (et le port
UDP du relay.py, par défaut 8766) dans le pare-feu Windows Defender.
"""

from __future__ import annotations

import asyncio
import json
import logging
import time
from typing import Optional

from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, Field

import client_ip
import config as server_config
import habilitation
import database as db
import relay_activity
import version
from rate_limit import FailureLimiter

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("meshamoto-server")

# Limitation des tentatives d'authentification echouees (voir rate_limit.py).
# Indispensable des que le serveur est joignable depuis Internet.
_join_limiter = FailureLimiter()

app = FastAPI(title="Meshamoto Signaling Server")

# Connexions WebSocket actives, indexées par peer_id, pour pouvoir pousser des mises à jour ciblées
active_connections: dict[int, WebSocket] = {}

# Code de fermeture WebSocket : « ce pair ne fait plus partie du réseau » (expulsé,
# réseau supprimé, base repartie de zéro). Dans la plage 4000-4999 réservée aux
# applications par la RFC 6455, donc sans collision possible avec un code du protocole.
#
# Il existe pour que le client puisse distinguer « on ne veut plus de moi » d'un
# « serveur momentanément indisponible » : le premier doit ARRÊTER les tentatives et le
# dire à l'utilisateur, le second doit réessayer. Sans cette distinction, une
# reconnexion automatique tourne indéfiniment contre un serveur qui n'en veut pas, et
# l'utilisateur n'apprend jamais pourquoi (point 70).
WS_PEER_INCONNU = 4404

# Le message `{"type": "refuse", "raison": ...}` envoyé JUSTE AVANT cette fermeture est
# ce sur quoi le client s'appuie réellement — voir la note dans `network_ws`. Le code
# ci-dessus reste envoyé : il ne coûte rien et il est lisible dans une capture réseau.
RAISON_PEER_INCONNU = "Ce pair ne fait plus partie du réseau"

# Refus faute d'habilitation. Code DISTINCT de WS_PEER_INCONNU : les deux appellent des
# conduites différentes côté client — un pair expulsé ne revient pas, un jeton expiré se
# réactive tout seul. Le message applicatif porte en plus un champ `motif` lisible par la
# machine, pour que le client sache lequel des deux cas s'applique.
WS_LICENCE = 4403
RAISON_LICENCE = "Habilitation refusée"

# Allocations du pool de ports du relais UDP de secours (voir relay.py) : état
# éphémère, en mémoire uniquement (comme active_connections ci-dessus) — un
# redémarrage du serveur force simplement les clients à re-détecter l'échec direct
# et à redemander une session, ce qui est acceptable pour un mécanisme de secours.
# Clé : frozenset({peer_id_a, peer_id_b})
#   -> {"port": int, "allocated_at": float, "premiere_allocation": float}
#
# `allocated_at` est rafraîchi par le trafic et par les nouvelles demandes (point 86) ;
# `premiere_allocation` ne l'est JAMAIS. C'est cette distinction qui rend la
# réévaluation périodique possible — la dater sur `allocated_at` la rendrait
# inatteignable, puisqu'une paire qui parle rafraîchit sa propre échéance.
_relay_allocations: dict[frozenset, dict] = {}

# Historique des réévaluations, VOLONTAIREMENT séparé des allocations : une réévaluation
# SUPPRIME l'allocation (c'est ce qui fait cesser l'annonce du port), donc y ranger le
# compteur d'échecs le perdrait à chaque fois — et le recul ne reculerait jamais.
# Clé : même frozenset -> {"echecs": int, "a": float}
_relay_reevaluations: dict[frozenset, dict] = {}

# Plafond du compteur d'échecs. L'intervalle est déjà borné par
# RELAY_REEVALUATION_MAX_SECONDS ; ceci évite seulement de calculer 2**n pour un n qui
# grandit sans fin sur une paire en CGNAT permanent.
_MAX_ECHECS_REEVALUATION = 10


def _relay_pair_key(peer_a: int, peer_b: int) -> frozenset:
    return frozenset({peer_a, peer_b})


def intervalle_de_reevaluation(echecs: int) -> float:
    """
    Délai avant de retenter le chemin direct, après `echecs` tentatives infructueuses.

    Logique pure (testable sans état ni horloge) : recul exponentiel plafonné, à partir
    de la base. 0 échec -> 30 min, 1 -> 1 h, 2+ -> 2 h.
    """
    echecs = max(0, min(echecs, _MAX_ECHECS_REEVALUATION))
    intervalle = server_config.RELAY_REEVALUATION_BASE_SECONDS * (2 ** echecs)
    return min(intervalle, server_config.RELAY_REEVALUATION_MAX_SECONDS)


_activite_cache: dict = {"lu_a": None, "valeur": {}}


def _activite_relais(now: float) -> dict:
    """
    Activité publiée par le relais, relue au plus une fois par seconde.

    `_active_relay_port` est appelé pour CHAQUE paire de pairs à chaque construction de
    liste, donc en N² par diffusion : sans mémoïsation, une poignée de pairs suffirait à
    relire ce fichier des dizaines de fois par seconde pour rien. Une seconde est très en
    dessous de la période de publication du relais (10 s), donc on ne perd aucune
    fraîcheur réelle.
    """
    dernier = _activite_cache["lu_a"]
    if dernier is None or now - dernier >= 1.0 or now < dernier:
        _activite_cache["valeur"] = relay_activity.lire(server_config.RELAY_ACTIVITY_PATH)
        _activite_cache["lu_a"] = now
    return _activite_cache["valeur"]


def _allocation_vivante(alloc: dict, now: float) -> bool:
    """
    Une allocation est vivante tant que le RELAIS a réexpédié du trafic récemment, ou à
    défaut tant que la demande qui l'a créée est récente.

    **C'est le correctif du 2026-08-05, et l'ordre des deux critères est tout le sujet.**
    Auparavant seul le second existait, et `allocated_at` n'était rafraîchi qu'à la
    réception d'une nouvelle demande : une paire en plein trafic perdait donc son relais
    à l'expiration du TTL, exactement comme une paire inactive. Voir config.py
    (`RELAY_ALLOCATION_TTL_SECONDS`) pour l'oscillation que ça produisait et sa mesure.

    Le repli sur `allocated_at` n'est pas une politesse : le relais est un processus
    séparé et optionnel, qui peut ne pas tourner, tourner ailleurs, ou ne pas avoir le
    droit d'écrire son fichier d'activité. Sans ce repli, une installation sans relais
    verrait ses allocations mourir instantanément.
    """
    dernier_trafic = _activite_relais(now).get(alloc["port"])
    if dernier_trafic is not None and now - dernier_trafic < server_config.RELAY_ALLOCATION_TTL_SECONDS:
        return True
    return now - alloc["allocated_at"] < server_config.RELAY_ALLOCATION_TTL_SECONDS


def _allocate_relay_port(peer_a: int, peer_b: int, network_id: int) -> int:
    """
    Retourne un port du pool assigné à cette paire de pairs (le même à chaque appel
    tant que la session reste active), ou en alloue un nouveau depuis le pool
    (voir server/config.py, RELAY_PORT_POOL_START/_SIZE) sinon.
    """
    key = _relay_pair_key(peer_a, peer_b)
    now = time.time()
    existing = _relay_allocations.get(key)
    if existing is not None and _allocation_vivante(existing, now):
        existing["allocated_at"] = now
        return existing["port"]

    used_ports = {
        alloc["port"]
        for k, alloc in _relay_allocations.items()
        if k != key and _allocation_vivante(alloc, now)
    }
    pool = range(
        server_config.RELAY_PORT_POOL_START,
        server_config.RELAY_PORT_POOL_START + server_config.RELAY_PORT_POOL_SIZE,
    )
    for port in pool:
        if port not in used_ports:
            _noter_resultat_de_reevaluation(key, now)
            _relay_allocations[key] = {
                "port": port,
                "allocated_at": now,
                # Jamais rafraîchie : c'est l'échéance de réévaluation. Voir la
                # déclaration de _relay_allocations.
                "premiere_allocation": now,
                # Mémorisé pour que la tâche de fond sache À QUI rediffuser quand elle
                # retire l'allocation : elle n'a que la paire de pairs sous la main.
                "network_id": network_id,
            }
            return port
    raise RuntimeError("Pool de ports de relais épuisé (toutes les sessions sont actives)")


def _noter_resultat_de_reevaluation(key: frozenset, now: float) -> None:
    """
    Une paire qui redemande un relais peu après une réévaluation prouve que le direct
    n'a pas tenu : on allonge son prochain délai. Si la demande est tardive (ou s'il n'y
    a jamais eu de réévaluation), c'est une panne neuve et le compteur repart de zéro.
    """
    reeval = _relay_reevaluations.get(key)
    if reeval is None:
        return
    if now - reeval["a"] < server_config.RELAY_REEVALUATION_ECHEC_SECONDS:
        reeval["echecs"] = min(reeval["echecs"] + 1, _MAX_ECHECS_REEVALUATION)
        logger.info(
            "Relais redemandé %.0fs après la réévaluation : le chemin direct n'a pas "
            "tenu, prochaine tentative dans %.0f min",
            now - reeval["a"],
            intervalle_de_reevaluation(reeval["echecs"]) / 60,
        )
    else:
        # Le direct a tenu au moins une fois depuis : cette panne-ci est neuve.
        del _relay_reevaluations[key]


def _reevaluer_si_echu(key: frozenset, alloc: dict, now: float) -> bool:
    """
    Retire l'allocation si son échéance de réévaluation est passée. Rend True si retiré.

    Cesser d'annoncer le port fait retenter le chemin direct aux DEUX pairs en même
    temps. La coordination doit venir du serveur : le roaming de WireGuard annule toute
    tentative unilatérale (voir config.py). Si le direct ne passe toujours pas, ils
    réescaladeront en une quinzaine de secondes et une nouvelle allocation sera faite —
    le repli n'est pas supprimé, il est seulement remis en question de temps en temps.
    """
    echecs = _relay_reevaluations.get(key, {}).get("echecs", 0)
    if now - alloc["premiere_allocation"] < intervalle_de_reevaluation(echecs):
        return False
    del _relay_allocations[key]
    _relay_reevaluations[key] = {"echecs": echecs, "a": now}
    logger.info(
        "Relais du port %d retiré après %.0f min : les deux pairs vont retenter le "
        "chemin direct",
        alloc["port"],
        (now - alloc["premiere_allocation"]) / 60,
    )
    return True


async def passe_de_reevaluation() -> int:
    """
    Un passage de la tâche de fond : retire les allocations échues et **rediffuse** la
    liste aux réseaux concernés. Rend le nombre d'allocations retirées.

    **C'est cette rediffusion qui fait tout le travail.** Retirer l'allocation ne suffit
    pas : tant que les clients ne reçoivent pas de nouvelle liste, ils gardent l'endpoint
    de relais qu'on leur a annoncé et rien ne change. Le premier jet du point 104 s'en
    remettait à la prochaine diffusion spontanée — mesuré en production, il n'y en a
    aucune sur un réseau stable, donc la réévaluation ne se déclenchait jamais.
    """
    now = time.time()
    reseaux = set()
    for key, alloc in list(_relay_allocations.items()):
        if _reevaluer_si_echu(key, alloc, now):
            reseaux.add(alloc["network_id"])
    for network_id in reseaux:
        await _broadcast_peer_list(network_id)
    return len(reseaux)


async def _boucle_de_reevaluation() -> None:
    """Tâche de fond, démarrée au lancement. Une erreur ne doit jamais l'arrêter :
    c'est le seul déclencheur du mécanisme, et un serveur qui la perd redeviendrait
    silencieusement incapable de rendre le chemin direct."""
    while True:
        await asyncio.sleep(server_config.RELAY_REEVALUATION_CHECK_SECONDS)
        try:
            await passe_de_reevaluation()
        except Exception:  # noqa: BLE001 — voir docstring
            logger.exception("Passage de réévaluation des relais interrompu")


def _active_relay_port(peer_a: int, peer_b: int) -> Optional[int]:
    """Port de relais actif pour cette paire, ou None si aucune session n'existe/a expiré."""
    key = _relay_pair_key(peer_a, peer_b)
    now = time.time()
    alloc = _relay_allocations.get(key)
    if alloc is None or not _allocation_vivante(alloc, now):
        return None
    if _reevaluer_si_echu(key, alloc, now):
        return None
    return alloc["port"]


@app.on_event("startup")
async def on_startup() -> None:
    db.init_db()
    logger.info("Base de données initialisée (%s)", db.DB_PATH)
    habilitation.charger_au_demarrage()
    # Seul déclencheur de la réévaluation des relais : rien d'autre ne l'appelle sur un
    # réseau stable, qui ne produit plus aucune diffusion (voir config.py).
    asyncio.create_task(_boucle_de_reevaluation())


class CreateNetworkRequest(BaseModel):
    name: str = Field(..., min_length=3, max_length=64)
    password: str = Field(..., min_length=4)
    # Habilitation, ignorée par un serveur auto-hébergé (voir habilitation.py).
    # Optionnelle de bout en bout : un client d'une version antérieure ne l'envoie pas,
    # et il doit continuer de fonctionner contre un serveur qui n'en demande pas.
    jeton: Optional[str] = None


class RotatePasswordRequest(BaseModel):
    name: str
    password: str  # l'actuel : le connaître EST l'autorisation
    # Mêmes bornes que la création : un réseau ne doit pas pouvoir se retrouver
    # avec un mot de passe qu'il aurait été interdit de lui donner au départ.
    new_password: str = Field(..., min_length=4)


class KickPeerRequest(BaseModel):
    name: str
    password: str
    peer_id: int


class DeleteNetworkRequest(BaseModel):
    name: str
    password: str


class JoinNetworkRequest(BaseModel):
    name: str
    password: str
    public_key: str  # clé publique WireGuard du client (base64, 44 caractères)
    # Optionnel, et le défaut est `None` et NON « peer » : un client qui ne déclare pas
    # de pseudo doit CONSERVER celui déjà enregistré, pas l'effacer. Un littéral par
    # défaut renommait tout le monde « peer » à la moindre reconnexion sans saisie, et
    # détruisait le nom choisi sans retour possible (point 69). Le repli « peer » ne
    # sert plus qu'à un pair réellement nouveau, pour qui il n'existe rien à conserver.
    display_name: Optional[str] = None
    # Habilitation — voir CreateNetworkRequest.
    jeton: Optional[str] = None
    # "windows" / "linux". Optionnel : un client d'une version antérieure ne l'envoie
    # pas. Sert au panneau de diagnostic du client, qui adapte ses conseils ICMP à l'OS
    # du pair visé (Windows ne répond pas au ping entrant par défaut, Linux si).
    os_family: Optional[str] = None


def _liste_endpoints_locaux(peer: db.Peer) -> list[str]:
    """
    Adresses locales d'un pair, telles que diffusées aux autres.

    Stockées en une seule colonne séparée par des virgules — un endpoint `ip:port`
    n'en contient jamais. Retombe sur `last_local_endpoint` quand la liste est
    absente : un pair d'une version antérieure n'annonce qu'une adresse, et la
    présenter sous la forme moderne évite au CLIENT d'avoir à gérer les deux cas.
    Le champ historique reste diffusé tel quel à côté, pour les clients anciens.
    """
    if peer.last_local_endpoints:
        return [e for e in peer.last_local_endpoints.split(",") if e]
    return [peer.last_local_endpoint] if peer.last_local_endpoint else []


def _serialiser_endpoints_locaux(valeur: object) -> Optional[str]:
    """
    Liste d'endpoints reçue d'un client -> chaîne stockable, ou None.

    None signifie « le client n'a rien déclaré », ce que `COALESCE` traduit par
    « ne rien effacer ». Une liste VIDE est traitée comme None pour la même raison :
    un client qui n'a rien pu énumérer ne doit pas effacer ce qu'on savait de lui.

    Les entrées non conformes sont écartées une à une plutôt que de rejeter tout le
    message : la valeur vient du réseau, et une seule entrée douteuse ne doit pas
    faire perdre les autres. **La virgule est interdite parce qu'elle est le
    séparateur** — l'accepter permettrait d'injecter des endpoints supplémentaires
    dans la valeur stockée, donc dans la configuration WireGuard des autres pairs.
    """
    if not isinstance(valeur, list):
        return None
    retenus = [
        e for e in valeur
        if isinstance(e, str) and ":" in e and "," not in e and len(e) <= 64
    ]
    return ",".join(retenus[:16]) or None


# --- Services annoncés par un pair ------------------------------------------------
#
# Le serveur TRANSPORTE ces déclarations sans les interpréter, comme il le fait déjà
# pour `last_local_endpoints` : il ne connaît pas les types de service et n'a aucune
# raison de les connaître. La validation du contenu est côté client
# (`client/service_types.py`), c'est-à-dire là où la valeur est CONSOMMÉE — c'est le
# client qui l'affiche et qui écrit ensuite dans les favoris Steam de l'utilisateur.
#
# Le serveur ne borne donc que ce qui relève de LUI : la taille. Sans plafond, un pair
# pourrait gonfler la base et surtout chaque diffusion de liste, que tous les pairs du
# réseau reçoivent.
HOSTED_SERVICES_MAX_LEN = 2048

# Ces trois valeurs sont le CONTRAT partagé avec `client/service_types.py`, que le
# serveur ne peut pas importer (même nom de module que `server/config.py`, suites de
# tests séparées — voir les conventions du projet). Un test anti-dérive les compare :
# sans lui, renommer une clé d'un seul côté ferait disparaître les services de tout le
# monde, en silence.
CLE_SERVICE_LABEL = "label"
CLE_SERVICE_PORT = "port"
CLE_SERVICE_TYPE = "type"
TYPE_SERVICE_PAR_DEFAUT = "jeu"
SERVICES_MAX = 8


def _serialiser_services(valeur: object) -> Optional[str]:
    """
    Liste de services reçue d'un client -> chaîne JSON stockable, ou None.

    **Hygiène STRUCTURELLE, pas validation métier.** On vérifie que chaque entrée a la
    forme attendue et on rebâtit le JSON nous-mêmes ; on ne juge NI le libellé NI le
    type, qui appartiennent au client. Reconstruire plutôt que stocker la chaîne reçue
    telle quelle est ce qui garantit que la taille et la structure sont bornées, quoi
    qu'un client envoie.

    Les entrées non conformes sont écartées une à une plutôt que de rejeter tout le
    message — même raison qu'aux endpoints : une seule entrée douteuse ne doit pas faire
    perdre les autres.

    None signifie « aucun service » et EFFACE, contrairement aux endpoints où None veut
    dire « ne rien changer ». C'est voulu : le client envoie toujours l'état complet de
    ses déclarations, donc retirer son dernier service doit pouvoir se dire.
    """
    if not isinstance(valeur, list):
        return None
    retenus = []
    for entree in valeur:
        if not isinstance(entree, dict):
            continue
        libelle = entree.get(CLE_SERVICE_LABEL)
        port = entree.get(CLE_SERVICE_PORT)
        type_service = entree.get(CLE_SERVICE_TYPE) or TYPE_SERVICE_PAR_DEFAUT
        if not isinstance(libelle, str) or not libelle.strip():
            continue
        # `isinstance(True, int)` vaut True : sans cette garde, `true` passerait
        # pour le port 1.
        if not isinstance(port, int) or isinstance(port, bool):
            continue
        if not (1 <= port <= 65535):
            continue
        if not isinstance(type_service, str) or not type_service.strip():
            continue
        retenus.append(
            {
                CLE_SERVICE_LABEL: libelle.strip()[:64],
                CLE_SERVICE_PORT: port,
                CLE_SERVICE_TYPE: type_service.strip()[:32],
            }
        )
        if len(retenus) >= SERVICES_MAX:
            break
    if not retenus:
        return None
    serialise = json.dumps(retenus, ensure_ascii=False)
    # Filet : les bornes ci-dessus rendent le dépassement impossible en pratique
    # (8 × ~110 octets), mais le plafond doit être vrai par construction et pas
    # seulement par arithmétique — c'est lui qui protège la base et chaque diffusion.
    return serialise if len(serialise) <= HOSTED_SERVICES_MAX_LEN else None


def _couple_historique(services_json: Optional[str]) -> tuple[Optional[str], Optional[int]]:
    """
    Ancien couple `label`/`port` à conserver pour les clients d'une version antérieure.

    **On y met le premier service de type `jeu`, et RIEN s'il n'y en a pas.** Un client
    ancien traite ce couple comme un jeu : sonde A2S, favoris Steam, `steam://connect`.
    Y placer un service web lui ferait donc faire trois choses fausses, silencieusement.
    Mieux vaut qu'il ne voie aucun service que d'en voir un qu'il interprétera de travers.
    """
    if not services_json:
        return None, None
    try:
        charge = json.loads(services_json)
    except (ValueError, TypeError):
        return None, None
    if not isinstance(charge, list):
        return None, None
    for entree in charge:
        if not isinstance(entree, dict):
            continue
        if entree.get(CLE_SERVICE_TYPE) == TYPE_SERVICE_PAR_DEFAUT:
            return entree.get(CLE_SERVICE_LABEL), entree.get(CLE_SERVICE_PORT)
    return None, None


def _services_annonces(peer: db.Peer) -> Optional[str]:
    """
    Services d'un pair, tels que diffusés aux autres.

    Retombe sur l'ancien couple `hosted_service_label`/`hosted_service_port` quand la
    liste est absente, en le présentant sous la forme moderne : un pair d'une version
    antérieure n'annonce qu'un service, et le synthétiser ici **évite au CLIENT d'avoir
    à gérer les deux cas**. Même raisonnement et même forme que
    `_liste_endpoints_locaux` juste au-dessus.

    Le type par défaut est `jeu`, parce que c'est ce que l'ancien couple signifiait en
    pratique : le salon le traitait déjà comme un port de jeu.

    Le champ historique reste diffusé tel quel à côté, pour les clients anciens.
    """
    if peer.hosted_services:
        return peer.hosted_services
    if peer.hosted_service_label and peer.hosted_service_port:
        return json.dumps(
            [
                {
                    CLE_SERVICE_LABEL: peer.hosted_service_label,
                    CLE_SERVICE_PORT: peer.hosted_service_port,
                    CLE_SERVICE_TYPE: TYPE_SERVICE_PAR_DEFAUT,
                }
            ],
            ensure_ascii=False,
        )
    return None


def _peer_payload(p: db.Peer, pour_peer_id: int) -> dict:
    """
    Représentation d'un pair envoyée à `pour_peer_id`, à la jointure comme en diffusion.

    Factorisé parce que les deux endroits construisaient le MÊME dictionnaire à la main :
    ajouter un champ à l'un et pas à l'autre donnerait un client qui voit une information
    à la connexion puis la perd à la première rediffusion — panne intermittente et très
    difficile à relier à sa cause.
    """
    return {
        "id": p.id,
        "public_key": p.public_key,
        "virtual_ip": p.virtual_ip,
        "endpoint": p.last_endpoint,
        "local_endpoint": p.last_local_endpoint,
        "local_endpoints": _liste_endpoints_locaux(p),
        "display_name": p.display_name,
        "relay_port": _active_relay_port(pour_peer_id, p.id),
        # Ancien couple conservé : un client antérieur ne lit que lui.
        "hosted_service_label": p.hosted_service_label,
        "hosted_service_port": p.hosted_service_port,
        "hosted_services": _services_annonces(p),
        "os_family": p.os_family,
    }


# ⚠ MODÈLE INUTILISÉ — la forme réellement diffusée est celle de `_peer_payload`.
# Rien ne le référence (vérifié le 2026-08-08) : les charges utiles sont construites en
# dictionnaires. Ajouter un champ ICI n'a aucun effet, et c'est un piège d'autant plus
# facile que le modèle a l'air de faire autorité. Il lui manque déjà `os_family` et
# `hosted_services`.
class PeerInfo(BaseModel):
    id: int
    public_key: str
    virtual_ip: str
    endpoint: Optional[str]
    local_endpoint: Optional[str] = None
    local_endpoints: list[str] = []
    display_name: str
    relay_port: Optional[int] = None
    hosted_service_label: Optional[str] = None
    hosted_service_port: Optional[int] = None


class RelaySessionRequest(BaseModel):
    peer_id: int  # le pair qui fait la demande (n'arrive pas à joindre target_peer_id)
    target_peer_id: int


# Le reverse proxy TLS documente (Caddy, voir README) tourne sur la meme machine que le
# serveur : c'est ce qui permet de reconnaitre ses requetes sans configuration.
# Réexportés depuis client_ip pour que le service de licence partage exactement la
# même logique (voir ce module) — les noms restent ceux d'ici, où ils sont utilisés
# à quatre endroits et référencés par tests/test_rate_limit.py.
_ADRESSES_DE_CONFIANCE = client_ip.ADRESSES_DE_CONFIANCE
_client_key = client_ip.cle_source


def _exiger_habilitation(jeton: Optional[str]) -> None:
    """
    Refuse la requête si ce serveur exige une licence et que le jeton ne convient pas.

    Ne fait RIEN sur un serveur auto-hébergé, qui est le défaut (voir habilitation.py).
    `motif` est renvoyé dans un en-tête plutôt que dans le texte : le client doit
    pouvoir distinguer « réactive et réessaie » de « arrête-toi » sans analyser une
    phrase, qui est traduisible et donc fragile — même raisonnement que l'ancrage sur
    les mnémoniques anglais plutôt que sur l'affichage des outils Windows.
    """
    verdict = habilitation.verifier(jeton)
    if verdict.autorise:
        if verdict.licence_id:
            logger.info("Requête habilitée par la licence %s", verdict.licence_id)
        return
    raise HTTPException(
        status_code=402 if verdict.motif == "absent" else 403,
        detail=verdict.message,
        headers={"X-Meshamoto-Motif": verdict.motif},
    )


@app.post("/networks/create")
def create_network(req: CreateNetworkRequest) -> dict:
    """Crée un nouveau réseau virtuel. Retourne une erreur 409 si le nom est déjà pris."""
    # AVANT toute écriture : un réseau créé puis refusé laisserait une trace en base et
    # occuperait un nom, ce qui est pire qu'un refus net.
    _exiger_habilitation(req.jeton)
    try:
        net = db.create_network(req.name, req.password)
    except Exception as exc:  # sqlite3.IntegrityError si nom dupliqué
        raise HTTPException(status_code=409, detail="Ce nom de réseau existe déjà.") from exc
    return {"network_id": net.id, "name": net.name, "cidr": net.cidr}


@app.post("/networks/rotate-password")
def rotate_password(req: RotatePasswordRequest, request: Request) -> dict:
    """
    Change le mot de passe d'un réseau — c'est-à-dire **révoque les codes
    d'invitation déjà distribués**, qui le transportent en clair et ne peuvent ni
    expirer ni être rappelés.

    Soumis à la MÊME limitation de tentatives que `join`, et volontairement au
    même compteur : sans ça, ce point d'entrée offrirait un second guichet pour
    marteler le même mot de passe, et la protection du point 40 se contournerait
    en alternant les deux URL.

    Aucune notion de propriétaire : connaître le mot de passe courant EST
    l'autorisation, comme pour rejoindre. Le projet n'a pas de comptes, et en
    inventer un ici ne protégerait de rien — quiconque peut rejoindre voit déjà
    tout le réseau.
    """
    source = _client_key(request)
    if _join_limiter.is_blocked(source):
        delai = _join_limiter.retry_after_seconds(source)
        raise HTTPException(
            status_code=429,
            detail="Trop de tentatives echouees. Reessayez plus tard.",
            headers={"Retry-After": str(delai)},
        )

    net = db.rotate_network_password(req.name, req.password, req.new_password)
    if net is None:
        _join_limiter.record_failure(source)
        raise HTTPException(status_code=401, detail="Réseau introuvable ou mot de passe incorrect.")

    _join_limiter.record_success(source)
    logger.info("Mot de passe du reseau %s change", req.name)
    return {"network_id": net.id, "name": net.name}


@app.post("/networks/kick")
async def kick_peer(req: KickPeerRequest, request: Request) -> dict:
    """
    Retire un pair du réseau et **ferme sa WebSocket**.

    Fermer la connexion n'est pas un détail de propreté : sans ça, le pair expulsé
    garderait sa session ouverte, continuerait de recevoir les diffusions de liste
    et n'apprendrait jamais son expulsion. La suppression en base ne suffit pas —
    l'état vivant est dans `active_connections`.

    **Un pair expulsé peut rejoindre à nouveau s'il connaît toujours le mot de
    passe.** C'est une conséquence directe du modèle d'autorisation (le mot de
    passe est la seule identité), et l'interface le dit : expulser sans changer le
    mot de passe ne sert que contre quelqu'un qui ne cherche pas à revenir.
    """
    source = _client_key(request)
    if _join_limiter.is_blocked(source):
        raise HTTPException(
            status_code=429,
            detail="Trop de tentatives echouees. Reessayez plus tard.",
            headers={"Retry-After": str(_join_limiter.retry_after_seconds(source))},
        )

    net = db.verify_network_password(req.name, req.password)
    if net is None:
        _join_limiter.record_failure(source)
        raise HTTPException(status_code=401, detail="Réseau introuvable ou mot de passe incorrect.")
    _join_limiter.record_success(source)

    if not db.delete_peer(net.id, req.peer_id):
        raise HTTPException(status_code=404, detail="Ce pair n'appartient pas à ce réseau.")

    ws = active_connections.pop(req.peer_id, None)
    if ws is not None:
        try:
            await ws.close()
        except Exception:  # noqa: BLE001
            # La connexion peut déjà être morte : l'important est qu'elle ne soit
            # plus dans active_connections, ce qui est fait juste au-dessus.
            logger.debug("Fermeture de la WebSocket du pair %d sans effet", req.peer_id)

    logger.info("Pair %d expulse du reseau %s", req.peer_id, req.name)
    await _broadcast_peer_list(net.id)
    return {"kicked": req.peer_id}


@app.post("/networks/delete")
async def delete_network(req: DeleteNetworkRequest, request: Request) -> dict:
    """
    Supprime un réseau et tous ses pairs, et ferme toutes leurs WebSockets.

    Irréversible et sans confirmation côté serveur : c'est à l'interface de la
    demander. Un point d'entrée qui exigerait une confirmation en deux temps
    compliquerait le protocole sans rien empêcher — le mot de passe reste la seule
    chose à connaître, ici comme pour rejoindre.
    """
    source = _client_key(request)
    if _join_limiter.is_blocked(source):
        raise HTTPException(
            status_code=429,
            detail="Trop de tentatives echouees. Reessayez plus tard.",
            headers={"Retry-After": str(_join_limiter.retry_after_seconds(source))},
        )

    net = db.verify_network_password(req.name, req.password)
    if net is None:
        _join_limiter.record_failure(source)
        raise HTTPException(status_code=401, detail="Réseau introuvable ou mot de passe incorrect.")
    _join_limiter.record_success(source)

    # Les identifiants sont relevés AVANT la suppression : après, la table ne dit
    # plus qui était connecté, et les WebSockets resteraient ouvertes.
    identifiants = [p.id for p in db.list_peers(net.id)]
    db.delete_network(net.id)
    for peer_id in identifiants:
        ws = active_connections.pop(peer_id, None)
        if ws is not None:
            try:
                await ws.close()
            except Exception:  # noqa: BLE001
                logger.debug("Fermeture de la WebSocket du pair %d sans effet", peer_id)

    logger.info("Reseau %s supprime (%d pair(s))", req.name, len(identifiants))
    return {"deleted": net.id, "peers": len(identifiants)}


@app.post("/networks/join")
def join_network(req: JoinNetworkRequest, request: Request) -> dict:
    """
    Authentifie le client sur un réseau et lui assigne une IP virtuelle.
    Le client doit ensuite ouvrir une WebSocket sur /networks/{network_id}/ws?peer_id=...
    pour recevoir les mises à jour de la liste de pairs en temps réel.
    """
    source = _client_key(request)
    if _join_limiter.is_blocked(source):
        delai = _join_limiter.retry_after_seconds(source)
        logger.warning("Trop de tentatives echouees depuis %s, blocage %ds", source, delai)
        raise HTTPException(
            status_code=429,
            detail="Trop de tentatives echouees. Reessayez plus tard.",
            headers={"Retry-After": str(delai)},
        )

    net = db.verify_network_password(req.name, req.password)
    if net is None:
        # Seuls les ECHECS comptent : un utilisateur legitime qui rejoint dix fois son
        # reseau n'est jamais gene. Le hachage PBKDF2 protege la base en cas de fuite,
        # pas le service contre le martelage en ligne — d'ou cette limite.
        _join_limiter.record_failure(source)
        raise HTTPException(status_code=401, detail="Réseau introuvable ou mot de passe incorrect.")

    _join_limiter.record_success(source)

    # APRÈS la vérification du mot de passe, et cet ordre est délibéré : contrôler
    # l'habilitation d'abord révélerait à quelqu'un sans clé si un réseau existe et
    # quel est son mot de passe — le refus d'habilitation deviendrait un oracle. Le
    # mot de passe reste le premier secret ; la clé dit seulement qui a le droit de se
    # servir de CE serveur.
    _exiger_habilitation(req.jeton)

    peer = db.join_network(
        net.id, net.cidr, req.public_key, req.display_name, req.os_family
    )
    # Même filtre qu'à la diffusion WebSocket : le client installe son tunnel à partir
    # de cette liste avant même d'ouvrir sa WebSocket, il ne doit donc pas y trouver de
    # pairs déconnectés (voir _connected_peers).
    peers = _connected_peers(db.list_peers(net.id, exclude_peer_id=peer.id))

    return {
        "network_id": net.id,
        "peer_id": peer.id,
        "virtual_ip": peer.virtual_ip,
        "cidr": net.cidr,
        "peers": [_peer_payload(p, peer.id) for p in peers],
    }


def _connected_peers(peers: list) -> list:
    """
    Ne garde que les pairs ayant une WebSocket ouverte en ce moment.

    La table `peers` est persistante : un pair qui a fermé l'application y reste
    indéfiniment. Sans ce filtre il continuait d'être diffusé à tout le monde, donc
    d'apparaître dans le lobby, d'être pingué, et — au bout de
    RELAY_FALLBACK_FAILURE_THRESHOLD échecs — de déclencher une demande de relais de
    secours qui modifiait la liste de pairs et faisait réinstaller le tunnel de tous
    les autres (deux invites de mot de passe sur Linux, pour une machine éteinte
    depuis des heures). Observé en conditions réelles le 2026-07-29.

    On se base sur `active_connections` plutôt que sur `last_seen` : ce dernier n'est
    mis à jour qu'à la jointure et aux changements d'endpoint, un pair connecté mais
    inactif depuis longtemps aurait donc été filtré à tort.
    """
    return [p for p in peers if p.id in active_connections]


async def _broadcast_peer_list(network_id: int) -> None:
    """Pousse la liste à jour des pairs à tous les clients connectés de ce réseau."""
    peers = _connected_peers(db.list_peers(network_id))
    # On ne connait le mapping peer_id -> websocket que pour les pairs actuellement connectés
    for peer in peers:
        ws = active_connections.get(peer.id)
        if ws is None:
            continue
        # Chaque pair reçoit la liste des AUTRES pairs (pas lui-même)
        others = [p for p in peers if p.id != peer.id]
        payload = {
            "type": "peer_list",
            "peers": [_peer_payload(p, peer.id) for p in others],
        }
        try:
            await ws.send_text(json.dumps(payload))
        except Exception:
            logger.warning("Échec d'envoi vers le pair %s, connexion probablement fermée", peer.id)


@app.websocket("/networks/{network_id}/ws")
async def network_ws(websocket: WebSocket, network_id: int, peer_id: int) -> None:
    """
    Connexion persistante pour un pair déjà authentifié via /networks/join.

    Messages entrants attendus du client (JSON) :
      {"type": "endpoint_update", "endpoint": "1.2.3.4:51820", "local_endpoint": "192.168.1.10:51820"}
      -> le client signale son endpoint public (STUN) et, si connue, son IP locale
         LAN (préférée par les autres pairs du même sous-réseau, voir network_client.py)
      {"type": "service_update",
       "services": [{"label": "Palworld", "port": 8211, "type": "jeu"},
                    {"label": "Jellyfin", "port": 8096, "type": "web"}]}
      -> le client déclare l'état COMPLET de ses services locaux (liste vide = tout
         effacer), diffusé aux autres pairs pour qu'ils puissent s'y connecter
         directement (voir gui.py, "Mes services hébergés").
         Le serveur ne fait que les transporter : il ne connaît pas les types, c'est
         le client qui les interprète (client/service_types.py).
      {"type": "service_update", "label": "Palworld", "port": 8211}
      -> ancienne forme, encore acceptée : un client d'une version antérieure ne
         connaît qu'un service à la fois. Promue en liste à un élément de type "jeu".

    Messages sortants vers le client :
      {"type": "peer_list", "peers": [...]}  -> envoyé à la connexion et à chaque changement
    """
    await websocket.accept()

    # Le pair existe-t-il encore dans CE réseau ? La connexion était acceptée sans
    # aucune vérification, ce qui a deux conséquences fâcheuses depuis que le client se
    # reconnaît tout seul (point 70) : un pair expulsé serait accueilli puis ignoré en
    # silence — jamais dans aucune liste, puisqu'il n'est plus en base — et il n'aurait
    # aucun moyen de distinguer ça d'un serveur en panne, donc il réessaierait
    # indéfiniment. Le code de fermeture lui dit lequel des deux c'est.
    # Habilitation AVANT l'existence du pair : sur le serveur géré, quelqu'un sans clé
    # ne doit pas pouvoir sonder quels identifiants de pair existent.
    #
    # Contrôlée à l'ACCEPTATION seulement, jamais en cours de session : une WebSocket
    # peut rester ouverte plus longtemps que le jeton, et couper le réseau de quelqu'un
    # en pleine partie parce qu'une échéance est tombée serait hostile pour un gain
    # nul. La latence de révocation reste bornée par les reconnexions, qui sont
    # fréquentes (redémarrage du serveur, changement de réseau, veille).
    verdict = habilitation.verifier(websocket.headers.get(habilitation.ENTETE_JETON))
    if not verdict.autorise:
        logger.info(
            "WebSocket refusée pour le pair %s : habilitation (%s)", peer_id, verdict.motif
        )
        # Message applicatif AVANT la fermeture, comme pour le pair inconnu : mesuré au
        # point 70, `websocket-client` perd le code de fermeture quand le serveur ferme
        # aussitôt après l'acceptation. Le champ `motif` permet au client de distinguer
        # « réactive ta clé et reviens » de « arrête-toi ».
        await websocket.send_json(
            {
                "type": "refuse",
                "raison": verdict.message,
                "motif": verdict.motif,
                "reessayable": verdict.reessayable,
            }
        )
        await websocket.close(code=WS_LICENCE, reason=RAISON_LICENCE)
        return

    if db.get_peer(network_id, peer_id) is None:
        logger.info("WebSocket refusée : pair %s inconnu du réseau %s", peer_id, network_id)
        # Un MESSAGE applicatif avant la fermeture, et pas seulement un code de
        # fermeture. Mesuré le 2026-08-01 : `websocket-client` rapporte
        # `close_status_code = None` à son `on_close` quand le serveur ferme
        # immédiatement après l'acceptation, alors que le code 4404 part bien sur le
        # fil. Le client ne pouvait donc pas distinguer ce refus d'une coupure, et
        # réessayait indéfiniment — précisément ce que ce refus doit empêcher.
        # Un message traverse le même chemin que tous les autres, y compris un reverse
        # proxy, et ne dépend d'aucune subtilité de bibliothèque.
        await websocket.send_json({"type": "refuse", "raison": RAISON_PEER_INCONNU})
        await websocket.close(code=WS_PEER_INCONNU, reason=RAISON_PEER_INCONNU)
        return

    active_connections[peer_id] = websocket
    logger.info("Pair %s connecté au réseau %s", peer_id, network_id)

    # Envoi immédiat de l'état actuel
    await _broadcast_peer_list(network_id)

    try:
        while True:
            raw = await websocket.receive_text()
            try:
                message = json.loads(raw)
            except json.JSONDecodeError:
                continue

            if message.get("type") == "endpoint_update":
                endpoint = message.get("endpoint") or None
                local_endpoint = message.get("local_endpoint") or None
                local_endpoints = _serialiser_endpoints_locaux(message.get("local_endpoints"))
                if endpoint or local_endpoint or local_endpoints:
                    db.update_peer_endpoint(
                        peer_id, endpoint, local_endpoint, local_endpoints
                    )
                    await _broadcast_peer_list(network_id)

            elif message.get("type") == "service_update":
                if "services" in message:
                    # Client à jour : il envoie la liste complète et fait autorité.
                    services = _serialiser_services(message.get("services"))
                    label, port = _couple_historique(services)
                else:
                    # Client d'une version antérieure : il n'envoie que l'ancien couple.
                    # On le promeut en liste à un élément pour que les clients à jour
                    # n'aient jamais à gérer les deux formes.
                    label = message.get("label") or None
                    port = message.get("port") or None
                    services = _serialiser_services(
                        [
                            {
                                CLE_SERVICE_LABEL: label,
                                CLE_SERVICE_PORT: port,
                                CLE_SERVICE_TYPE: TYPE_SERVICE_PAR_DEFAUT,
                            }
                        ]
                        if label and port
                        else []
                    )
                db.update_peer_service(peer_id, label, port, services)
                await _broadcast_peer_list(network_id)
    except WebSocketDisconnect:
        logger.info("Pair %s déconnecté du réseau %s", peer_id, network_id)
    finally:
        # **N'EFFACER QUE SI L'ENTRÉE EST ENCORE LA NÔTRE.**
        #
        # `pop(peer_id)` sans condition évinçait la connexion d'un pair qui venait de
        # SE RECONNECTER. La course est banale et Windows la déclenche systématiquement :
        # le poste constate la coupure et rouvre en 2 s, tandis que l'ancienne connexion
        # met 20 à 30 s à expirer côté serveur. Le gestionnaire tardif de l'ancienne
        # effaçait alors l'entrée de la nouvelle.
        #
        # Effet observé le 2026-08-05, et il est particulièrement retors : le pair reste
        # RÉELLEMENT connecté — sa liaison est saine, il reçoit les pings d'uvicorn —
        # mais il n'est plus dans `active_connections`, donc invisible de tous les
        # autres et privé de toute diffusion. Rien ne le ramène : côté client il n'y a
        # aucune coupure à constater, donc ni reconnexion (point 70) ni surveillant de
        # silence (point 95) ne peuvent se déclencher. Il a fallu une action humaine.
        #
        # C'était la vraie cause du point 93-(d), attribuée à tort à un défaut du
        # keepalive sous Windows.
        if active_connections.get(peer_id) is websocket:
            active_connections.pop(peer_id, None)
            await _broadcast_peer_list(network_id)


@app.post("/networks/{network_id}/relay-session")
async def request_relay_session(network_id: int, req: RelaySessionRequest) -> dict:
    """
    Demande (ou prolonge) une session de relais UDP de secours entre deux pairs qui
    n'arrivent pas à se connecter directement. Alloue un port du pool de relay.py
    (voir _allocate_relay_port) et repousse immédiatement la liste de pairs aux deux
    côtés pour qu'ils voient le nouveau `relay_port` sans attendre le prochain
    changement de pairs.
    """
    port = _allocate_relay_port(req.peer_id, req.target_peer_id, network_id)
    await _broadcast_peer_list(network_id)
    return {"relay_port": port}


@app.get("/health")
def health() -> dict:
    """
    Test de vie, et **seul endroit où une instance dit quelle version elle
    tourne**. Le champ `version` est ajouté ici plutôt que sur un point d'entrée
    dédié parce que celui-ci répond déjà sans authentification et qu'il est déjà
    celui qu'on interroge pour vérifier qu'un serveur est debout : un `curl`
    répond donc du même coup à « cette instance a-t-elle le correctif ? », y
    compris à distance sur un VPS ou derrière Caddy (voir server/version.py).

    Champ ADDITIONNEL : `status` reste en place et garde sa valeur, un client ou
    une sonde antérieurs ne cassent pas.
    """
    return {"status": "ok", "version": version.version_affichee()}
