"""
Backend WireGuard pour Windows : pilote wireguard.exe, qui expose des sous-commandes
CLI cachées pour installer/désinstaller un tunnel en tant que service Windows (WireGuardNT).

IMPORTANT : nécessite des privilèges administrateur. Lancez le client depuis une invite
PowerShell/CMD "Exécuter en tant qu'administrateur", ou embarquez un manifeste UAC si
vous packagez l'app avec PyInstaller.
"""

from __future__ import annotations

import logging
import re
import subprocess
import time

import config
from wireguard_backend_common import (  # noqa: F401  (TunnelSpec : annotation de signature)
    TunnelSpec,
    parse_latest_handshakes,
    render_sync_conf,
)

logger = logging.getLogger("meshamoto-client.wireguard.windows")

# Code d'état d'un service Windows en marche (champ dwCurrentState de SERVICE_STATUS).
_SERVICE_STATE_RUNNING = 4

# Le driver WireGuardNT crée un adaptateur réseau virtuel au démarrage du service :
# quelques secondes sont normales, on ne conclut pas à l'échec avant.
_SERVICE_START_TIMEOUT_SECONDS = 10.0

# Ancrage sur le MNÉMONIQUE d'état (RUNNING, STOPPED...) et non sur le libellé du
# champ : ce dernier est traduit sur un Windows localisé ("ÉTAT" en français), alors
# que le mnémonique provient d'une table fixe de sc.exe et reste en anglais.
_SERVICE_STATE_PATTERN = re.compile(
    r"\b(\d+)\s+(?:STOPPED|START_PENDING|STOP_PENDING|RUNNING"
    r"|CONTINUE_PENDING|PAUSE_PENDING|PAUSED)\b"
)


def _parse_service_state(sc_query_output: str) -> int | None:
    """
    Extrait le code d'état numérique de la sortie de `sc query`.

    Retourne None si la sortie n'en contient aucun (service inexistant, sortie
    inattendue) — l'appelant traite alors le tunnel comme inactif. Logique pure,
    testable sans Windows ni service réel.
    """
    match = _SERVICE_STATE_PATTERN.search(sc_query_output)
    return int(match.group(1)) if match else None


def _query_service_state() -> int | None:
    """État courant du service du tunnel, ou None s'il n'existe pas."""
    result = subprocess.run(
        ["sc", "query", f"WireGuardTunnel${config.TUNNEL_NAME}"],
        capture_output=True, text=True, timeout=5,
        creationflags=subprocess.CREATE_NO_WINDOW,
    )
    if result.returncode != 0:
        return None
    return _parse_service_state(result.stdout)


def is_tunnel_active() -> bool:
    """
    Vrai si le service du tunnel existe ET tourne réellement. `sc query` ne nécessite
    pas de privilèges administrateur (contrairement à l'installation/désinstallation) —
    sert à éviter un appel `/uninstalltunnelservice` inutile (donc une invite UAC
    inutile) quand il n'y a rien à désinstaller.

    ATTENTION : `sc query` renvoie 0 dès lors que le service EXISTE, même arrêté. Se
    fier au seul code de retour faisait passer un tunnel mort pour actif, avec deux
    conséquences : `restart_tunnel_with_peers` refusait de le remonter (sa voie de
    secours "interface disparue" ne se déclenchait jamais sous Windows), et
    l'utilisateur voyait ses pairs et son IP virtuelle comme si tout allait bien alors
    que rien ne circulait. D'où la vérification de l'état, et pas seulement de
    l'existence.
    """
    return _query_service_state() == _SERVICE_STATE_RUNNING


# Échec ATTENDU et rattrapé, pas une panne : `/uninstalltunnelservice` rend la main
# avant que Windows ait fini de retirer le service, donc l'installation qui suit échoue
# le temps que ça se termine (voir `install_tunnel`, qui reprend). Le journaliser en
# ERROR faisait chercher une panne là où il n'y en a pas — trois lignes rouges pour un
# changement d'effectif ordinaire, observées le 2026-08-05 sur XEON.
_COURSE_ATTENDUE = "already installed and running"


def _run_wireguard_cli(*args: str) -> subprocess.CompletedProcess:
    exe = config.find_wireguard_exe()
    logger.info("Exécution : %s %s", exe, " ".join(args))
    # shell=False + liste d'arguments : évite les problèmes d'échappement de chemins Windows avec espaces
    result = subprocess.run(
        [str(exe), *args],
        capture_output=True,
        text=True,
        timeout=30,
        # Sans ça, Windows affiche brièvement une fenêtre console à chaque appel
        # depuis une app sans console (.exe empaqueté avec console=False).
        creationflags=subprocess.CREATE_NO_WINDOW,
    )
    if result.returncode != 0:
        # Le niveau dépend de la NATURE de l'échec, pas de son code de retour : la
        # course ci-dessus est normale et rattrapée, tout le reste est une vraie panne.
        niveau = logging.DEBUG if _COURSE_ATTENDUE in result.stderr else logging.ERROR
        logger.log(
            niveau, "wireguard.exe a échoué (code %d) : %s", result.returncode, result.stderr
        )
    return result


