"""
Mode isolation — Windows : restreint les connexions ENTRANTES non sollicitées au
seul réseau virtuel Meshamoto, via deux règles netsh advfirewall :

1. Une règle ALLOW scoped sur le CIDR virtuel (ex: 25.0.0.0/8) — les pairs du réseau
   peuvent toujours t'initier une connexion (partage de fichiers, jeu hébergé, etc.)
2. Deux règles BLOCK scopées sur le COMPLÉMENT de ce CIDR (tout le reste d'IPv4) —
   bloquent les connexions entrantes non sollicitées depuis n'importe quelle autre IP.
   Scindées en TCP / UDP pour pouvoir exclure le port d'écoute WireGuard de la portée
   UDP, sans quoi le mode isolation casserait le tunnel lui-même (voir _ports_excluding).

Pourquoi le complément et pas un simple "Block all" ? Parce que Windows Firewall donne
toujours priorité aux règles BLOCK sur les règles ALLOW, quel que soit l'ordre de
création. Un "Block all" bloquerait donc aussi le trafic du réseau virtuel qu'on veut
justement autoriser. En scopant le BLOCK sur le complément exact (calculé via le module
ipaddress), la règle ALLOW du réseau virtuel n'est jamais court-circuitée.

ACLs par pair (optionnel, voir acl_rules.py) : BLOCK bat toujours ALLOW sous Windows,
peu importe la spécificité — on ne peut donc PAS restreindre un pair en ajoutant une
règle BLOCK scopée à lui (ça bloquerait aussi les ports qu'on veut justement lui
laisser). La seule approche qui fonctionne : EXCLURE ce pair de la règle ALLOW
générale du réseau virtuel (même technique de complément que pour le BLOCK global),
puis ajouter une règle ALLOW dédiée, scopée à son IP ET aux ports autorisés
uniquement. Ses autres ports ne correspondent alors à aucune règle ALLOW et tombent
sur le comportement par défaut de Windows Firewall pour le trafic entrant (Bloqué par
défaut sur les profils Domaine/Privé/Public standards).

IMPORTANT : le trafic SORTANT n'est jamais touché. Windows Firewall est stateful : les
réponses à une connexion que TU as initiée (navigation web, etc.) restent toujours
autorisées, peu importe ces règles entrantes. Seules les connexions entrantes
NOUVELLES et non sollicitées sont concernées.

Nécessite des privilèges administrateur (comme le reste de la gestion WireGuard).
"""

from __future__ import annotations

import ipaddress
import json
import logging
import subprocess
import sys

import config

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

_ALLOW_RULE_NAME = "MeshamotoIsolation-Allow"
_BLOCK_RULE_NAME = "MeshamotoIsolation-Block"  # UDP, sauf le port WireGuard
_BLOCK_TCP_RULE_NAME = "MeshamotoIsolation-BlockTcp"
_BLOCK_ICMP_RULE_PREFIX = "MeshamotoIsolation-BlockIcmp"
_ACL_RULE_PREFIX = "MeshamotoACL"

# Règle d'autorisation de NOTRE PROPRE exécutable — voir `autoriser_application`.
# Indépendante du mode isolation : elle n'est ni créée ni retirée par
# enable/disable_isolation, et son nom ne figure dans aucune de leurs listes de
# suppression.
_APP_ALLOW_RULE_NAME = "Meshamoto-AutoriserApplication"

# Types ICMPv4 de SONDAGE non sollicité, bloqués depuis le complément du réseau
# virtuel : Echo Request (ping), Timestamp Request, Address Mask Request. Scinder la
# règle BLOCK en TCP/UDP pour exempter le port WireGuard avait laissé l'ICMP sortir de
# sa portée : la machine redevenait "pingable" depuis le LAN alors que l'isolation
# était censée l'en couper (constaté en conditions réelles le 2026-07-29 — d'autant
# plus visible que le README recommande d'ajouter une règle "ICMP Allow incoming" pour
# le diagnostic du tunnel, laquelle s'appliquait alors sans contrepoids).
#
# Les types d'ERREUR (3 Destination Unreachable, 11 Time Exceeded) sont volontairement
# laissés passer : ce sont des réponses à NOTRE trafic sortant, et bloquer le type 3
# code 4 casse la découverte de MTU (connexions qui s'établissent puis se figent sur
# les gros paquets). L'ancienne règle `protocol=any` les bloquait, elle ; le mode
# isolation est donc désormais à la fois plus étanche sur les sondages et moins
# cassant sur le fonctionnement normal du réseau.
_BLOCKED_ICMP_TYPES = (8, 13, 17)


