"""
Explique pourquoi une connexion au serveur de signalisation a expiré.

Un seul cas est traité, mais c'est le premier obstacle que rencontre quiconque
auto-héberge : **le serveur est chez soi, et on essaie de le joindre par son
adresse publique depuis le même réseau local.** La box doit alors renvoyer vers
l'intérieur une connexion sortie vers sa propre adresse publique (« hairpin
NAT ») — la plupart des box grand public ne le font pas, y compris celle sur
laquelle ce projet est développé. Le paquet part et personne ne répond : le
client attend dix secondes puis affiche un `ConnectTimeoutError` brut, qui ne
dit rien d'exploitable.

Le diagnostic est **déterministe, pas heuristique** : si le nom saisi résout
vers notre propre adresse publique, alors le serveur est forcément derrière la
même box que nous. Il n'y a pas d'autre explication possible, donc pas de faux
positif à craindre.

La logique de verdict (`expliquer_echec`) est séparée des résolutions réseau
pour être testable sans réseau, comme `hosting_diagnostics.py`.
"""

from __future__ import annotations

import ipaddress
import logging
import socket
from typing import Optional

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


def _est_privee(adresse: str) -> bool:
    try:
        return ipaddress.ip_address(adresse).is_private
    except ValueError:
        return False


# Plages RFC 1918, et elles seules. `ipaddress.is_private` est BEAUCOUP plus
# large : il couvre aussi la boucle locale, le lien-local, et les plages de
# documentation RFC 5737 (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) qui ne
# sont pas des adresses de réseau local. S'en servir ici faisait conclure « votre
# fichier hosts pointe vers une adresse locale » pour une adresse qui n'en est
# pas une — attrapé par un test dont la donnée d'exemple était justement en
# 203.0.113.x.
_PLAGES_LAN = (
    ipaddress.ip_network("10.0.0.0/8"),
    ipaddress.ip_network("172.16.0.0/12"),
    ipaddress.ip_network("192.168.0.0/16"),
)


def _est_adresse_de_lan(adresse: str) -> bool:
    """Vrai si l'adresse ne peut exister que sur un réseau local (RFC 1918)."""
    try:
        ip = ipaddress.ip_address(adresse)
    except ValueError:
        return False
    return any(ip in plage for plage in _PLAGES_LAN)


def _meme_reseau_local(a: str, b: str) -> bool:
    """
    Vrai si les deux adresses partagent le même /24.

    Approximation assumée : on ne connaît pas le masque réel. Elle ne sert qu'à
    se TAIRE dans le doute — si deux adresses partagent leur /24, on considère
    qu'elles peuvent se joindre et on n'explique rien. Se tromper dans ce sens
    coûte un message manquant, jamais un message faux.
    """
    try:
        reseau_a = ipaddress.ip_network(f"{a}/24", strict=False)
        return ipaddress.ip_address(b) in reseau_a
    except ValueError:
        return False


def expliquer_echec(
    nom_serveur: str,
    ip_resolue: Optional[str],
    ip_publique: Optional[str],
    ip_locale: Optional[str],
) -> Optional[str]:
    """
    Renvoie une explication actionnable, ou None si le cas n'est pas reconnu.

    Renvoyer None est le comportement par défaut ASSUMÉ : un message inventé
    pour une panne qu'on n'a pas identifiée enverrait chercher au mauvais
    endroit, ce qui est pire que l'erreur technique brute. On n'explique que ce
    dont on est certain.
    """
    if not ip_resolue:
        return None

    # Cas 2 : le nom pointe vers une adresse PRIVÉE d'un autre réseau que celui
    # où l'on se trouve. Typiquement une ligne laissée dans le fichier hosts pour
    # contourner le hairpin NAT chez soi (cas 1 ci-dessous), qui devient un piège
    # dès qu'on change de réseau — partage de connexion mobile, déplacement.
    # Rencontré en conditions réelles le 2026-07-30, et parfaitement muet : le
    # client attend dix secondes une adresse qui n'existe pas là où il est.
    if _est_adresse_de_lan(ip_resolue) and ip_locale and not _meme_reseau_local(ip_locale, ip_resolue):
        return (
            f"Le nom « {nom_serveur} » pointe vers l'adresse privée {ip_resolue}, "
            f"qui n'existe pas sur le réseau où vous êtes actuellement "
            f"(votre adresse : {ip_locale}).\n\n"
            "C'est presque toujours une ligne laissée dans le fichier hosts de cette "
            "machine, ajoutée pour un autre réseau. Retirez-la, puis videz le cache "
            "DNS, et le nom retrouvera son adresse publique."
        )

    if not ip_publique or ip_resolue != ip_publique:
        return None

    # Le nom pointe sur notre propre adresse publique : le serveur est derrière
    # la même box que nous.
    if ip_locale and _est_privee(ip_locale):
        conseil = (
            f"Utilisez plutôt l'adresse locale du serveur (celle du type "
            f"{ip_locale.rsplit('.', 1)[0]}.x), ou ajoutez cette ligne au fichier "
            f"hosts de cette machine, en dehors du bloc « # Meshamoto » qui est "
            f"réécrit automatiquement :\n"
            f"    <adresse locale du serveur>    {nom_serveur}"
        )
    else:
        conseil = (
            "Utilisez plutôt l'adresse locale du serveur sur votre réseau, ou "
            f"ajoutez-la au fichier hosts de cette machine pour le nom {nom_serveur}, "
            "en dehors du bloc « # Meshamoto » qui est réécrit automatiquement."
        )

    return (
        f"Le serveur « {nom_serveur} » se trouve sur VOTRE réseau local : ce nom "
        f"pointe vers votre propre adresse publique ({ip_publique}).\n\n"
        "La plupart des box ne savent pas renvoyer vers l'intérieur une connexion "
        "destinée à leur propre adresse publique, donc la tentative expire sans "
        "réponse. Ce n'est pas une panne du serveur : depuis l'extérieur, il "
        "répond normalement.\n\n" + conseil
    )


