"""
Client UPnP-IGD minimal : demande à la box d'ouvrir un port, sans intervention manuelle.

Deux usages, par ordre de valeur :

1. **Port externe déterministe pour WireGuard.** STUN se contente d'OBSERVER le mapping
   que le NAT a choisi, et ce mapping peut changer — c'est toute la fragilité du perçage
   de NAT. Une redirection UPnP, elle, impose le port et le garantit tant que le bail
   court. Pour un pair derrière une box compatible, c'est strictement meilleur.
2. **Ouverture du port du serveur de signalisation**, qui supprime l'obstacle principal
   de l'auto-hébergement : la redirection manuelle sur la box.

**Le port de liaison SSDP dépend de l'OS, et les deux moitiés sont mesurées.** C'est la
seule divergence système du module ; voir `port_de_liaison_ssdp`.

*Linux — se lier au port 1900 (piège vérifié le 2026-07-29).* Une réponse SSDP est un
UNICAST émis depuis l'adresse réelle de la box, alors que la requête part vers une adresse
MULTICAST (`239.255.255.250`). Conntrack n'associe pas les deux, donc un pare-feu à état
— `ufw` par défaut — jette la réponse, et la découverte semble échouer alors que la box
répond parfaitement. Se lier explicitement au port SSDP, que les pare-feux autorisent
couramment, résout ça. Un premier diagnostic avait conclu à tort que la box ne faisait pas
d'UPnP, en mesurant en réalité le pare-feu.

*Windows — se lier à un port ÉPHÉMÈRE (piège mesuré le 2026-08-01).* Faire la même chose
ici ne marche pas, et échoue en silence : Windows fait tourner les services `SSDP
Discovery` et `UPnP Device Host`, qui détiennent UDP 1900 sur toutes les interfaces. Or
`SO_REUSEADDR` n'a pas la même sémantique sur les deux OS — sous Windows le bind
**réussit** et les réponses unicast partent quand même au service système. D'où un
symptôme qui ne ressemble à rien : jamais `Port SSDP indisponible`, toujours
`0 description(s) examinée(s)`. Mesuré sur la machine de test, depuis l'adresse de son
interface LAN : `192.168.0.14:1900` → **0 réponse**, port éphémère → **43 réponses**.
Le pare-feu est hors de cause, et c'est vérifié et non supposé — `AllowUnicastResponseTo
Multicast` vaut `NotConfigured` (donc le défaut, qui autorise) sur les trois profils, et
les 43 réponses l'ont bien traversé.

**Conséquence historique, à connaître** : l'UPnP n'a JAMAIS fonctionné sur aucune machine
Windows du projet avant ce correctif. Les endpoints publics Windows qui conservaient leur
port interne le devaient à un NAT qui préserve le port, pas à une redirection — l'inverse
avait été conclu à tort (CLAUDE.md, point 43). Observer un port préservé ne prouve pas
qu'une redirection existe.

Aucune dépendance : SSDP en UDP brut, description en HTTP, commandes en SOAP — tout est
dans la bibliothèque standard. La logique d'analyse est séparée des accès réseau pour
rester testable.
"""

from __future__ import annotations

import logging
import re
import socket
import urllib.request
from dataclasses import dataclass
from typing import Optional
from urllib.parse import urljoin

import config

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

SSDP_ADDRESS = "239.255.255.250"
SSDP_PORT = 1900

# Port éphémère : le noyau en choisit un libre. Sous Windows c'est la seule façon de
# recevoir les réponses, 1900 étant confisqué par un service système (voir l'en-tête).
PORT_EPHEMERE = 0

WAN_SERVICE_TYPES = (
    "urn:schemas-upnp-org:service:WANIPConnection:1",
    "urn:schemas-upnp-org:service:WANPPPConnection:1",
)


def port_de_liaison_ssdp(os_family: str) -> int:
    """
    Port local sur lequel se lier pour émettre le M-SEARCH, selon l'OS.

    Logique pure et séparée de `discover` pour être testable sans réseau ni box : c'est
    la seule chose qui diffère entre les deux systèmes, et se tromper de port produit une
    panne parfaitement silencieuse (voir l'en-tête du module).

    Tout OS inconnu retombe sur le port SSDP : c'est le comportement historique, et le
    seul qui traverse un pare-feu à état. Un système de type BSD ressemble davantage à
    Linux qu'à Windows sur ce point.
    """
    return PORT_EPHEMERE if os_family == "windows" else SSDP_PORT