def _run_wg_cli(*args: str) -> subprocess.CompletedProcess | None:
    """
    Exécute `wg.exe`. Rend None s'il est introuvable — ce n'est pas une erreur.

    Distinct de `_run_wireguard_cli` : ce sont deux exécutables différents, et
    surtout l'absence de `wg.exe` doit dégrader proprement alors que celle de
    `wireguard.exe` est fatale.
    """
    exe = config.find_wg_exe()
    if exe is None:
        return None
    result = subprocess.run(
        [str(exe), *args],
        capture_output=True,
        text=True,
        timeout=30,
        # Même raison que pour wireguard.exe : sans ça une fenêtre console
        # apparaît brièvement à chaque appel depuis l'app empaquetée.
        creationflags=subprocess.CREATE_NO_WINDOW,
    )
    if result.returncode != 0:
        logger.info("wg.exe a échoué (code %d) : %s", result.returncode, result.stderr.strip())
    return result


def sync_tunnel(spec: "TunnelSpec | None" = None) -> bool:
    """
    Met à jour les pairs À CHAUD via `wg.exe syncconf`, sans détruire l'interface.

    **L'ASYMÉTRIE AVEC LINUX EST SUPPRIMÉE, et l'affirmation qu'elle était
    inévitable était fausse.** Cette fonction renvoyait `False` en dur, au motif
    que combler l'écart exigerait « `wireguard-tools` installé séparément, soit
    exactement la dépendance externe que le projet refuse ». Mesuré le 2026-08-09
    sur une machine du parc : `wg.exe` est livré PAR WireGuard for Windows, dans
    le même dossier que `wireguard.exe`, et ce dernier est déjà un prérequis dur
    du client Windows. Il n'y a donc aucune dépendance à ajouter — elle était là
    depuis le début. `wg.exe --help` liste bien `syncconf`, et `wg show` répond.

    **Ce que ça évite n'est PAS du confort local**, contrairement à ce qui était
    écrit ici. Sans mise à jour à chaud, Windows détruit et recrée sa carte
    réseau à **chaque arrivée ou départ de pair** — ce qui fait rejouer la
    détection de réseau de Windows et peut interrompre une session
    d'administration à distance sans aucun rapport avec Meshamoto (point 75bis,
    mesuré sur une session AnyDesk). Sur une machine pilotée à distance, c'est un
    risque opérationnel, pas une gêne.

    Aucune élévation à demander : le client Windows tourne déjà élevé
    (`uac_admin=True` élève le processus une fois au lancement), là où Linux doit
    passer par le composant privilégié ou `pkexec`.

    Le fichier temporaire contient la clé privée et est supprimé aussitôt. Il
    n'ajoute aucune exposition : `config.WIREGUARD_CONF_PATH` porte déjà cette
    même clé, dans ce même dossier, en permanence. Et le format dépouillé
    n'accepte aucune directive exécutable — voir `render_sync_conf`.

    Rend False dès que quoi que ce soit cloche : l'appelant retombe alors sur la
    réinstallation complète, c'est-à-dire exactement le comportement d'avant.
    Aucune régression possible sur une machine où ce chemin échouerait.
    """
    if spec is None:
        return False
    if config.find_wg_exe() is None:
        logger.info(
            "wg.exe introuvable à côté de wireguard.exe : pas de mise à jour à chaud, "
            "réinstallation complète (installation WireGuard trop ancienne ?)."
        )
        return False

    config.ensure_directories()
    chemin = config.WIREGUARD_CONF_PATH.with_name(f"{config.TUNNEL_NAME}-sync.conf")
    try:
        chemin.write_text(
            render_sync_conf(spec.private_key, spec.listen_port, spec.peers),
            encoding="utf-8", newline="\n",
        )
        result = _run_wg_cli("syncconf", config.TUNNEL_NAME, str(chemin))
    except OSError as exc:
        logger.info("Mise à jour à chaud impossible : %s", exc)
        return False
    finally:
        # Jamais laissé derrière : il porte la clé privée.
        try:
            chemin.unlink(missing_ok=True)
        except OSError:
            pass

    return result is not None and result.returncode == 0


