"""
Diagnostic de joignabilité d'un serveur de signalisation auto-hébergé.

**Diagnostiquer plutôt qu'automatiser** (CLAUDE.md point 1ter-b). L'UPnP est le
bon outil pour un client — un bail court, renouvelé par un processus vivant —
et le mauvais pour un serveur, qui veut une redirection permanente : le bail
expire, et il a été mesuré sur le CODA-4680-FIZ que la box n'honore même pas la
durée demandée. Une règle statique saisie dans la box est strictement meilleure.
Ce module se contente donc de **constater** et d'expliquer quoi faire.

Indépendant de l'OS : que des sockets et du HTTP. La logique de verdict est
séparée des appels réseau (fonctions `evaluer_*`), donc testable sans réseau.
"""

from __future__ import annotations

import logging
import socket
from dataclasses import dataclass

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

# Trois niveaux plutôt que deux : beaucoup de constats ne sont ni bons ni
# mauvais en soi (une IP publique non vérifiable n'est pas une panne), et les
# afficher en rouge apprendrait à l'utilisateur à ignorer les rouges.
OK = "ok"
AVERTISSEMENT = "avertissement"
PROBLEME = "probleme"


@dataclass(frozen=True)
class Constat:
    """Une ligne du panneau de diagnostic, affichable telle quelle."""

    libelle: str
    niveau: str
    detail: str


def evaluer_ecoute_locale(port: int, ouvert: bool) -> Constat:
    """Le serveur répond-il sur la machine elle-même ?"""
    if ouvert:
        return Constat(
            libelle="Le serveur répond localement",
            niveau=OK,
            detail=f"Un service écoute bien sur le port {port} de cette machine.",
        )
    return Constat(
        libelle="Le serveur ne répond pas localement",
        niveau=PROBLEME,
        detail=(
            f"Rien n'écoute sur le port {port}. Démarrez le serveur avant de vérifier "
            "le reste : tout ce qui suit dépend de lui."
        ),
    )


def evaluer_redirection(port: int, ip_publique: str | None, redirection_connue: bool) -> Constat:
    """
    La box laisse-t-elle entrer le port depuis Internet ?

    Volontairement prudent : l'absence de redirection UPnP déclarée ne prouve
    PAS qu'il n'y en a pas — une règle statique saisie à la main dans la box
    (précisément ce qu'on recommande pour un serveur) est invisible à cette
    interrogation. Annoncer « pas de redirection » serait donc faux dans le cas
    le mieux configuré, ce qui est le pire moment pour se tromper.
    """
    if redirection_connue:
        return Constat(
            libelle="Redirection de port détectée",
            niveau=OK,
            detail=f"La box redirige bien le port {port} vers cette machine.",
        )
    if ip_publique is None:
        return Constat(
            libelle="Redirection de port invérifiable",
            niveau=AVERTISSEMENT,
            detail=(
                "Impossible d'interroger la box (UPnP absent ou désactivé). Vérifiez "
                f"manuellement qu'une règle redirige le port {port} vers cette machine."
            ),
        )
    return Constat(
        libelle="Aucune redirection UPnP déclarée",
        niveau=AVERTISSEMENT,
        detail=(
            f"La box n'annonce pas de redirection pour le port {port}. Si vous en avez "
            "créé une à la main, c'est normal : les règles statiques ne sont pas "
            "visibles ici. Sinon, ajoutez-la dans l'interface de votre box."
        ),
    )


def evaluer_nom_de_domaine(nom: str, ip_resolue: str | None, ip_publique: str | None) -> Constat:
    """
    Le nom de domaine pointe-t-il vraiment sur cette connexion ?

    C'est la panne la plus silencieuse de l'auto-hébergement : le serveur
    tourne, la redirection existe, et le nom pointe sur une ancienne adresse.
    Rien n'échoue visiblement, plus personne ne se connecte.
    """
    if not nom:
        return Constat(
            libelle="Aucun nom de domaine",
            niveau=AVERTISSEMENT,
            detail=(
                "Vos amis devront saisir votre adresse IP publique, qui peut changer. "
                "Un nom gratuit (DuckDNS par exemple) évite ce problème."
            ),
        )
    if ip_resolue is None:
        return Constat(
            libelle=f"{nom} ne résout pas",
            niveau=PROBLEME,
            detail="Ce nom ne renvoie aucune adresse. Vérifiez sa configuration.",
        )
    if ip_publique is None:
        return Constat(
            libelle=f"{nom} résout vers {ip_resolue}",
            niveau=AVERTISSEMENT,
            detail=(
                "Votre adresse publique n'a pas pu être déterminée : impossible de "
                "confirmer que le nom pointe bien sur cette connexion."
            ),
        )
    if ip_resolue == ip_publique:
        return Constat(
            libelle=f"{nom} pointe bien ici",
            niveau=OK,
            detail=f"Le nom résout vers {ip_resolue}, votre adresse publique actuelle.",
        )
    return Constat(
        libelle=f"{nom} pointe ailleurs",
        niveau=PROBLEME,
        detail=(
            f"Le nom résout vers {ip_resolue} alors que votre adresse publique est "
            f"{ip_publique}. Personne ne joindra votre serveur tant que ce sera le cas."
        ),
    )


