"""
Partie de la logique WireGuard qui est strictement identique sur Windows et Linux :
génération de clés Curve25519 et construction du texte de fichier .conf.

Les différences entre OS (comment on active/désactive réellement le tunnel) sont
dans wireguard_backend_windows.py et wireguard_backend_linux.py.
"""

from __future__ import annotations

import base64
from dataclasses import dataclass
from pathlib import Path

from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey


# Préfixe de repli quand le CIDR du réseau n'a pas pu être analysé. Aligné sur le
# défaut du serveur (192.168.192.0/24) : un préfixe TROP LARGE ferait capter au tunnel
# des adresses qui ne lui appartiennent pas, un préfixe trop étroit est sans danger.
DEFAULT_ADDRESS_PREFIX_LEN = 24


@dataclass
class TunnelSpec:
    """
    Description complète du tunnel à monter, sous forme de PARAMÈTRES et non de fichier.

    Existe pour le composant privilégié Linux (packaging/linux/meshamoto-helper), qui
    refuse par conception de recevoir un `.conf` : celui-ci accepte des directives
    `PostUp = <commande>` exécutées en root, donc un helper sans mot de passe qui
    accepterait un fichier écrit par la session offrirait un root gratuit. Le backend
    Windows ignore ce paramètre — `wireguard.exe` ne sait prendre qu'un chemin de
    fichier — mais la signature reste commune aux deux backends (règle de symétrie).
    """
    private_key: str
    local_virtual_ip: str
    address_prefix_len: int
    listen_port: int
    peers: list["PeerConfig"]


@dataclass
class KeyPair:
    private_key_b64: str
    public_key_b64: str


def generate_keypair() -> KeyPair:
    """Génère une paire de clés X25519 encodées en base64, au format attendu par WireGuard."""
    private_key = X25519PrivateKey.generate()
    public_key = private_key.public_key()

    private_bytes = private_key.private_bytes(
        encoding=serialization.Encoding.Raw,
        format=serialization.PrivateFormat.Raw,
        encryption_algorithm=serialization.NoEncryption(),
    )
    public_bytes = public_key.public_bytes(
        encoding=serialization.Encoding.Raw,
        format=serialization.PublicFormat.Raw,
    )
    return KeyPair(
        private_key_b64=base64.b64encode(private_bytes).decode("ascii"),
        public_key_b64=base64.b64encode(public_bytes).decode("ascii"),
    )


def load_or_create_keypair(identity_path: Path) -> KeyPair:
    """
    Charge la paire de clés WireGuard persistée dans `identity_path`, ou en génère une
    nouvelle et la sauvegarde si le fichier n'existe pas encore.

    Sans cette persistance, chaque lancement du client générerait une nouvelle
    identité réseau : le serveur verrait un nouveau pair à chaque redémarrage (IP
    virtuelle différente, ancien pair orphelin jamais nettoyé côté base de données).
    """
    if identity_path.exists():
        private_key_b64 = identity_path.read_text(encoding="utf-8").strip()
        private_key = X25519PrivateKey.from_private_bytes(base64.b64decode(private_key_b64))
        public_bytes = private_key.public_key().public_bytes(
            encoding=serialization.Encoding.Raw,
            format=serialization.PublicFormat.Raw,
        )
        return KeyPair(
            private_key_b64=private_key_b64,
            public_key_b64=base64.b64encode(public_bytes).decode("ascii"),
        )

    keypair = generate_keypair()
    identity_path.parent.mkdir(parents=True, exist_ok=True)
    identity_path.write_text(keypair.private_key_b64, encoding="utf-8")
    try:
        # Restreint la lecture au propriétaire (clé privée) — sans effet notable sur
        # Windows, où la protection s'appuie sur les ACL du profil utilisateur.
        identity_path.chmod(0o600)
    except OSError:
        pass
    return keypair


@dataclass
class PeerConfig:
    public_key: str
    allowed_ip: str  # IP virtuelle du pair en /32 (ex: "25.0.0.3/32")
    endpoint: str | None  # "ip:port" si connu, sinon None


def render_conf(
    private_key: str,
    local_virtual_ip: str,
    listen_port: int,
    peers: list[PeerConfig],
    address_prefix_len: int = DEFAULT_ADDRESS_PREFIX_LEN,
) -> str:
    """
    Construit le contenu texte d'un fichier .conf WireGuard, valide aussi bien pour
    `wireguard.exe` (Windows) que pour `wg-quick` (Linux) — c'est le même format standard.

    `address_prefix_len` DOIT correspondre au préfixe du réseau virtuel : c'est lui qui
    détermine la route que l'interface s'approprie. Il était écrit en dur à /8, ce qui
    était juste tant que la plage valait 25.0.0.0/8 ; avec la plage actuelle en
    192.168.192.0/24, un /8 ferait capter TOUT 192.0.0.0/8 au tunnel — donc le LAN
    physique de l'utilisateur, sa box et son NAS compris. Le préfixe vient désormais du
    CIDR annoncé par le serveur (voir NetworkClient, session.cidr).

    PersistentKeepalive=25 est essentiel derrière la plupart des NAT/routeurs grand public.
    """
    lines = [
        "[Interface]",
        f"PrivateKey = {private_key}",
        f"Address = {local_virtual_ip}/{address_prefix_len}",
        f"ListenPort = {listen_port}",
        "",
    ]
    for peer in peers:
        lines.append("[Peer]")
        lines.append(f"PublicKey = {peer.public_key}")
        lines.append(f"AllowedIPs = {peer.allowed_ip}")
        if peer.endpoint:
            lines.append(f"Endpoint = {peer.endpoint}")
        lines.append("PersistentKeepalive = 25")
        lines.append("")
    return "\n".join(lines)