def install_tunnel(spec: "TunnelSpec | None" = None) -> None:
    """
    Installe le tunnel comme service Windows à partir du fichier .conf déjà écrit.

    `spec` est accepté pour garder une signature commune avec le backend Linux, mais
    ignoré : `wireguard.exe /installtunnelservice` ne sait prendre qu'un chemin de
    fichier. Windows n'a de toute façon aucune invite à supprimer — `uac_admin=True`
    élève le processus une fois au lancement.

    /uninstalltunnelservice (appelé juste avant par restart_tunnel_with_peers) peut
    renvoyer un succès avant que Windows n'ait fini de retirer le service précédent —
    l'installation immédiatement après échoue alors avec "already installed and
    running" bien que ce ne soit qu'une question de timing. Recréer un adaptateur
    réseau virtuel (driver WireGuardNT) peut prendre plusieurs secondes, pas juste
    quelques centaines de ms : on retente donc pendant une fenêtre assez large avant
    d'abandonner.

    Un code de retour 0 signifie seulement que le SERVICE a été créé, pas qu'il
    tourne — d'où la vérification d'état qui suit (voir `_verify_service_started`).
    """
    result = None
    for attempt in range(15):
        result = _run_wireguard_cli("/installtunnelservice", str(config.WIREGUARD_CONF_PATH))
        if result.returncode == 0:
            _verify_service_started()
            return
        if _COURSE_ATTENDUE not in result.stderr:
            break
        time.sleep(1.0)

    raise RuntimeError(
        "Impossible d'installer le tunnel WireGuard. Vérifiez que le client tourne "
        "avec des droits administrateur. Détail : " + result.stderr
    )


def _wait_until_running(timeout_seconds: float) -> bool:
    """Attend que le service atteigne l'état RUNNING, au plus `timeout_seconds`."""
    deadline = time.monotonic() + timeout_seconds
    while True:
        if is_tunnel_active():
            return True
        if time.monotonic() >= deadline:
            return False
        time.sleep(0.5)


def _verify_service_started() -> None:
    """
    Vérifie que le service fraîchement installé tourne réellement.

    Cas réel rencontré le 2026-07-29 : quand le port UDP d'écoute est déjà détenu par
    un autre tunnel WireGuard (ici l'application WireGuard officielle), le service est
    installé avec succès puis meurt aussitôt, faute de pouvoir se lier au port. Sans
    cette vérification, le client rejoignait le serveur, affichait ses pairs et son IP
    virtuelle comme si tout allait bien, et seul un `tcpdump` sur l'autre machine
    révélait qu'aucun paquet ne partait jamais — le panneau de diagnostic orientait
    même vers une fausse piste ICMP.

    Le message d'erreur NOMME cette cause : c'est de loin la plus fréquente, et elle
    est invisible sans savoir où regarder.
    """
    if _wait_until_running(_SERVICE_START_TIMEOUT_SECONDS):
        return

    # Retirer le service mort : le laisser en place ferait échouer la prochaine
    # tentative d'installation sur un "already installed" trompeur.
    uninstall_tunnel()
    raise RuntimeError(
        "Le tunnel WireGuard a été installé mais son service ne démarre pas.\n\n"
        f"Cause la plus fréquente : le port UDP {config.WIREGUARD_LISTEN_PORT} est "
        "déjà utilisé par un autre tunnel WireGuard (application WireGuard "
        "officielle, client VPN d'entreprise...). Meshamoto ne peut pas partager ce "
        "port.\n\n"
        "Pour identifier le programme concerné, dans PowerShell :\n"
        "    Get-Process -Id (Get-NetUDPEndpoint -LocalPort "
        f"{config.WIREGUARD_LISTEN_PORT}).OwningProcess\n\n"
        "Désactivez le tunnel concerné, puis relancez Meshamoto."
    )


def uninstall_tunnel() -> None:
    """Désinstalle le tunnel (à appeler avant une réinstallation, ou à la fermeture de l'app)."""
    # /uninstalltunnelservice ne renvoie pas d'erreur bloquante si le tunnel n'existe pas déjà
    _run_wireguard_cli("/uninstalltunnelservice", config.TUNNEL_NAME)


def get_latest_handshakes() -> dict[str, float | None]:
    """
    Secondes écoulées depuis le dernier handshake de chaque pair (None si jamais).

    **Disponible sous Windows depuis le 2026-08-09**, contrairement à ce que cette
    fonction affirmait. Elle rendait un dict vide au motif que WireGuardNT n'expose
    rien « sans installer séparément `wireguard-tools` » ; c'était faux, `wg.exe` est
    livré avec WireGuard for Windows (voir `sync_tunnel` et `config.find_wg_exe`).
    Le point 71 avait déjà mis ce raisonnement en doute — il avait raison, mais la
    bonne réponse n'était pas d'atteindre l'UAPI nous-mêmes : son client était déjà
    installé sur toutes les machines.

    Rend un dict vide si `wg.exe` manque, et le panneau de diagnostic affiche alors la
    vérification comme « indisponible » — exactement comme avant sur ces machines-là.
    """
    result = _run_wg_cli("show", config.TUNNEL_NAME, "latest-handshakes")
    if result is None or result.returncode != 0:
        return {}
    return parse_latest_handshakes(result.stdout, time.time())
