"""
Configuration centrale du client : chemins (Windows et Linux), constantes réseau.

Windows : on utilise %APPDATA% (Roaming) pour stocker la config utilisateur,
et on s'attend à ce que WireGuard for Windows soit installé (télécharger sur
https://www.wireguard.com/install/), typiquement dans
"C:\\Program Files\\WireGuard\\wireguard.exe".

Linux (CachyOS, Arch, Debian/Ubuntu, Fedora...) : on suit la spécification XDG
Base Directory ($XDG_CONFIG_HOME, par défaut ~/.config). Le fichier .conf est
écrit dans le dossier de config de l'utilisateur (pas /etc/wireguard) : wg-quick
accepte un chemin complet vers le .conf, ce qui évite d'avoir besoin d'écrire
dans un répertoire système appartenant à root.
"""

from __future__ import annotations

import os
import sys
from pathlib import Path

# --- Chemins par plateforme -------------------------------------------------

if sys.platform == "win32":
    # %APPDATA%\Meshamoto  (ex: C:\Users\<user>\AppData\Roaming\Meshamoto)
    APP_DATA_DIR = Path(os.environ.get("APPDATA", str(Path.home()))) / "Meshamoto"
else:
    # ~/.config/meshamoto (respecte XDG_CONFIG_HOME si défini, comme sur CachyOS)
    xdg_config_home = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
    APP_DATA_DIR = Path(xdg_config_home) / "meshamoto"

CONFIG_DIR = APP_DATA_DIR / "config"
LOG_DIR = APP_DATA_DIR / "logs"

# Emplacement par défaut de l'exécutable WireGuard sur Windows (installateur officiel)
# Non utilisé sur Linux, où wg-quick est recherché dynamiquement via $PATH (shutil.which).
WIREGUARD_EXE_CANDIDATES = [
    Path(r"C:\Program Files\WireGuard\wireguard.exe"),
    Path(r"C:\Program Files (x86)\WireGuard\wireguard.exe"),
]

# Famille d'OS de cette machine, signalée au serveur puis rediffusée aux autres pairs.
# Sert uniquement au panneau de diagnostic, qui adapte ses conseils à l'OS du pair VISÉ :
# un ping sans réponse est normal vers un Windows (ICMP entrant bloqué par défaut, même
# en profil "Privé") mais anormal vers un Linux. Centralisé ici plutôt que dispersé en
# `sys.platform` dans le code métier (voir CLAUDE.md, conventions).
if sys.platform == "win32":
    OS_FAMILY = "windows"
elif sys.platform.startswith("linux"):
    OS_FAMILY = "linux"
else:
    OS_FAMILY = sys.platform

# Le nom de l'interface/tunnel tel qu'il apparaîtra dans "WireGuard for Windows"
TUNNEL_NAME = "meshamoto"

# Nom du fichier de config WireGuard généré (doit correspondre à TUNNEL_NAME)
WIREGUARD_CONF_PATH = CONFIG_DIR / f"{TUNNEL_NAME}.conf"

# Clé privée WireGuard persistée : identité réseau stable d'un lancement à l'autre
# (même IP virtuelle, pas de pair fantôme créé côté serveur à chaque redémarrage).
IDENTITY_KEY_PATH = CONFIG_DIR / "identity.key"

# Réseaux récemment rejoints (nom + mot de passe), pour éviter de les ressaisir.
RECENT_NETWORKS_PATH = CONFIG_DIR / "recent_networks.json"

# Dernière adresse de serveur utilisée avec succès (host + port), pour éviter de
# redéfinir MESHAMOTO_SERVER_HOST/_PORT à chaque lancement.
SERVER_ADDRESS_PATH = CONFIG_DIR / "server_address.json"

# Règles ACL du mode isolation : restreint certains pairs à des ports précis plutôt
# qu'à tout le réseau virtuel (voir acl_rules.py). Réglage purement local.
ACL_RULES_PATH = CONFIG_DIR / "acl_rules.json"

# Préférence de thème clair/sombre (voir theme_settings.py). Réglage purement local,
# indépendant de l'OS (géré par Qt, identique sur Windows et Linux).
THEME_SETTINGS_PATH = CONFIG_DIR / "theme.json"

# Port UDP d'écoute WireGuard choisi par l'utilisateur (voir wireguard_port_settings.py).
# Réglage purement local : chaque pair annonce son propre port via son endpoint.
WIREGUARD_PORT_SETTINGS_PATH = CONFIG_DIR / "wireguard_port.json"