def est_echec_tls(exception: BaseException) -> bool:
    """
    Vrai si la connexion TCP a ABOUTI et que c'est la liaison sécurisée qui a
    échoué.

    Indispensable parce que **`requests.exceptions.SSLError` hérite de
    `ConnectionError`** : sans ce test, un échec TLS est indiscernable d'un port
    fermé, et le message affirme « rien ne répond » alors que le serveur a
    parfaitement répondu. Constaté le 2026-07-31 — le PC Windows établissait bien
    la connexion (`Test-NetConnection` à True) pendant que Meshamoto annonçait le
    contraire. Les deux causes mènent à des vérifications opposées : le pare-feu
    et le port d'un côté, l'horloge et les certificats de l'autre.
    """
    try:
        import requests
    except ImportError:  # pragma: no cover - requests est une dépendance du client
        return False
    return isinstance(exception, requests.exceptions.SSLError)


def expliquer_substitution_infructueuse(
    nom_serveur: str, adresse_lan: str, port: int, tls: bool, echec_tls: bool = False
) -> str:
    """
    Message quand la substitution d'adresse locale a été appliquée ET a échoué.

    Existe parce que redemander l'adresse dans ce cas produit une **boucle** :
    l'utilisateur ressaisit la même valeur, la même tentative échoue, la même
    fenêtre revient. Rencontré en conditions réelles le 2026-07-31 depuis un PC
    Windows.

    **Et l'adresse n'est presque jamais le vrai problème** : dans le cas mesuré,
    elle était juste, c'est le PORT qui ne l'était pas. Le serveur documenté est
    lié à `127.0.0.1` derrière un reverse proxy, donc son port applicatif (8765)
    n'est joignable depuis AUCUNE autre machine — seul le 443 du proxy l'est. Le
    message nomme donc le couple complet, et pointe la piste du schéma, qui est
    ce qui détermine le port quand on n'en écrit pas.

    `echec_tls` sépare le cas où la connexion a bel et bien été établie. Sans
    lui, le message dit « rien ne répond » d'un serveur qui a répondu, ce qui
    envoie vérifier le pare-feu et le port alors qu'il faut regarder l'horloge de
    la machine et les autorités de certification.
    """
    cible = f"{adresse_lan}:{port}"
    if echec_tls:
        return (
            f"La connexion à {cible} a bien été ÉTABLIE pour « {nom_serveur} », "
            "mais la liaison sécurisée (TLS) a été refusée.\n\n"
            "Le serveur répond donc : ni le port ni le pare-feu ne sont en cause. "
            "Les pistes, dans l'ordre :\n"
            "• l'horloge de cette machine est-elle à l'heure ? Un décalage de "
            "quelques heures suffit à faire rejeter un certificat valide ;\n"
            f"• le certificat est-il bien émis pour « {nom_serveur} » et non "
            "expiré ?\n"
            "• les certificats racines de cette machine sont-ils à jour ?"
        )

    message = (
        f"L'adresse locale {adresse_lan} a bien été essayée pour « {nom_serveur} », "
        f"sans succès : aucune connexion n'a pu être établie sur {cible}.\n\n"
        "L'adresse est probablement bonne — c'est le PORT qui est en cause."
    )
    if not tls and port != 443:
        message += (
            f"\n\nLe port {port} vient de ce que vous avez saisi dans le champ "
            "« Serveur ». Si le serveur est derrière un reverse proxy (le montage "
            "recommandé pour une exposition sur Internet), il n'écoute pas ce port "
            "sur le réseau : seul le proxy est joignable, en HTTPS.\n"
            f"Essayez « https://{nom_serveur} » sans numéro de port — le 443 en "
            "découle alors tout seul."
        )
    else:
        message += (
            f"\n\nVérifiez que le serveur écoute bien sur {cible} et qu'aucun "
            "pare-feu ne bloque ce port sur la machine qui l'héberge."
        )
    return message


