"""
Logique pure partagée par les deux backends d'hébergement du serveur de
signalisation (`server_service_backend_linux.py` / `..._windows.py`).

Tout ce qui est ici est indépendant de l'OS et sans appel privilégié ni
subprocess : ce sont les analyseurs de sortie de commandes et le type de retour
commun. Le reste du projet applique la même séparation (voir `_build_ruleset`
dans `firewall_backend_linux.py`), qui permet de tester le raisonnement sans
avoir la commande — donc de tester le chemin Windows depuis Linux.
"""

from __future__ import annotations

import re
from dataclasses import dataclass


@dataclass(frozen=True)
class ServiceStatus:
    """
    État de l'hébergement, formulé en promesses utilisateur plutôt qu'en
    vocabulaire d'OS — c'est ce qui permet aux deux backends d'être réellement
    équivalents malgré des mécanismes très différents (systemd d'un côté,
    Planificateur de tâches de l'autre).

    installed : le mécanisme d'hébergement est en place et utilisable
    running   : le serveur répond en ce moment
    autostart : il redémarrera tout seul après un redémarrage de la machine
    detail    : phrase courte affichable telle quelle, jamais None
    """

    installed: bool
    running: bool
    autostart: bool
    detail: str = ""


def parse_systemctl_show(output: str) -> dict[str, str]:
    """
    Analyse la sortie `systemctl show --property=A,B,C` : des lignes `CLE=valeur`.

    La valeur peut elle-même contenir des `=` (rare sur les propriétés qu'on
    demande, mais gratuit à gérer), d'où le découpage sur le PREMIER seulement.
    Les lignes sans `=` sont ignorées plutôt que de faire échouer l'analyse :
    une version de systemd qui ajouterait un en-tête ne doit pas casser l'état.
    """
    properties: dict[str, str] = {}
    for line in output.splitlines():
        line = line.strip()
        if not line or "=" not in line:
            continue
        key, _, value = line.partition("=")
        properties[key.strip()] = value.strip()
    return properties


def status_from_systemctl(
    properties: dict[str, str], *, depuis_appimage: bool = False
) -> ServiceStatus:
    """
    Traduit les propriétés systemd en promesses utilisateur.

    `LoadState` distingue une unité absente (`not-found`) d'une unité installée
    mais arrêtée — sans ça, un paquet incomplet et un serveur simplement éteint
    donneraient le même affichage, alors que les deux appellent des actions
    opposées de la part de l'utilisateur.

    `depuis_appimage` traite un troisième cas, apparu quand l'AppImage a commencé
    à embarquer le serveur : l'unité systemd est bien absente, mais **le serveur
    est là**. Dire « pas installé » serait faux et enverrait chercher un paquet
    qu'on a déjà. L'unité ne peut pas être créée pour autant — elle doit pointer
    un chemin FIXE, or une AppImage se déplace au gré de son propriétaire, et
    en générer une à l'exécution reviendrait à laisser la session inscrire un
    exécutable arbitraire au démarrage (même refus qu'au point 45).
    """
    load_state = properties.get("LoadState", "")
    active_state = properties.get("ActiveState", "")
    unit_file_state = properties.get("UnitFileState", "")

    installed = load_state == "loaded"
    running = active_state == "active"
    # `enabled-runtime` ne survit pas au redémarrage : ce n'est donc PAS un
    # démarrage automatique au sens où l'utilisateur l'entend.
    autostart = unit_file_state == "enabled"

    if not installed and depuis_appimage:
        detail = (
            "Le serveur est fourni dans cette AppImage, mais il ne peut pas être "
            "piloté depuis cette fenêtre. Lancez-la avec l'option --server."
        )
    elif not installed:
        detail = "Le serveur n'est pas installé sur cette machine."
    elif running:
        detail = "Le serveur tourne."
    elif active_state == "failed":
        detail = "Le serveur s'est arrêté sur une erreur."
    else:
        detail = "Le serveur est arrêté."
    return ServiceStatus(installed=installed, running=running, autostart=autostart, detail=detail)