# Mode réseau restreint : atteindre le relais par WebSocket/443 plutôt qu'en UDP
# (voir ws_transport_settings.py et ws_relay_transport.py). Décoché par défaut.
WS_TRANSPORT_SETTINGS_PATH = CONFIG_DIR / "mode_reseau_restreint.json"

# Pseudo affiché aux autres pairs (voir display_name_settings.py). Sans mémorisation,
# le champ repartait vide à chaque lancement et la jointure retombait sur « peer »,
# écrasant le nom choisi pour tout le monde (constaté en production, point 69).
DISPLAY_NAME_SETTINGS_PATH = CONFIG_DIR / "display_name.json"

# Clé de licence et jeton d'habilitation (voir licence_settings.py). N'existe que sur
# une machine qui se sert d'un serveur GÉRÉ : quelqu'un qui héberge le sien n'a aucune
# clé, et rien ne lui en demande jamais.
LICENCE_SETTINGS_PATH = CONFIG_DIR / "licence.json"

# Sous-domaine et token DuckDNS (voir duckdns_settings.py). CONTIENT UN SECRET,
# d'où un fichier en 0600 sur Linux.
#
# Volontairement dans APP_DATA_DIR et **pas** dans CONFIG_DIR, contrairement à tous
# les réglages ci-dessus : c'est exactement le chemin que lit déjà la minuterie
# systemd du paquet Arch (`packaging/linux/meshamoto-duckdns-update`, point 44),
# soit `~/.config/meshamoto/duckdns.env`. Configurer DuckDNS dans l'interface
# suffit donc à ce que la minuterie maintienne l'enregistrement à jour même
# Meshamoto fermé. Le déplacer sous CONFIG_DIR casserait ce lien en silence.
DUCKDNS_SETTINGS_PATH = APP_DATA_DIR / "duckdns.env"

# --- Réseau -----------------------------------------------------------------

DEFAULT_SERVER_HOST = os.environ.get("MESHAMOTO_SERVER_HOST", "127.0.0.1")
DEFAULT_SERVER_PORT = int(os.environ.get("MESHAMOTO_SERVER_PORT", "8765"))

# Serveur de signalisation GÉRÉ, proposé par l'écran d'accueil. Le défaut ci-dessus
# reste volontairement la boucle locale : qui installe sans rien lire héberge chez lui,
# ce qui est la promesse du produit. Celui-ci est un choix EXPLICITE de l'utilisateur.
#
# SOURCE UNIQUE DE VÉRITÉ, et ce n'est pas cosmétique : cette chaîne part dans les
# binaires distribués. Le jour où le serveur déménage, tout client déjà installé vise
# le vide et rien ne le met à jour. Une constante rend le changement d'une ligne au
# lieu d'une chasse dans le code, et un test la compare à ce qu'affiche le site.
#
# LA FORME COMPTE : schéma explicite, JAMAIS de port. `parse_server_address` dérive 443
# de `https://`, mais `meshamoto.ca:443` — ce que recopie quelqu'un qui a vu un port —
# s'analyse en TLS=False, part en clair vers le port chiffré et échoue derrière le
# reverse proxy, avec un message qui accuse le pare-feu (mesuré, point 83d).
MANAGED_SERVER_URL = os.environ.get("MESHAMOTO_MANAGED_SERVER", "https://meshamoto.ca")

# Guide utilisateur, ouvert par le bouton « Guide » de l'en-tête.
#
# **Volontairement PAS dérivé de MANAGED_SERVER_URL**, bien que les deux pointent le
# même hôte aujourd'hui. Cette variable-là est surchargeable par l'environnement pour
# viser une instance de test ou un serveur auto-hébergé : en faire dériver le guide
# enverrait alors le bouton d'aide sur une machine qui ne sert aucune documentation,
# c'est-à-dire précisément quand quelqu'un cherche de l'aide.
#
# Un test vérifie que ce chemin correspond à une page réellement livrée dans
# packaging/site/ : un bouton d'aide qui ouvre un 404 est pire que pas de bouton.
GUIDE_URL = "https://meshamoto.ca/guide"
# TLS vers le serveur de signalisation (https/wss au lieu de http/ws). Indispensable dès
# que le serveur est joignable depuis Internet : sans lui, le mot de passe d'un réseau
# circule en clair. Se règle aussi en préfixant l'adresse par "https://" dans l'interface.
DEFAULT_SERVER_TLS = os.environ.get("MESHAMOTO_SERVER_TLS", "").lower() in ("1", "true", "yes")
# Redirection de port automatique via UPnP-IGD (voir upnp_client.py). Donne un port
# externe DÉTERMINISTE, là où STUN se contente d'observer un mapping que le NAT peut
# changer. Désactivable pour les réseaux où l'UPnP est proscrit (entreprise), ou pour
# diagnostiquer : MESHAMOTO_UPNP=0.
UPNP_ENABLED = os.environ.get("MESHAMOTO_UPNP", "1").lower() not in ("0", "false", "no")