def evaluer_acces_depuis_le_lan(ip_locale: str | None, port: int, nom: str = "") -> Constat:
    """
    Prévient que les machines du MÊME réseau ne pourront pas passer par
    l'adresse publique.

    Ce constat existe parce que le cas s'est réellement produit : un client
    Windows du même LAN a échoué sur un `ConnectTimeoutError` de dix secondes,
    sans le moindre indice. La box doit renvoyer vers l'intérieur une connexion
    destinée à sa propre adresse publique (« hairpin NAT ») et la plupart ne le
    font pas — c'est le premier obstacle de l'auto-hébergement, et il frappe
    précisément quand on teste son installation soi-même.

    Toujours affiché en information, jamais en erreur : rien n'est cassé, c'est
    une propriété normale des réseaux domestiques.
    """
    if ip_locale is None:
        return Constat(
            libelle="Accès depuis votre propre réseau",
            niveau=AVERTISSEMENT,
            detail=(
                "Les machines de votre réseau local devront viser l'adresse locale de "
                "cette machine, pas l'adresse publique : la plupart des box ne "
                "renvoient pas vers l'intérieur une connexion destinée à leur propre "
                "adresse publique."
            ),
        )
    ligne_hosts = f"    {ip_locale}    {nom}" if nom else ""
    return Constat(
        libelle="Accès depuis votre propre réseau",
        niveau=AVERTISSEMENT,
        detail=(
            f"Vos amis sur Internet utilisent l'adresse publique, mais les machines de "
            f"VOTRE réseau doivent viser {ip_locale}:{port} — la plupart des box ne "
            f"renvoient pas vers l'intérieur une connexion destinée à leur propre "
            f"adresse publique, et la tentative expire sans message utile."
            + (
                f"\n    Pour garder le même nom (et donc le certificat), ajoutez cette "
                f"ligne au fichier hosts de la machine cliente, en dehors du bloc "
                f"« # Meshamoto » qui est réécrit automatiquement :\n{ligne_hosts}"
                if nom else ""
            )
        ),
    )


def adresse_locale() -> str | None:
    """
    Adresse de cette machine sur le réseau local.

    Réutilise la détection déjà éprouvée du client (socket UDP non connecté vers
    une adresse externe, aucun paquet émis) plutôt que d'en écrire une seconde.
    """
    try:
        from network_client import _detect_local_ip

        return _detect_local_ip()
    except Exception as exc:  # noqa: BLE001 - un diagnostic ne doit jamais planter
        logger.info("Adresse locale indéterminable : %s", exc)
        return None


def port_en_ecoute(port: int, hote: str = "127.0.0.1", timeout: float = 2.0) -> bool:
    """Vrai si quelque chose accepte une connexion TCP sur ce port localement."""
    try:
        with socket.create_connection((hote, port), timeout=timeout):
            return True
    except OSError:
        return False


def resoudre(nom: str) -> str | None:
    """Résout un nom en adresse IPv4, ou None. Ne lève jamais."""
    if not nom:
        return None
    try:
        return socket.gethostbyname(nom)
    except (OSError, UnicodeError):
        return None


def _ip_publique_et_redirection(port: int) -> tuple[str | None, bool]:
    """
    Interroge la box en UPnP : adresse publique et présence d'une redirection.

    Aucune modification n'est faite ici — pas d'`AddPortMapping`. Ce panneau
    diagnostique un serveur, et un serveur veut une règle permanente que l'UPnP
    ne sait pas donner.
    """
    try:
        import upnp_client
    except ImportError:  # pragma: no cover - le module fait partie du client
        return (None, False)

    try:
        passerelle = upnp_client.discover()
    except Exception as exc:  # noqa: BLE001 - un diagnostic ne doit jamais planter
        logger.info("Découverte UPnP indisponible : %s", exc)
        return (None, False)
    if passerelle is None:
        return (None, False)

    try:
        ip_publique = passerelle.external_ip()
    except Exception as exc:  # noqa: BLE001
        logger.info("Adresse publique UPnP indisponible : %s", exc)
        ip_publique = None

    redirection = False
    verifier = getattr(passerelle, "get_port_mapping", None)
    if callable(verifier):
        try:
            redirection = bool(verifier(port, "TCP"))
        except Exception as exc:  # noqa: BLE001
            logger.info("Vérification de redirection indisponible : %s", exc)
    return (ip_publique, redirection)


def diagnostiquer(port: int, nom_de_domaine: str = "") -> list[Constat]:
    """
    Enchaîne les vérifications et renvoie les constats prêts à afficher.

    Appel bloquant (sockets, SSDP, DNS) : à exécuter dans un thread worker,
    jamais dans le thread graphique.
    """
    constats = [evaluer_ecoute_locale(port, port_en_ecoute(port))]

    ip_publique, redirection = _ip_publique_et_redirection(port)
    constats.append(evaluer_redirection(port, ip_publique, redirection))
    constats.append(evaluer_nom_de_domaine(nom_de_domaine, resoudre(nom_de_domaine), ip_publique))
    constats.append(evaluer_acces_depuis_le_lan(adresse_locale(), port, nom_de_domaine))
    return constats