def render_sync_conf(
    private_key: str, listen_port: int, peers: list[PeerConfig]
) -> str:
    """
    Variante « dépouillée » du .conf, pour `wg syncconf`.

    **Sans la ligne `Address`, et ce n'est pas un oubli** : `Address` est une
    directive de `wg-quick`, pas de `wg`. C'est elle qui configure l'adresse et
    les routes de l'interface, opérations que `wg syncconf` ne fait pas — il ne
    touche QU'À la liste de pairs, à la clé privée et au port d'écoute. La lui
    passer ferait échouer la commande sur une clé inconnue.

    C'est aussi ce qui rend `syncconf` intéressant : il met à jour les pairs
    **sans démonter l'interface**, donc sans la coupure de plusieurs centaines de
    millisecondes que subissaient tous les pairs à chaque arrivée ou départ.

    Effet de bord utile pour la sécurité : ce format n'accepte aucune directive
    exécutable (`PostUp` & co. appartiennent à `wg-quick`), contrairement au
    `.conf` complet dont le point 38 explique pourquoi il ne doit jamais être
    accepté depuis la session.
    """
    lines = [
        "[Interface]",
        f"PrivateKey = {private_key}",
        f"ListenPort = {listen_port}",
        "",
    ]
    for peer in peers:
        lines.append("[Peer]")
        lines.append(f"PublicKey = {peer.public_key}")
        lines.append(f"AllowedIPs = {peer.allowed_ip}")
        if peer.endpoint:
            lines.append(f"Endpoint = {peer.endpoint}")
        lines.append("PersistentKeepalive = 25")
        lines.append("")
    return "\n".join(lines)


def address_line(conf: str) -> str:
    """
    Ligne `Address` d'un .conf rendu, ou chaîne vide.

    Sert à décider entre `wg syncconf` et une réinstallation complète : tout ce
    qui touche l'adresse ou le préfixe change les ROUTES de l'interface, ce que
    `syncconf` ne fait pas. Les pairs, la clé privée et le port d'écoute, eux,
    se synchronisent à chaud.
    """
    for ligne in conf.splitlines():
        if ligne.strip().lower().startswith("address"):
            return ligne.strip()
    return ""


def parse_latest_handshakes(sortie: str, maintenant: float) -> dict[str, float | None]:
    """
    Analyse la sortie de `wg show <interface> latest-handshakes`.

    Format : une ligne par pair, `<clé publique base64>\\t<horodatage Unix>`. Un
    horodatage nul signifie « jamais négocié » et devient `None` — le confondre avec
    zéro seconde ferait passer un pair qui n'a jamais répondu pour un pair dont le
    handshake vient d'aboutir, c'est-à-dire l'inverse exact de ce que le panneau de
    diagnostic cherche à distinguer.

    **C'est le format MACHINE, pas l'affichage.** `wg show` sans sous-commande produit
    un pavé lisible (« latest handshake: 1 minute, 55 seconds ago ») qu'il ne faut
    jamais analyser ; `latest-handshakes` produit deux champs séparés par une
    tabulation, faits pour être relus. Quatrième application de la règle du projet
    après `sc query` (point 32), la sortie de `ping` (point 34) et `schtasks`
    (point 45) — on n'analyse jamais ce qu'un outil affiche, seulement ce qu'il
    produit pour être relu.

    `maintenant` est injecté plutôt que lu ici : c'est ce qui rend la fonction
    testable sans dépendre de l'horloge.

    Partagée par les DEUX backends, délibérément : la sortie vient du même `wg` sur
    les deux systèmes, et deux analyseurs jumeaux finiraient par diverger — le
    diagnostic de handshake dirait alors des choses différentes selon l'OS pour des
    données identiques.
    """
    handshakes: dict[str, float | None] = {}
    for ligne in sortie.strip().splitlines():
        parts = ligne.split("\t")
        if len(parts) != 2:
            continue
        cle_publique, horodatage_str = parts
        try:
            horodatage = float(horodatage_str)
        except ValueError:
            continue
        handshakes[cle_publique] = (maintenant - horodatage) if horodatage > 0 else None
    return handshakes