DEFAULT_RELAY_HOST = os.environ.get("MESHAMOTO_RELAY_HOST", "127.0.0.1")
DEFAULT_RELAY_PORT = int(os.environ.get("MESHAMOTO_RELAY_PORT", "8766"))

# Port UDP local utilisé par l'interface WireGuard (doit correspondre à ListenPort
# dans le .conf). Configurable, car 51820 est indisponible dès qu'un autre tunnel
# WireGuard tourne déjà sur la machine (application WireGuard officielle, client VPN
# d'entreprise) : le tunnel Meshamoto échouait alors à démarrer, silencieusement sous
# Windows — voir wireguard_backend_windows._verify_service_started.
#
# Trois sources, par priorité décroissante : la variable d'environnement, le réglage
# mémorisé par l'interface (appliqué au démarrage via set_wireguard_listen_port), puis
# ce défaut. Il n'a PAS besoin d'être identique d'une machine à l'autre : chaque pair
# annonce son propre port aux autres dans son endpoint.
DEFAULT_WIREGUARD_LISTEN_PORT = 51820
WIREGUARD_PORT_ENV_VAR = "MESHAMOTO_WIREGUARD_PORT"
WIREGUARD_PORT_FORCED_BY_ENV = WIREGUARD_PORT_ENV_VAR in os.environ
WIREGUARD_LISTEN_PORT = int(
    os.environ.get(WIREGUARD_PORT_ENV_VAR, DEFAULT_WIREGUARD_LISTEN_PORT)
)


def set_wireguard_listen_port(port: int) -> None:
    """
    Applique le port d'écoute pour toute la session.

    Tous les consommateurs lisent `config.WIREGUARD_LISTEN_PORT` au moment de l'appel
    (rendu du .conf, découverte STUN, endpoint annoncé, les deux backends de pare-feu),
    jamais par un `from config import ...` figé à l'import — réaffecter l'attribut ici
    suffit donc à propager le changement, sans faire circuler le port en paramètre dans
    toute la pile. À appeler avant de monter le tunnel.
    """
    global WIREGUARD_LISTEN_PORT
    WIREGUARD_LISTEN_PORT = port

# Décalage de port pour le canal Infrastructure du relais broadcast/multicast LAN
# (voir client/lan_broadcast_relay.py) : le canal Infrastructure d'un jeu dont le
# port de découverte est PORT écoute sur PORT + LAN_RELAY_PORT_OFFSET.
LAN_RELAY_PORT_OFFSET = 10000

# Port de découverte LAN des jeux moteur Source/GoldSrc (Half-Life, Counter-Strike
# 1.6, Team Fortress 2, Left 4 Dead/2, Garry's Mod...) — confirmé par une capture
# Wireshark réelle sur Left 4 Dead 2 le 2026-07-27 (voir README.md, section
# "Gaming LAN virtuel") : broadcast UDP contenant la chaîne ASCII "LanSearch".
SOURCE_ENGINE_LAN_PORT = 27015

# Nombre d'échecs de ping CONSÉCUTIFS vers un pair (voir gui.py::_on_ping_results,
# rafraîchi toutes les 5s) avant de considérer la connexion directe comme non
# fonctionnelle et de demander une session de relais UDP de secours (NAT symétrique
# des deux côtés, typiquement). 3 échecs = ~15s, évite de réagir à une simple perte
# de paquet isolée.
RELAY_FALLBACK_FAILURE_THRESHOLD = 3