# Bail demandé, en secondes. Volontairement court (2 h) : si le client se termine
# brutalement sans nettoyer, la box reprend le port d'elle-même plutôt que de garder une
# redirection orpheline.
#
# ATTENTION — la box n'est pas tenue de l'honorer. Mesuré le 2026-07-29 : un
# CODA-4680-FIZ accorde 86400 s quoi qu'on demande. Le retrait explicite à la fermeture
# (`delete_port_mapping`) n'est donc PAS une politesse mais la seule garantie de ne pas
# laisser une ouverture traîner une journée entière.
DEFAULT_LEASE_SECONDS = 7200

_LOCATION_RE = re.compile(r"(?im)^location:\s*(\S+)\s*$")
_SERVICE_BLOCK_RE = re.compile(r"<service>(?:(?!</service>).)*?</service>", re.S)
_SERVICE_TYPE_RE = re.compile(r"<serviceType>([^<]+)</serviceType>")
_CONTROL_URL_RE = re.compile(r"<controlURL>([^<]+)</controlURL>")


def parse_ssdp_location(response: str) -> Optional[str]:
    """URL de description annoncée dans une réponse SSDP, ou None."""
    match = _LOCATION_RE.search(response)
    return match.group(1) if match else None


def find_control_url(description_xml: str, base_url: str) -> Optional[tuple[str, str]]:
    """
    (type de service, controlURL absolue) du service de redirection de port, ou None.

    La description contient plusieurs services ; seul WANIPConnection (ou
    WANPPPConnection sur les liens ADSL) sait créer une redirection. La `controlURL` est
    relative dans la plupart des firmwares, d'où la résolution contre l'URL de base.
    """
    for bloc in _SERVICE_BLOCK_RE.findall(description_xml):
        type_match = _SERVICE_TYPE_RE.search(bloc)
        url_match = _CONTROL_URL_RE.search(bloc)
        if not type_match or not url_match:
            continue
        if type_match.group(1) in WAN_SERVICE_TYPES:
            return type_match.group(1), urljoin(base_url, url_match.group(1))
    return None


def build_soap_envelope(service_type: str, action: str, arguments: dict) -> str:
    """Enveloppe SOAP d'une commande UPnP. Les valeurs sont échappées : elles viennent
    de la configuration locale, mais une IP ou une description mal formée ne doit pas
    pouvoir casser le XML."""
    corps = "".join(f"<{k}>{_escape(str(v))}</{k}>" for k, v in arguments.items())
    return (
        '<?xml version="1.0"?>'
        '<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" '
        's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body>'
        f'<u:{action} xmlns:u="{service_type}">{corps}</u:{action}>'
        "</s:Body></s:Envelope>"
    )


def _escape(valeur: str) -> str:
    return (valeur.replace("&", "&amp;").replace("<", "&lt;")
            .replace(">", "&gt;").replace('"', "&quot;"))


def parse_soap_value(response_xml: str, tag: str) -> Optional[str]:
    """Valeur d'une balise de réponse SOAP (ex: NewExternalIPAddress), ou None."""
    match = re.search(rf"<{tag}>([^<]*)</{tag}>", response_xml)
    return match.group(1) if match else None


