"""
Diagnostics de connectivité pair-à-pair : rend visibles, en langage clair, les causes
de panne déjà rencontrées en conditions réelles pendant le développement de ce projet
(pare-feu Windows qui bloque les connexions entrantes, ICMP bloqué par défaut sur
Windows même en profil "Privé", hairpin NAT peu fiable...) plutôt que de laisser
l'utilisateur deviner pourquoi "ça ne marche pas". Voir README, section "Limites
connues", pour le détail de chaque bug d'origine.

Aucune dépendance à la GUI : gui.py appelle ces fonctions et affiche les résultats.
"""

from __future__ import annotations

import socket
from dataclasses import dataclass
from typing import Optional

import ping_client
import tunnel_probe
import wireguard_manager

PORT_TEST_TIMEOUT_SECONDS = 3.0
STALE_HANDSHAKE_THRESHOLD_SECONDS = 180.0  # WireGuard renégocie normalement toutes les ~2 min


@dataclass
class DiagnosticResult:
    label: str
    ok: Optional[bool]  # True=OK, False=problème détecté, None=indéterminé/non applicable
    detail: str


def ping_failure_detail(peer_os: Optional[str]) -> str:
    """
    Conseil à afficher quand un ping échoue, adapté à l'OS du pair VISÉ.

    Le conseil "Windows ne répond pas à l'ICMP entrant" ne vaut que si la cible est une
    machine Windows. Affiché sans condition, il envoie sur une fausse piste quand la
    cible est un Linux — c'est exactement ce qui s'est produit le 2026-07-29, où un
    tunnel réellement mort (service WireGuard incapable de se lier à son port) a été
    diagnostiqué pendant deux heures comme un possible problème d'ICMP.

    `peer_os` vaut None si le pair tourne une version antérieure du client, qui ne
    signale pas son OS : on donne alors les deux cas de figure sans trancher.
    """
    if peer_os == "windows":
        return (
            "Pas de réponse. Ce pair tourne sous Windows, qui ne répond pas à l'ICMP "
            "entrant par défaut, même sur un profil \"Privé\" : si le handshake est "
            "récent, le tunnel est probablement sain et ce ping n'est pas concluant. "
            "Voir le conseil ci-dessous."
        )
    if peer_os == "linux":
        return (
            "Pas de réponse. Ce pair tourne sous Linux, qui répond à l'ICMP par défaut "
            "— contrairement à Windows, l'absence de réponse est donc un vrai signal "
            "de panne, pas un faux positif. Vérifiez de son côté : tunnel réellement "
            "monté, mode isolation, pare-feu local."
        )
    return (
        "Pas de réponse. L'OS de ce pair est inconnu (client d'une version "
        "antérieure). S'il tourne sous Windows, c'est peut-être normal : il ne répond "
        "pas à l'ICMP entrant par défaut. Sous Linux en revanche, l'ICMP passe par "
        "défaut et l'absence de réponse indique une vraie panne de son côté."
    )


def check_ping(virtual_ip: str, peer_os: Optional[str] = None) -> DiagnosticResult:
    """
    Vivacité d'un pair, par l'écho Meshamoto ET par l'ICMP.

    **Les DEUX sont tentés ici, contrairement au salon**, qui s'arrête au premier
    qui répond pour tenir son cycle de 5 s. Ce panneau est ouvert à la demande :
    la seconde supplémentaire achète la seule information qui compte quand ça va
    mal — savoir si le silence vient du pair ou seulement de son ICMP.

    C'est le cas mesuré le 2026-08-02 (point 67bis) : un pair parfaitement
    joignable, 1,9 Mo échangés, et le ping à 100 % de perte parce que son
    antivirus jette l'ICMP sans qu'aucun réglage administrateur n'y change rien.
    Sans cette distinction, le panneau accusait le tunnel.
    """
    echo = tunnel_probe.sonder(virtual_ip)
    latency = ping_client.ping(virtual_ip)

    if echo is not None and latency is None:
        # Le cas qui justifie tout ce chantier : vivant, mais muet à l'ICMP.
        return DiagnosticResult(
            "Ping",
            True,
            f"Répond à l'écho Meshamoto en {echo:.0f} ms, mais PAS au ping ICMP. "
            "Le tunnel fonctionne — c'est l'ICMP de ce pair qui est filtré, "
            "typiquement par un antivirus ou un pare-feu tiers. Rien à corriger "
            "de votre côté, et un `ping` manuel vers ce pair échouera toujours.",
        )
    if echo is not None:
        return DiagnosticResult("Ping", True, f"{echo:.0f} ms (écho Meshamoto)")
    if latency is not None:
        # Pas d'écho mais l'ICMP passe : très probablement un pair qui tourne un
        # client antérieur. Surtout pas un défaut à signaler.
        return DiagnosticResult("Ping", True, f"{latency:.0f} ms (ICMP)")
    return DiagnosticResult("Ping", False, ping_failure_detail(peer_os))