# Délai après un mouvement du réseau pendant lequel les échecs de ping ne comptent
# pas dans le repli sur relais : l'interface est down pendant la réinstallation (et
# ça dure le temps d'une saisie de mot de passe), puis WireGuard doit encore
# renégocier son handshake avec chaque pair. Sans ce délai, une simple arrivée de
# pair suffisait à faire basculer un lien parfaitement sain sur le relais — voir
# NetworkClient.tunnel_recently_changed. Couvre trois cycles de ping (5s chacun).
#
# Le compte est reparti du dernier CHANGEMENT DE LISTE DE PAIRS depuis le point 76bis,
# et plus seulement de notre propre réinstallation : un pair Windows recrée son
# interface à chaque changement d'effectif (pas de `wg syncconf`, point 54), donc il
# disparaît réellement pendant que NOTRE tunnel, lui, ne bouge pas. Mesuré le
# 2026-08-03 : escalade 14 s après une reconnexion de masse, alors que la dernière
# diffusion la précédait de la même durée — la fenêtre couvre donc ce cas, et le
# compteur repart à chaque diffusion tant que la rafale dure.
PING_GRACE_AFTER_TUNNEL_CHANGE_SECONDS = 15.0

# Intervalle MINIMUM entre deux évaluations du repli sur relais.
#
# Le lobby déclenche un cycle de ping immédiat à chaque diffusion de la liste de
# pairs (pour que l'affichage réagisse tout de suite), en plus du timer de 5 s. Sans
# ce plancher, le compteur d'échecs mesurait donc un NOMBRE DE DIFFUSIONS et non un
# temps écoulé : une rafale — reconnexion de masse, puis chaque pair réannonçant son
# endpoint après STUN/UPnP — franchissait `RELAY_FALLBACK_FAILURE_THRESHOLD` en deux
# secondes au lieu de quinze.
#
# Mesuré en conditions réelles le 2026-08-03 (point 76) : au redémarrage du serveur
# de signalisation, les cinq pairs se sont reconnectés en 1,2 s, et le pair distant a
# demandé un relais avec TOUS ses pairs 2 s après l'expiration de la grâce.
#
# Sous les 5 s du timer, pour qu'un cycle périodique compte toujours ; assez haut
# pour qu'une rafale de diffusions ne vote qu'une fois.
PING_ESCALATION_MIN_INTERVAL_SECONDS = 4.0

# Reconnexion automatique de la WebSocket de signalisation (voir
# network_client._boucle_websocket et reconnect_policy.py).
#
# Il n'en existait AUCUNE jusqu'au point 70 : une coupure Wi-Fi, un redémarrage du
# serveur ou une expulsion laissaient le pair déconnecté en silence jusqu'à ce que
# quelqu'un relance l'application à la main. Invisible sur un parc surveillé, bloquant
# sur une machine laissée seule.
#
# Recul exponentiel plafonné : réessayer toutes les secondes contre un serveur éteint
# ne le rallume pas et remplit son journal, mais attendre plusieurs minutes après un
# simple hoquet réseau serait tout aussi mauvais. Le plafond est court (30 s) parce
# qu'un pair déconnecté n'est PAS un pair inactif — il est absent du réseau de tout le
# monde pendant ce temps.
RECONNECT_INITIAL_DELAY_SECONDS = 1.0
RECONNECT_MAX_DELAY_SECONDS = 30.0
RECONNECT_BACKOFF_FACTOR = 2.0

# Keepalive WebSocket côté client — ce qui permet de S'APERCEVOIR qu'une connexion est
# morte. La reconnexion du point 70 est excellente une fois la coupure constatée ; encore
# faut-il la constater.
#
# `run_forever()` était appelé sans `ping_interval`, et le défaut de websocket-client est
# **zéro, donc aucun ping**. Une connexion à moitié ouverte — le réseau du poste
# disparaît sans que le socket reçoive ni RST ni FIN — n'était donc détectée qu'à
# l'expiration TCP du système. **Mesuré le 2026-08-05 sur un poste Windows dont on a
# désactivé la carte réseau : ONZE MINUTES** (15:35:25 connecté -> 15:46:41
# « Connection to remote host was lost »). Pendant tout ce temps le pair se croyait
# connecté, ne tentait rien, et était absent de la liste de tout le monde — le serveur
# l'avait retiré au bout de ~28 s.
#
# **Ce n'est pas un mécanisme nouveau, c'est une symétrie rétablie** : uvicorn ping déjà
# ses clients toutes les 20 s (`ws_ping_interval`, valeur par défaut jamais modifiée
# dans nos unités), ce qui explique précisément que le serveur, LUI, détecte la panne en
# une demi-minute. Seul le sens client -> serveur manquait.
#
# 20 s + 10 s de patience : détection en ~30 s au pire, contre onze minutes. Ça reste
# très en dessous de tout timeout de proxy, et la connexion n'était de toute façon
# jamais inactive puisque le serveur y envoie déjà ses propres pings.
# **CONTRAINTE DE LA BIBLIOTHÈQUE : `ping_interval` DOIT être > `ping_timeout`**,
# websocket-client lève `WebSocketException` sinon — donc au démarrage du client, et pas
# à la première coupure. Un test verrouille l'inégalité.
WEBSOCKET_PING_INTERVAL_SECONDS = 20.0
WEBSOCKET_PING_TIMEOUT_SECONDS = 10.0