@dataclass
class InternetGatewayDevice:
    """Passerelle découverte, prête à recevoir des commandes."""

    service_type: str
    control_url: str

    def _call(self, action: str, arguments: dict, timeout: float = 6.0) -> Optional[str]:
        enveloppe = build_soap_envelope(self.service_type, action, arguments)
        requete = urllib.request.Request(
            self.control_url,
            data=enveloppe.encode("utf-8"),
            headers={
                "Content-Type": 'text/xml; charset="utf-8"',
                "SOAPAction": f'"{self.service_type}#{action}"',
            },
        )
        try:
            with urllib.request.urlopen(requete, timeout=timeout) as reponse:
                return reponse.read().decode("utf-8", errors="replace")
        except Exception:
            logger.debug("Commande UPnP %s en échec", action, exc_info=True)
            return None

    def external_ip(self) -> Optional[str]:
        reponse = self._call("GetExternalIPAddress", {})
        return parse_soap_value(reponse, "NewExternalIPAddress") if reponse else None

    def add_port_mapping(
        self,
        external_port: int,
        internal_port: int,
        internal_ip: str,
        protocol: str = "UDP",
        description: str = "Meshamoto",
        lease_seconds: int = DEFAULT_LEASE_SECONDS,
    ) -> bool:
        """Crée (ou renouvelle) une redirection. Retourne False si la box refuse."""
        reponse = self._call("AddPortMapping", {
            "NewRemoteHost": "",
            "NewExternalPort": external_port,
            "NewProtocol": protocol.upper(),
            "NewInternalPort": internal_port,
            "NewInternalClient": internal_ip,
            "NewEnabled": 1,
            "NewPortMappingDescription": description,
            "NewLeaseDuration": lease_seconds,
        })
        return reponse is not None and "AddPortMappingResponse" in reponse

    def get_port_mapping(self, external_port: int, protocol: str = "UDP") -> Optional[str]:
        """
        Adresse interne vers laquelle ce port est redirigé, ou None si aucune
        redirection n'est déclarée. Lecture seule : ne crée rien.

        Sert au diagnostic d'auto-hébergement (`hosting_diagnostics.py`).
        **Une réponse vide ne prouve pas l'absence de redirection** : une règle
        statique saisie dans l'interface de la box — précisément ce qu'on
        recommande pour un serveur — n'est pas toujours visible par cette
        commande. L'appelant doit donc formuler l'absence comme un doute, pas
        comme un verdict.
        """
        reponse = self._call("GetSpecificPortMappingEntry", {
            "NewRemoteHost": "",
            "NewExternalPort": external_port,
            "NewProtocol": protocol.upper(),
        })
        if reponse is None:
            return None
        # La box repond une erreur SOAP (SpecifiedArrayIndexInvalid /
        # NoSuchEntryInArray) quand il n'y a pas de redirection : pas de
        # NewInternalClient a extraire, donc None sans traitement particulier.
        return parse_soap_value(reponse, "NewInternalClient") or None

    def delete_port_mapping(self, external_port: int, protocol: str = "UDP") -> bool:
        """Retire une redirection. À appeler à la fermeture, pour ne pas laisser
        d'ouverture orpheline sur la box."""
        reponse = self._call("DeletePortMapping", {
            "NewRemoteHost": "",
            "NewExternalPort": external_port,
            "NewProtocol": protocol.upper(),
        })
        return reponse is not None and "DeletePortMappingResponse" in reponse


def discover(timeout: float = 3.0, local_ip: str = "") -> Optional[InternetGatewayDevice]:
    """
    Découvre la passerelle et son service de redirection, ou None.

    Le port de liaison dépend de l'OS et c'est structurant : voir `port_de_liaison_ssdp`
    et l'en-tête du module. Se tromper ne lève rien — la découverte renvoie simplement
    zéro emplacement, et l'appelant conclut que la box ne fait pas d'UPnP.
    """
    # Seul le port SOURCE dépend de l'OS. La DESTINATION reste 239.255.255.250:1900 dans
    # tous les cas — c'est l'adresse SSDP du protocole, pas un réglage local : y toucher
    # ferait qu'aucune box n'entendrait la requête.
    port_local = port_de_liaison_ssdp(config.OS_FAMILY)
    requete = (
        "M-SEARCH * HTTP/1.1\r\n"
        f"HOST:{SSDP_ADDRESS}:{SSDP_PORT}\r\n"
        'MAN:"ssdp:discover"\r\n'
        "MX:2\r\n"
        "ST:ssdp:all\r\n\r\n"
    )
    prise = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    prise.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    try:
        prise.bind((local_ip, port_local))
    except OSError:
        logger.info("Port SSDP %d indisponible, découverte UPnP abandonnée", port_local)
        prise.close()
        return None

    prise.settimeout(timeout)
    emplacements: list[str] = []
    try:
        prise.sendto(requete.encode(), (SSDP_ADDRESS, SSDP_PORT))
        while True:
            donnees, _ = prise.recvfrom(4096)
            emplacement = parse_ssdp_location(donnees.decode("utf-8", errors="replace"))
            if emplacement and emplacement not in emplacements:
                emplacements.append(emplacement)
    except socket.timeout:
        pass
    except OSError:
        logger.debug("Découverte SSDP interrompue", exc_info=True)
    finally:
        prise.close()

    for emplacement in emplacements:
        try:
            with urllib.request.urlopen(emplacement, timeout=5) as reponse:
                description = reponse.read().decode("utf-8", errors="replace")
        except Exception:
            continue
        trouve = find_control_url(description, emplacement)
        if trouve:
            logger.info("Passerelle UPnP trouvée : %s", trouve[1])
            return InternetGatewayDevice(service_type=trouve[0], control_url=trouve[1])

    # Le port est journalisé parce que c'est LUI qui a causé la panne silencieuse du
    # 2026-08-01 : sans lui, ce message est identique qu'on ait écouté au bon endroit ou
    # non, et il envoie accuser la box (ce qu'il a fait pendant des semaines).
    logger.info("Aucune passerelle UPnP exploitable (%d description(s) examinée(s), "
                "écoute sur le port local %d)", len(emplacements), port_local)
    return None