def est_echec_de_connexion(exception: BaseException) -> bool:
    """
    Vrai si l'exception est un échec d'ÉTABLISSEMENT de connexion (timeout,
    refus, DNS), par opposition à une réponse du serveur (401, 409, 500...).

    La distinction compte : le diagnostic qui suit coûte plusieurs secondes
    (SSDP, STUN) et n'a aucun sens si le serveur a répondu — il a bien été
    joint, le problème est ailleurs.
    """
    try:
        import requests
    except ImportError:  # pragma: no cover - requests est une dépendance du client
        return False
    return isinstance(exception, (requests.exceptions.ConnectionError,
                                  requests.exceptions.Timeout))


def _resoudre(nom: str) -> Optional[str]:
    try:
        return socket.gethostbyname(nom)
    except (OSError, UnicodeError):
        return None


def _ip_publique() -> Optional[str]:
    """
    Notre adresse publique, par STUN puis par UPnP en repli.

    **STUN d'abord, et l'écart est massif : 37 ms contre 8,5 s** (mesuré sur la
    box de développement, un CODA-4680-FIZ). La découverte SSDP attend ses
    réponses puis va chercher la description XML de la passerelle ; l'UPnP n'est
    rapide que pour *réutiliser* une passerelle déjà connue, pas pour la
    trouver. Comme ce diagnostic s'ajoute aux dix secondes de timeout déjà
    subies, l'ordre inverse ferait attendre vingt secondes au total.

    STUN se lie au port **0** (éphémère) et non au port WireGuard : celui-ci est
    détenu en exclusivité dès que le tunnel tourne, et la découverte échouerait
    bruyamment pour rien (voir point 30).
    """
    try:
        import stun_client

        endpoint = stun_client.discover_public_endpoint(0)
        if endpoint:
            return endpoint[0]
    except Exception as exc:  # noqa: BLE001 - un diagnostic ne doit jamais planter
        logger.info("Adresse publique par STUN indisponible : %s", exc)

    # Repli utile quand l'UDP sortant est bloqué (pare-feu d'entreprise), cas où
    # STUN ne peut rien renvoyer alors que la box répond en UPnP.
    try:
        import upnp_client

        passerelle = upnp_client.discover()
        if passerelle is not None:
            return passerelle.external_ip() or None
    except Exception as exc:  # noqa: BLE001
        logger.info("Adresse publique par UPnP indisponible : %s", exc)
    return None


def diagnostiquer_serveur_injoignable(nom_serveur: str) -> Optional[str]:
    """
    Version « avec réseau » : à appeler UNIQUEMENT après un échec de connexion.

    Bloquant (DNS, SSDP, STUN) et donc réservé au thread worker. On ne le fait
    jamais en préventif : ce serait plusieurs secondes ajoutées à chaque
    connexion réussie pour un cas qui ne se produit qu'en cas de panne.
    """
    if not nom_serveur:
        return None
    ip_resolue = _resoudre(nom_serveur)
    if ip_resolue is None:
        return None

    from network_client import _detect_local_ip

    ip_locale = _detect_local_ip()

    # Une adresse privée ne peut pas être notre adresse publique : le cas 2 se
    # tranche avec la seule adresse locale, sans interroger STUN ni la box. Ça
    # évite plusieurs centaines de millisecondes, mais surtout ça reste fiable
    # là où la découverte d'adresse publique peut très bien échouer — sur un
    # partage de connexion mobile, justement.
    if _est_adresse_de_lan(ip_resolue):
        return expliquer_echec(nom_serveur, ip_resolue, None, ip_locale)

    return expliquer_echec(nom_serveur, ip_resolue, _ip_publique(), ip_locale)