# Silence maximal toléré du serveur avant de considérer la liaison morte, quoi qu'en
# dise la bibliothèque.
#
# **Ceci est une défense en profondeur, pas un doublon.** Le keepalive de
# `run_forever` (ci-dessus) fonctionne : mesuré trois fois le 2026-08-05 sous Linux —
# `ws://` serveur figé, `wss://` serveur figé, `wss://` à travers un trou noir — il
# détecte en 25 s dans les trois cas. Mais en production, sur une machine WINDOWS, il
# n'a **pas** fonctionné : le client s'est cru connecté treize minutes après que le
# serveur l'eut retiré, sans une ligne de journal, et n'est revenu que sur une action
# humaine (point 93-d). La cause exacte n'est pas établie et n'est pas accessible
# depuis la machine de développement.
#
# On ne fait donc plus dépendre d'une bibliothèque une propriété aussi critique que
# « suis-je encore dans le réseau ? ». Le signal choisi ne vient pas de nous :
# **uvicorn envoie ses propres pings WebSocket toutes les 20 s** (`ws_ping_interval`,
# défaut jamais modifié dans nos unités, déjà vérifié au point 88), et
# websocket-client nous les remonte par `on_ping`. Trois cycles manqués valent donc
# panne — assez large pour qu'une pointe de latence ou une pause du serveur ne
# déclenche rien, assez court pour rester une demi-minute plutôt que onze.
WEBSOCKET_SILENCE_MAX_SECONDS = 75.0

# Nombre de mesures de ping récentes conservées par pair (voir gui.py::_on_ping_results)
# pour calculer la gigue/perte affichée dans l'indicateur de qualité de connexion
# (connection_quality.py) — fenêtre glissante, pas un nouvel échantillonnage réseau.
PING_HISTORY_WINDOW = 4

# Serveurs STUN publics pour la découverte d'adresse publique (IP:port) — nécessaire au hole punching
STUN_SERVERS = [
    ("stun.l.google.com", 19302),
    ("stun1.l.google.com", 19302),
]


def ensure_directories() -> None:
    """Crée les répertoires de config/logs si absents. À appeler au démarrage du client."""
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    LOG_DIR.mkdir(parents=True, exist_ok=True)


def find_wireguard_exe() -> Path:
    """
    Localise wireguard.exe. Lève FileNotFoundError avec un message clair si WireGuard
    for Windows n'est pas installé — c'est un prérequis externe à ce projet.
    """
    for candidate in WIREGUARD_EXE_CANDIDATES:
        if candidate.exists():
            return candidate
    raise FileNotFoundError(
        "wireguard.exe introuvable. Installez WireGuard for Windows depuis "
        "https://www.wireguard.com/install/ avant de lancer ce client."
    )


def find_wg_exe() -> Path | None:
    """
    Localise `wg.exe`, livré PAR WireGuard for Windows à côté de `wireguard.exe`.

    **Ce n'est PAS une nouvelle dépendance**, contrairement à ce que le projet a
    affirmé pendant des mois à trois endroits (les docstrings de `sync_tunnel` et
    `get_latest_handshakes`, et le README) : l'installateur officiel dépose les deux
    exécutables dans le même dossier, et WireGuard for Windows est déjà un prérequis
    dur du client Windows — `find_wireguard_exe()` ci-dessus lève sinon.
    Mesuré le 2026-08-09 sur une machine du parc : `wg.exe` (140 408 o) et
    `wireguard.exe` côte à côte, et `wg show` répond, handshake compris.

    Le chemin est **dérivé** de `WIREGUARD_EXE_CANDIDATES` et non recopié dans une
    seconde liste : deux listes de chemins d'installation divergeraient, et le jour où
    l'une changerait, la mise à jour à chaud retomberait en silence sur la
    réinstallation complète sans que rien ne le signale.

    Rend `None` plutôt que de lever : l'absence de `wg.exe` — installation WireGuard
    trop ancienne, dossier non standard — n'est pas une erreur. Les appelants
    retombent alors sur le comportement d'avant, qui reste correct.
    """
    for candidate in WIREGUARD_EXE_CANDIDATES:
        wg = candidate.with_name("wg.exe")
        if wg.exists():
            return wg
    return None