def parse_linger(output: str) -> bool:
    """
    Analyse `loginctl show-user <nom> -p Linger` (`Linger=yes` / `Linger=no`).

    Sans linger, un service `--user` s'arrête à la déconnexion de la session :
    la promesse « le serveur reste joignable » serait donc fausse dès que
    l'utilisateur ferme sa session, sans le moindre message.
    """
    return parse_systemctl_show(output).get("Linger", "").lower() == "yes"


def parse_tasklist(output: str, image_name: str) -> bool:
    """
    Vrai si `tasklist` liste un processus portant ce nom d'exécutable (Windows).

    `tasklist /fi` ne renvoie PAS un code d'erreur quand rien ne correspond : il
    écrit un message d'information sur la sortie standard et sort avec 0. Se
    fier au code de retour ferait donc croire que le serveur tourne toujours —
    c'est le même piège que `sc query` au point 32, et que le `KO` en HTTP 200
    de DuckDNS au point 44. On cherche donc explicitement le nom dans la sortie.
    """
    needle = image_name.lower()
    return any(needle in line.lower() for line in output.splitlines())


def parse_schtasks_xml(output: str) -> tuple[bool, bool]:
    """
    Analyse `schtasks /query /tn <nom> /xml ONE` -> (tâche présente, activée).

    **La sortie XML est le seul format non traduit.** La sortie tabulaire ou
    `/fo LIST` affiche l'état dans la langue du système : un Windows français
    écrit `Désactivé` là où l'anglais écrit `Disabled`. Chercher `disabled`
    faisait donc passer une tâche désactivée pour active sur toute machine
    localisée — c'est-à-dire sur celle de l'utilisateur. Le XML, lui, contient
    invariablement `<Enabled>true</Enabled>`, parce que c'est la définition de la
    tâche et non son affichage.

    Troisième fois que ce piège se présente dans ce projet : `sc query` au point
    32 (résolu en s'ancrant sur le mnémonique `RUNNING`), la sortie de `ping` au
    point 34 (résolue en s'ancrant sur l'unité `ms`), et ici. **Règle à
    retenir : ne jamais analyser ce qu'un outil Windows AFFICHE, chercher le
    format qu'il produit pour être relu.**

    Une tâche désactivée existe toujours : c'est ce qui distingue « hébergement
    jamais configuré » de « configuré mais démarrage automatique coupé ».
    """
    if "<Task" not in output:
        return (False, False)
    # Balise facultative : une tâche sans <Enabled> explicite est active, c'est
    # le défaut du planificateur.
    correspondance = re.search(r"<Enabled>\s*(true|false)\s*</Enabled>", output, re.IGNORECASE)
    if correspondance is None:
        return (True, True)
    return (True, correspondance.group(1).lower() == "true")


def status_from_windows(
    task_present: bool,
    task_enabled: bool,
    process_running: bool,
    executable_found: bool,
) -> ServiceStatus:
    """
    Traduit l'état Windows en la même promesse que côté Linux.

    Noter la dissymétrie assumée : sous Windows la tâche planifiée ne sert QU'AU
    démarrage automatique, le démarrage immédiat lançant l'exécutable
    directement. « Installé » veut donc dire « l'exécutable du serveur est
    présent », pas « la tâche existe » — sinon un utilisateur qui n'a jamais
    activé le démarrage automatique verrait « non installé » alors qu'il peut
    parfaitement héberger.
    """
    if not executable_found:
        return ServiceStatus(
            installed=False,
            running=False,
            autostart=False,
            detail="MeshamotoServer.exe est introuvable à côté du client.",
        )
    if process_running:
        detail = "Le serveur tourne."
    elif task_present and task_enabled:
        detail = "Le serveur est arrêté (il redémarrera au prochain démarrage)."
    else:
        detail = "Le serveur est arrêté."
    return ServiceStatus(
        installed=True,
        running=process_running,
        autostart=task_present and task_enabled,
        detail=detail,
    )