def _icmp_rule_name(icmp_type: int) -> str:
    return f"{_BLOCK_ICMP_RULE_PREFIX}-{icmp_type}"


def _ports_excluding(excluded_port: int) -> str:
    """
    Plage `localport` netsh couvrant tous les ports SAUF `excluded_port`.

    Sert à laisser passer le port d'écoute WireGuard : ses paquets entrants portent
    l'adresse PHYSIQUE du pair (LAN ou publique), jamais son adresse virtuelle, donc la
    règle ALLOW du réseau virtuel ne les couvre jamais et la règle BLOCK du complément
    les jette. Comme BLOCK bat toujours ALLOW sous Windows (voir l'en-tête du module),
    ajouter une règle ALLOW pour ce port ne servirait à rien : il faut l'exclure de la
    portée du BLOCK lui-même. Logique pure, testable sans netsh.
    """
    ranges = []
    if excluded_port > 1:
        ranges.append(f"1-{excluded_port - 1}" if excluded_port > 2 else "1")
    if excluded_port < 65535:
        ranges.append(f"{excluded_port + 1}-65535" if excluded_port < 65534 else "65535")
    return ",".join(ranges)


def _acl_rule_name(peer_ip: str, protocol: str) -> str:
    return f"{_ACL_RULE_PREFIX}-{peer_ip.replace('.', '-')}-{protocol}"