def check_handshake(public_key: str) -> DiagnosticResult:
    handshakes = wireguard_manager.get_latest_handshakes()
    if not handshakes:
        return DiagnosticResult(
            "Handshake WireGuard",
            None,
            "Indisponible sur cet OS sans wireguard-tools (normal sur Windows, "
            "voir wireguard_backend_windows.py).",
        )
    seconds_ago = handshakes.get(public_key)
    if seconds_ago is None:
        return DiagnosticResult(
            "Handshake WireGuard", False,
            "Aucun handshake établi avec ce pair — le tunnel n'a jamais réussi à "
            "négocier de session avec lui, pas seulement une coupure temporaire.",
        )
    if seconds_ago < STALE_HANDSHAKE_THRESHOLD_SECONDS:
        return DiagnosticResult(
            "Handshake WireGuard", True, f"Il y a {seconds_ago:.0f}s — récent, le tunnel est actif.",
        )
    return DiagnosticResult(
        "Handshake WireGuard", False,
        f"Il y a {seconds_ago:.0f}s — ancien, la connexion a probablement été perdue.",
    )


def check_port(virtual_ip: str, port: int, protocol: str = "tcp", timeout: float = PORT_TEST_TIMEOUT_SECONDS) -> DiagnosticResult:
    """
    Teste si un port applicatif précis répond chez ce pair (ex: le port d'un serveur
    de jeu). Complémentaire au ping : le tunnel peut fonctionner parfaitement tout en
    ayant le service lui-même arrêté, ou bloqué par le pare-feu local du pair.
    """
    label = f"Port {port}/{protocol}"
    if protocol == "tcp":
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(timeout)
        try:
            result = sock.connect_ex((virtual_ip, port))
        except OSError as exc:
            return DiagnosticResult(label, False, f"Erreur réseau : {exc}")
        finally:
            sock.close()
        if result == 0:
            return DiagnosticResult(label, True, "Ouvert et joignable.")
        return DiagnosticResult(
            label, False,
            "Fermé ou filtré. Vérifiez que le service tourne bien sur cette machine "
            "et que son pare-feu local l'autorise en entrant (voir mode isolation/ACL "
            "si activés côté hôte).",
        )

    # UDP : pas de handshake TCP, on ne peut confirmer l'ouverture qu'en cas de
    # réponse applicative — l'absence de réponse dans le délai n'est pas concluante
    # (beaucoup de services UDP ne répondent qu'à leur propre protocole).
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.settimeout(timeout)
    try:
        sock.sendto(b"", (virtual_ip, port))
        sock.recvfrom(1)
        return DiagnosticResult(label, True, "Une réponse a été reçue.")
    except socket.timeout:
        return DiagnosticResult(
            label, None,
            "Aucune réponse dans le délai imparti — pas forcément une panne pour de "
            "l'UDP sans protocole applicatif reconnu par ce test.",
        )
    except OSError as exc:
        return DiagnosticResult(label, False, f"Erreur réseau : {exc}")
    finally:
        sock.close()