def _run_netsh(*args: str, journaliser_echec: bool = True) -> subprocess.CompletedProcess:
    """
    Appelle netsh. `journaliser_echec=False` pour les suppressions dont l'absence
    de cible est un cas NORMAL (suppression préalable d'une règle idempotente) :
    netsh renvoie un code non nul quand la règle n'existe pas, et le journaliser
    en ERROR à chaque lancement ferait passer le fonctionnement nominal pour une
    panne.
    """
    result = subprocess.run(
        ["netsh", *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 and journaliser_echec:
        logger.error("netsh a échoué (code %d) : %s", result.returncode, result.stderr or result.stdout)
    return result


def _complement_cidrs(virtual_cidr: str) -> list[str]:
    """Calcule les blocs CIDR couvrant tout IPv4 SAUF le réseau virtuel donné."""
    full_range = ipaddress.ip_network("0.0.0.0/0")
    virtual_net = ipaddress.ip_network(virtual_cidr)
    return [str(net) for net in full_range.address_exclude(virtual_net)]


def _remaining_cidrs_excluding_peers(virtual_cidr: str, restricted_ips: list[str]) -> list[str]:
    """Calcule les blocs CIDR couvrant `virtual_cidr` SAUF les IP listées (pairs avec
    une ACL active) — logique pure, testable sans privilèges."""
    remaining = [ipaddress.ip_network(virtual_cidr)]
    for ip in restricted_ips:
        host = ipaddress.ip_network(f"{ip}/32")
        next_remaining = []
        for net in remaining:
            if host.subnet_of(net):
                next_remaining.extend(net.address_exclude(host))
            else:
                next_remaining.append(net)
        remaining = next_remaining
    return [str(net) for net in remaining]


def enable_isolation(virtual_cidr: str, peer_acls: dict[str, list[int]] | None = None) -> None:
    """
    Active le mode isolation : seules les connexions entrantes depuis `virtual_cidr`
    (le réseau virtuel Meshamoto) seront acceptées ; tout le reste est bloqué en
    entrant. Les pairs listés dans `peer_acls` (IP virtuelle -> ports) sont restreints
    à ces ports uniquement plutôt qu'à tout le réseau virtuel.
    """
    peer_acls = {ip: ports for ip, ports in (peer_acls or {}).items() if ports}
    # On nettoie d'abord d'éventuelles règles précédentes (idempotent, évite les doublons)
    disable_isolation(peer_ips=list(peer_acls.keys()))

    remaining_cidrs = _remaining_cidrs_excluding_peers(virtual_cidr, list(peer_acls.keys()))
    complement = _complement_cidrs(virtual_cidr)

    allow_result = _run_netsh(
        "advfirewall", "firewall", "add", "rule",
        f"name={_ALLOW_RULE_NAME}", "dir=in", "action=allow",
        f"remoteip={','.join(remaining_cidrs)}", "profile=any", "enable=yes",
    )
    if allow_result.returncode != 0:
        raise RuntimeError(
            "Impossible de créer la règle d'autorisation du pare-feu. "
            "Vérifiez les droits administrateur. Détail : " + allow_result.stderr
        )

    for peer_ip, ports in peer_acls.items():
        port_list = ",".join(str(p) for p in ports)
        for protocol in ("tcp", "udp"):
            acl_result = _run_netsh(
                "advfirewall", "firewall", "add", "rule",
                f"name={_acl_rule_name(peer_ip, protocol)}", "dir=in", "action=allow",
                f"remoteip={peer_ip}", f"localport={port_list}", f"protocol={protocol}",
                "profile=any", "enable=yes",
            )
            if acl_result.returncode != 0:
                _run_netsh("advfirewall", "firewall", "delete", "rule", f"name={_ALLOW_RULE_NAME}")
                disable_isolation(peer_ips=list(peer_acls.keys()))
                raise RuntimeError(
                    f"Impossible de créer la règle ACL pour {peer_ip} ({protocol}). "
                    "Vérifiez les droits administrateur. Détail : " + acl_result.stderr
                )

    # Le blocage est scindé en TCP + UDP (au lieu d'une seule règle tous protocoles)
    # uniquement pour pouvoir exclure le port WireGuard de la portée UDP — voir
    # _ports_excluding. Conséquence assumée : les protocoles autres que TCP/UDP (ICMP,
    # GRE...) ne sont plus couverts par cette règle et retombent sur le comportement
    # par défaut de Windows Firewall en entrant (bloqué par défaut sur les profils
    # standards) — exactement le même repli que celui déjà utilisé pour les ports non
    # autorisés d'un pair sous ACL, décrit en en-tête du module.
    blocks = [
        (_BLOCK_TCP_RULE_NAME, "tcp", None),
        (_BLOCK_RULE_NAME, "udp", _ports_excluding(config.WIREGUARD_LISTEN_PORT)),
    ]
    # Un type ICMP par règle : netsh n'accepte pas de liste de types dans un seul
    # `protocol=`. Voir _BLOCKED_ICMP_TYPES pour le choix des types.
    blocks += [
        (_icmp_rule_name(icmp_type), f"icmpv4:{icmp_type},any", None)
        for icmp_type in _BLOCKED_ICMP_TYPES
    ]
    for rule_name, protocol, local_ports in blocks:
        args = [
            "advfirewall", "firewall", "add", "rule",
            f"name={rule_name}", "dir=in", "action=block",
            f"remoteip={','.join(complement)}", f"protocol={protocol}",
            "profile=any", "enable=yes",
        ]
        if local_ports is not None:
            args.append(f"localport={local_ports}")
        block_result = _run_netsh(*args)
        if block_result.returncode != 0:
            # On retire tout ce qui a déjà été créé pour ne pas laisser un état à moitié configuré
            disable_isolation(peer_ips=list(peer_acls.keys()))
            raise RuntimeError(
                f"Impossible de créer la règle de blocage {protocol.upper()} du pare-feu. "
                "Vérifiez les droits administrateur. Détail : " + block_result.stderr
            )

    logger.info(
        "Mode isolation activé (réseau virtuel autorisé : %s, %d pair(s) avec ACL)",
        virtual_cidr, len(peer_acls),
    )


def disable_isolation(peer_ips: list[str] | None = None) -> None:
    """Retire les règles de pare-feu du mode isolation, y compris les règles ACL des
    pairs listés dans `peer_ips`. Ne touche à aucune autre règle existante."""
    _run_netsh("advfirewall", "firewall", "delete", "rule", f"name={_ALLOW_RULE_NAME}")
    # _BLOCK_RULE_NAME a gardé son nom historique : la suppression par nom retire donc
    # aussi l'ancienne règle tous-protocoles laissée par une version antérieure.
    _run_netsh("advfirewall", "firewall", "delete", "rule", f"name={_BLOCK_RULE_NAME}")
    _run_netsh("advfirewall", "firewall", "delete", "rule", f"name={_BLOCK_TCP_RULE_NAME}")
    for icmp_type in _BLOCKED_ICMP_TYPES:
        _run_netsh("advfirewall", "firewall", "delete", "rule", f"name={_icmp_rule_name(icmp_type)}")
    for peer_ip in peer_ips or []:
        for protocol in ("tcp", "udp"):
            _run_netsh("advfirewall", "firewall", "delete", "rule", f"name={_acl_rule_name(peer_ip, protocol)}")
    logger.info("Mode isolation désactivé")


# ---------------------------------------------------------------------------
# Autorisation de notre propre exécutable (indépendant du mode isolation)
# ---------------------------------------------------------------------------
#
# **Le problème, mesuré sur trois machines les 2026-08-02 et 2026-08-03.**
# Windows classe le réseau Meshamoto en « Public » (constaté sur les trois
# postes du parc, l'interface étant recréée à chaque changement d'effectif —
# d'où les suffixes croissants « meshamoto 3 », « meshamoto 7 »). Or l'alerte de
# sécurité qui s'affiche au premier bind d'un programme propose « Réseaux
# privés » cochée et « Réseaux publics » DÉCOCHÉE : cliquer « Autoriser
# l'accès » crée un `Allow` pour le profil coché ET un `Block` pour le profil
# décoché. L'utilisateur autorise donc l'application et la bloque là où ça
# compte, en un seul clic, sans jamais l'apprendre.
#
#     Machine     Réseau physique   Réseau Meshamoto   Règles                        Écho
#     XEON        Ethernet Private  Public             Allow Private + Block Public  muet
#     PC-QUEBEC   Wi-Fi Public      Public             Allow Public, aucun Block     répond
#     PC-BELL     Ethernet Public   Public             aucun Block                   répond
#
# Une seule variable explique les trois lignes : la catégorie du réseau
# PHYSIQUE actif au moment de l'alerte décide de la case pré-cochée. Conséquence
# contre-intuitive : ce sont les machines les MIEUX configurées qui cassent,
# déclarer son réseau domestique « privé » étant ce que Windows recommande.
#
# **On supprime la cause, pas l'effet.** Poser nous-mêmes une règle ALLOW sur
# notre exécutable, profil Public inclus, fait d'une pierre deux coups : l'écho
# de vivacité passe, et l'alerte ne s'affiche jamais (Windows ne la montre que
# si aucune règle ne couvre le programme), donc aucun `Block` n'est fabriqué.
#
# **Ce qui reste hors de portée** : sur une machine DÉJÀ dans le mauvais état,
# ajouter un ALLOW ne sert à rien — BLOCK bat toujours ALLOW (voir l'en-tête du
# module). D'où `blocages_applicatifs`, qui CONSTATE et nomme, sans jamais
# supprimer d'autorité : c'est la règle du projet pour tout ce qu'un autre
# logiciel ou l'utilisateur a posé (voir aussi `ufw` dans tunnel_probe).


def _chemin_executable() -> str | None:
    """
    Chemin de l'exécutable Meshamoto empaqueté, ou None hors empaquetage.

    **Le None n'est pas un cas dégradé, c'est un refus de sécurité.** Hors
    empaquetage (lancement depuis les sources), `sys.executable` désigne
    `python.exe` : poser une règle d'autorisation dessus ouvrirait le pare-feu
    entrant à TOUT programme Python de la machine, bien au-delà de Meshamoto.
    Le cas des sources est de toute façon celui du développeur, qui sait ouvrir
    son pare-feu.
    """
    if not getattr(sys, "frozen", False):
        return None
    return sys.executable


def autoriser_application(virtual_cidr: str) -> bool:
    """
    Autorise notre propre exécutable à recevoir des connexions entrantes DEPUIS
    LE RÉSEAU VIRTUEL, sur tous les profils réseau — voir le commentaire de
    section ci-dessus pour le pourquoi.

    Portée volontairement étroite sur deux axes : la règle vise notre seul
    binaire, et `remoteip` la limite à la plage virtuelle. Elle n'expose donc
    rien au LAN physique ni à Internet, ce qui est la même défense en profondeur
    que la liaison de l'écho à l'IP virtuelle plutôt qu'à `0.0.0.0`.

    Idempotent (suppression préalable par nom) : le chemin de l'exécutable
    change avec une réinstallation ou un renommage, et une règle qui pointerait
    l'ancien chemin serait silencieusement sans effet.

    **Jamais retirée à la fermeture**, contrairement aux règles d'isolation :
    son intérêt principal est justement d'exister AVANT le prochain bind, pour
    que l'alerte ne se montre pas. La retirer relancerait le cycle qu'on
    supprime.

    Renvoie True si la règle est en place. Un échec n'est jamais fatal :
    l'appelant continue sans, et le pire cas est le comportement d'avant.
    """
    chemin = _chemin_executable()
    if chemin is None:
        logger.debug("Lancement hors empaquetage : aucune règle applicative posée")
        return False

    _run_netsh(
        "advfirewall", "firewall", "delete", "rule", f"name={_APP_ALLOW_RULE_NAME}",
        journaliser_echec=False,
    )
    resultat = _run_netsh(
        "advfirewall", "firewall", "add", "rule",
        f"name={_APP_ALLOW_RULE_NAME}", "dir=in", "action=allow",
        f"program={chemin}", f"remoteip={virtual_cidr}",
        "profile=any", "enable=yes",
    )
    if resultat.returncode != 0:
        logger.warning(
            "Impossible d'autoriser %s dans le pare-feu Windows : vos pairs pourraient "
            "vous voir « injoignable » alors que le tunnel fonctionne.", chemin,
        )
        return False
    logger.info(
        "Application autorisée dans le pare-feu Windows (entrant depuis %s, tous profils)",
        virtual_cidr,
    )
    return True


# Interrogation des règles BLOCK visant un programme.
#
# **PowerShell et pas netsh, pour deux raisons.** netsh ne sait pas filtrer sur
# le programme d'une règle — et chercher un blocage par PORT ne trouve RIEN, une
# règle applicative n'ayant aucun filtre de port (mesuré le 2026-08-02, c'est ce
# qui a fait chercher au mauvais endroit). Et surtout, la sortie d'affichage de
# netsh est TRADUITE : la règle du projet est de ne jamais analyser ce qu'un
# outil Windows affiche, mais le format qu'il produit pour être relu (JSON ici),
# après `sc query` au point 32, la sortie de `ping` au point 34 et `schtasks` au
# point 45. Les noms de propriétés PowerShell et les noms d'énumération, eux, ne
# sont pas localisés.
_REQUETE_BLOCAGES = (
    "Get-NetFirewallRule -Direction Inbound -Action Block -Enabled True | "
    "ForEach-Object { $p = ($_ | Get-NetFirewallApplicationFilter).Program; "
    "if ($p) { [pscustomobject]@{ Nom = $_.Name; Affichage = $_.DisplayName; "
    "Profil = [string]$_.Profile; Programme = $p } } } | ConvertTo-Json -Compress"
)


def _blocages_visant(sortie_json: str, chemin_programme: str) -> list[dict]:
    """
    Règles BLOCK de `sortie_json` qui visent `chemin_programme`. Logique pure,
    testable sans Windows ni pare-feu.

    Comparaison insensible à la casse : Windows conserve le chemin tel qu'il a
    été saisi au moment de la création de la règle, dont la casse ne suit pas
    forcément celle du système de fichiers.

    Une sortie vide, une sortie illisible ou un objet unique (ConvertTo-Json ne
    produit un tableau qu'à partir de deux éléments — piège classique) sont tous
    traités sans lever : cette fonction sert un DIAGNOSTIC, elle ne doit jamais
    faire échouer le démarrage de l'écho.
    """
    texte = (sortie_json or "").strip()
    if not texte:
        return []
    try:
        charge = json.loads(texte)
    except json.JSONDecodeError:
        return []
    if isinstance(charge, dict):
        charge = [charge]
    if not isinstance(charge, list):
        return []
    cible = chemin_programme.casefold()
    return [
        regle for regle in charge
        if isinstance(regle, dict) and str(regle.get("Programme", "")).casefold() == cible
    ]


def blocages_applicatifs() -> list[dict]:
    """
    Règles BLOCK entrantes ACTIVES visant notre propre exécutable.

    Une liste non vide explique à elle seule qu'un pair nous voie
    « injoignable » alors que le tunnel fonctionne : BLOCK bat ALLOW quelle que
    soit la spécificité, donc la règle posée par `autoriser_application` est
    court-circuitée. Ces règles ne viennent de personne en particulier — Windows
    les fabrique tout seul (voir le commentaire de section) — et elles
    persistent indéfiniment, y compris après une réinstallation.

    Diagnostic uniquement : rien n'est supprimé ici, jamais.
    """
    chemin = _chemin_executable()
    if chemin is None:
        return []
    try:
        resultat = subprocess.run(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", _REQUETE_BLOCAGES],
            capture_output=True,
            text=True,
            timeout=30,
            creationflags=subprocess.CREATE_NO_WINDOW,
        )
    except (OSError, subprocess.SubprocessError):
        return []
    if resultat.returncode != 0:
        return []
    return _blocages_visant(resultat.stdout, chemin)


def commande_retrait_a_suggerer(blocages: list[dict]) -> str:
    """
    Commande PowerShell à SUGGÉRER pour retirer les règles trouvées.

    Suggérée, jamais exécutée : ces règles ont été créées hors de Meshamoto, et
    en supprimer d'autorité reviendrait à défaire un choix de sécurité qu'on n'a
    pas posé. Même règle que pour `ufw` côté Linux.

    Ciblée par NOM de règle exact — jamais un filtre large, qui emporterait des
    règles sans rapport si la sélection est plus grosse que prévu.
    """
    noms = [str(regle.get("Nom", "")) for regle in blocages if regle.get("Nom")]
    if not noms:
        return ""
    liste = ",".join(f"'{nom}'" for nom in noms)
    return f"Remove-NetFirewallRule -Name {liste}"
