"""
Couche de persistance SQLite pour le serveur de signalisation.

Stocke :
- Les réseaux virtuels (VirtualNetwork) : nom, mot de passe (hashé), plage CIDR
- Les pairs (Peer) : clé publique WireGuard, dernier endpoint public connu (IP:port),
  adresse IP virtuelle assignée dans le réseau, timestamp de dernière activité

Note Windows : le fichier .db est créé à côté du script/exécutable par défaut.
Pour un chemin different (ex: service Windows), définissez MESHAMOTO_DB_PATH.
"""

from __future__ import annotations

import hashlib
import os
import secrets
import sqlite3
import sys
import time
from contextlib import contextmanager
from dataclasses import dataclass, fields
from ipaddress import IPv4Address, IPv4Network
from typing import Iterator, Optional

# Une fois figé par PyInstaller (packaging/windows/meshamoto-server.spec), __file__
# pointe vers le dossier d'extraction temporaire (_MEIPASS), différent à CHAQUE
# lancement -- la base serait recréée vide à chaque démarrage. sys.executable reste
# stable (l'emplacement réel de l'.exe), donc on l'utilise quand l'app est figée.
if getattr(sys, "frozen", False):
    _default_db_dir = os.path.dirname(sys.executable)
else:
    _default_db_dir = os.path.dirname(__file__)

DB_PATH = os.environ.get("MESHAMOTO_DB_PATH", os.path.join(_default_db_dir, "server.db"))

# Plage utilisée pour l'adressage virtuel de chaque réseau.
#
# 192.168.192.0/24 et non 25.0.0.0/8 comme Hamachi : mesuré le 2026-07-29, le mode LAN
# des jeux à moteur Source (`sv_lan 1`) refuse tout client hors CLASSE C — le texte
# d'aide du moteur le dit lui-même, "no non-class C addresses". Or 25.0.0.0/8 est de
# classe A, tout comme 10.0.0.0/8 ; 172.16.0.0/12 est de classe B. **192.168.0.0/16 est
# la seule plage privée de classe C**, et satisfait aussi l'interprétation RFC 1918 si
# le contrôle réel porte sur celle-ci — on n'a donc pas besoin de trancher.
#
# Le /24 réduit aussi massivement la surface de conflit : un /8 détournait seize
# millions d'adresses vers l'interface du tunnel, contre 254 ici. Le sous-réseau .192
# est choisi pour s'éloigner des .0 et .1 que les box grand public utilisent d'usine.
# 254 pairs par réseau reste deux ordres de grandeur au-dessus du plafond réel du
# projet (voir README, "Montée en charge" : l'orchestration sature vers 8 pairs).
#
# La plage est stockée PAR RÉSEAU dans la table networks : changer ce défaut n'affecte
# que les réseaux créés ensuite, les existants gardent la leur. Surchargeable pour les
# utilisateurs dont le LAN réel occupe déjà ce sous-réseau (voir aussi
# network_client.local_ip_conflicts_with_virtual_cidr, qui les avertit).
DEFAULT_VNET_CIDR = os.environ.get("MESHAMOTO_VNET_CIDR", "192.168.192.0/24")


@dataclass
class VirtualNetwork:
    id: int
    name: str
    password_hash: str
    salt: str
    cidr: str
    created_at: float


@dataclass
class Peer:
    id: int
    network_id: int
    public_key: str
    virtual_ip: str
    last_endpoint: Optional[str]  # "ip:port" — dernière adresse publique observée (pour le hole punching)
    last_local_endpoint: Optional[str]  # "ip:port" LAN, si le pair l'a signalé (préféré si même sous-réseau)
    last_seen: float
    display_name: str
    hosted_service_label: Optional[str] = None  # ex: "Palworld" — voir update_peer_service
    hosted_service_port: Optional[int] = None
    # "windows" / "linux" / None si le client ne l'a pas signalé (version antérieure).
    # Sert uniquement à adapter les conseils du panneau de diagnostic côté client : le
    # ping d'un pair Windows peut échouer alors que le tunnel est parfaitement sain
    # (ICMP entrant bloqué par défaut), ce qui n'est PAS le cas sous Linux.
    os_family: Optional[str] = None
    # TOUTES les adresses locales du pair, séparées par des virgules ("ip:port,ip:port").
    # `last_local_endpoint` n'en porte qu'une, celle de la route par défaut, et sur une
    # machine bi-domiciliée ce n'est pas forcément celle qui compte : un poste branché au
    # LAN ET à un partage de connexion cellulaire annonçait son adresse cellulaire, et ses
    # voisins de commutateur transitaient par un relais à 900 km (point 90).
    # None si le client ne les signale pas (version antérieure) — le serveur ne fait que
    # transporter cette valeur, c'est le client qui vérifie et décide.
    last_local_endpoints: Optional[str] = None
    # TOUS les services annoncés par le pair, en JSON : [{"label", "port", "type"}, ...].
    # Remplace le couple `hosted_service_label`/`hosted_service_port` ci-dessus, qui n'en
    # portait qu'UN et dont le port était présumé être un port de jeu par le salon.
    #
    # **Stocké et rediffusé OPAQUEMENT**, exactement comme `last_local_endpoints` : le
    # serveur ne cherche pas à comprendre les types de service, il les transporte. C'est
    # le client qui valide à la lecture (`service_types.deserialiser`), et c'est le bon
    # endroit — la défense doit être là où la valeur est CONSOMMÉE, puisque c'est le
    # client qui écrit ensuite dans les favoris Steam de l'utilisateur.
    # Le serveur ne borne que la TAILLE, ce qui est sa responsabilité à lui (voir
    # HOSTED_SERVICES_MAX_LEN dans app.py) : empêcher un pair de gonfler la base et
    # chaque diffusion de liste.
    hosted_services: Optional[str] = None


def _hash_password(password: str, salt: str) -> str:
    # PBKDF2-HMAC-SHA256 — suffisant pour ce cas d'usage (pas de stockage de données sensibles au-delà du nom de réseau)
    return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 200_000).hex()


@contextmanager
def get_connection() -> Iterator[sqlite3.Connection]:
    """Context manager pour une connexion SQLite avec clés étrangères activées."""
    conn = sqlite3.connect(DB_PATH, timeout=10)
    conn.execute("PRAGMA foreign_keys = ON")
    conn.row_factory = sqlite3.Row
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()


def _peer_from_row(row) -> "Peer":
    """
    Construit un Peer en IGNORANT les colonnes que cette version ne connaît pas.

    `Peer(**dict(row))` passait tout au constructeur : dès qu'une base avait été migrée
    par une version plus récente, une version antérieure du serveur plantait en la
    lisant (`unexpected keyword argument`). La migration était donc à sens unique, et
    chaque nouvelle colonne aurait reproduit le problème — notamment pour l'exécutable
    serveur Windows, qui se reconstruit rarement.

    Filtrer sur les champs connus rend la lecture tolérante dans les deux sens : une
    colonne en trop est ignorée, une colonne manquante retombe sur la valeur par défaut
    de la dataclass.
    """
    known = {f.name for f in fields(Peer)}
    return Peer(**{k: v for k, v in dict(row).items() if k in known})


def _add_column_if_missing(conn: sqlite3.Connection, table: str, column: str, ddl_type: str) -> None:
    """
    Ajoute une colonne à une table déjà créée, si elle en est absente.

    `CREATE TABLE IF NOT EXISTS` ne fait rien sur une base existante : sans cette
    migration, un serveur mis à jour planterait au premier accès sur une base
    antérieure à l'ajout de la colonne. Suffisant ici — les colonnes ajoutées sont
    toujours nullables et sans valeur par défaut à recalculer.
    """
    existing = {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")}
    if column not in existing:
        conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl_type}")


def init_db() -> None:
    """Crée les tables si elles n'existent pas encore. À appeler au démarrage du serveur."""
    with get_connection() as conn:
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS networks (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT UNIQUE NOT NULL,
                password_hash TEXT NOT NULL,
                salt TEXT NOT NULL,
                cidr TEXT NOT NULL,
                created_at REAL NOT NULL
            )
            """
        )
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS peers (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                network_id INTEGER NOT NULL,
                public_key TEXT NOT NULL,
                virtual_ip TEXT NOT NULL,
                last_endpoint TEXT,
                last_local_endpoint TEXT,
                last_seen REAL NOT NULL,
                display_name TEXT NOT NULL,
                hosted_service_label TEXT,
                hosted_service_port INTEGER,
                os_family TEXT,
                last_local_endpoints TEXT,
                hosted_services TEXT,
                FOREIGN KEY (network_id) REFERENCES networks(id) ON DELETE CASCADE,
                UNIQUE(network_id, public_key)
            )
            """
        )
        _add_column_if_missing(conn, "peers", "os_family", "TEXT")
        # Deuxième migration du projet, même mécanique que `os_family` (point 33) :
        # `CREATE TABLE IF NOT EXISTS` ne touche pas une base existante, donc sans ceci
        # un serveur mis à jour planterait sur une base d'avant.
        _add_column_if_missing(conn, "peers", "last_local_endpoints", "TEXT")
        # Troisième migration, même mécanique que les deux ci-dessus. Elle compte
        # autant : le serveur du VPS tourne sur une base peuplée, et sans elle il
        # planterait au premier accès après la mise à jour.
        _add_column_if_missing(conn, "peers", "hosted_services", "TEXT")


def create_network(name: str, password: str, cidr: str = DEFAULT_VNET_CIDR) -> VirtualNetwork:
    """Crée un nouveau réseau virtuel. Lève sqlite3.IntegrityError si le nom existe déjà."""
    salt = secrets.token_hex(16)
    password_hash = _hash_password(password, salt)
    now = time.time()
    with get_connection() as conn:
        cur = conn.execute(
            "INSERT INTO networks (name, password_hash, salt, cidr, created_at) VALUES (?, ?, ?, ?, ?)",
            (name, password_hash, salt, cidr, now),
        )
        return VirtualNetwork(id=cur.lastrowid, name=name, password_hash=password_hash, salt=salt, cidr=cidr, created_at=now)


def verify_network_password(name: str, password: str) -> Optional[VirtualNetwork]:
    """Retourne le VirtualNetwork si le mot de passe est correct, sinon None."""
    with get_connection() as conn:
        row = conn.execute("SELECT * FROM networks WHERE name = ?", (name,)).fetchone()
        if row is None:
            return None
        if _hash_password(password, row["salt"]) != row["password_hash"]:
            return None
        return VirtualNetwork(**dict(row))


def rotate_network_password(name: str, ancien: str, nouveau: str) -> Optional[VirtualNetwork]:
    """
    Remplace le mot de passe d'un réseau. Retourne le réseau à jour, ou None si
    l'ancien mot de passe est faux (ou le réseau inconnu).

    **C'est la révocation d'une invitation.** Un code `MESHA1-…` contient le mot
    de passe et ne peut ni expirer ni être rappelé (point 52) : le seul moyen de
    le neutraliser est de changer ce qu'il transporte. Le besoin a été démontré
    en conditions réelles le 2026-07-31 — un code collé dans une conversation, et
    aucune parade en dehors d'un `DELETE` en SQL sur le serveur.

    **Un sel NEUF est tiré à chaque rotation**, jamais réutilisé. Garder l'ancien
    permettrait à qui aurait vu la base de comparer les deux empreintes, et
    surtout de réutiliser un calcul déjà fait contre le nouveau mot de passe.

    Les pairs DÉJÀ MEMBRES ne sont pas expulsés : le mot de passe ne garde que la
    porte d'entrée, et leur tunnel WireGuard n'en dépend pas. La rotation révoque
    donc le CODE, pas les machines qui s'en sont déjà servi — l'interface doit le
    dire, sous peine de promettre plus qu'elle ne tient.
    """
    with get_connection() as conn:
        row = conn.execute("SELECT * FROM networks WHERE name = ?", (name,)).fetchone()
        if row is None:
            return None
        if _hash_password(ancien, row["salt"]) != row["password_hash"]:
            return None
        sel = secrets.token_hex(16)
        empreinte = _hash_password(nouveau, sel)
        conn.execute(
            "UPDATE networks SET password_hash = ?, salt = ? WHERE id = ?",
            (empreinte, sel, row["id"]),
        )
        return VirtualNetwork(
            id=row["id"], name=row["name"], password_hash=empreinte, salt=sel,
            cidr=row["cidr"], created_at=row["created_at"],
        )


def delete_network(network_id: int) -> None:
    """
    Supprime un réseau et tous ses pairs.

    Les pairs sont effacés EXPLICITEMENT, sans compter sur le `ON DELETE CASCADE`
    de la table : SQLite ne l'applique que si `PRAGMA foreign_keys` est activé,
    ce qu'il n'est PAS par défaut. S'y fier laisserait des pairs orphelins
    pointant vers un réseau disparu — et leurs IP virtuelles resteraient
    considérées comme prises par `_next_free_ip`.
    """
    with get_connection() as conn:
        conn.execute("DELETE FROM peers WHERE network_id = ?", (network_id,))
        conn.execute("DELETE FROM networks WHERE id = ?", (network_id,))


def delete_peer(network_id: int, peer_id: int) -> bool:
    """
    Retire un pair d'un réseau. Retourne False s'il n'y était pas.

    `network_id` fait partie de la condition et n'est pas décoratif : sans lui,
    un identifiant de pair appartenant à un AUTRE réseau serait supprimé sans
    rien y redire, alors que l'appelant ne prouve connaître que celui-ci.
    """
    with get_connection() as conn:
        cur = conn.execute(
            "DELETE FROM peers WHERE id = ? AND network_id = ?", (peer_id, network_id)
        )
        return cur.rowcount > 0


def _next_free_ip(conn: sqlite3.Connection, network_id: int, cidr: str) -> str:
    """Alloue la prochaine IP virtuelle disponible dans le CIDR du réseau (ex: 25.0.0.2, 25.0.0.3, ...)."""
    net = IPv4Network(cidr)
    used = {row["virtual_ip"] for row in conn.execute(
        "SELECT virtual_ip FROM peers WHERE network_id = ?", (network_id,)
    )}
    hosts = net.hosts()
    next(hosts)  # on saute .1, réservée en cas de besoin (passerelle logique)
    for addr in hosts:
        candidate = str(addr)
        if candidate not in used:
            return candidate
    raise RuntimeError("Plus d'adresses virtuelles disponibles dans ce réseau (CIDR épuisé).")


DEFAULT_DISPLAY_NAME = "peer"


def join_network(
    network_id: int,
    cidr: str,
    public_key: str,
    display_name: Optional[str] = None,
    os_family: Optional[str] = None,
) -> Peer:
    """Enregistre un pair dans un réseau, ou met à jour son display_name/last_seen s'il existe déjà."""
    now = time.time()
    with get_connection() as conn:
        existing = conn.execute(
            "SELECT * FROM peers WHERE network_id = ? AND public_key = ?", (network_id, public_key)
        ).fetchone()
        if existing:
            # COALESCE sur les DEUX champs. Pour `os_family`, un client d'une version
            # antérieure ne le signale pas et sa reconnexion ne doit pas effacer une
            # valeur connue (même logique que update_peer_endpoint).
            # Pour `display_name`, l'asymétrie qui manquait ici a coûté cher : le champ
            # était écrasé sans condition, donc un client relancé sans pseudo saisi
            # renommait le pair « peer » pour TOUS les autres, sans retour possible
            # (point 69, constaté en production). Un renommage volontaire passe
            # toujours — c'est le SILENCE qui est désormais préservé, pas la valeur.
            conn.execute(
                "UPDATE peers SET last_seen = ?, display_name = COALESCE(?, display_name), "
                "os_family = COALESCE(?, os_family) WHERE id = ?",
                (now, display_name, os_family, existing["id"]),
            )
            return _peer_from_row({
                **dict(existing),
                "last_seen": now,
                "display_name": display_name or existing["display_name"],
                "os_family": os_family or existing["os_family"],
            })

        # Pair réellement nouveau : il n'y a rien à conserver, d'où le repli.
        display_name = display_name or DEFAULT_DISPLAY_NAME

        virtual_ip = _next_free_ip(conn, network_id, cidr)
        cur = conn.execute(
            """INSERT INTO peers (network_id, public_key, virtual_ip, last_endpoint, last_local_endpoint, last_seen, display_name, os_family)
               VALUES (?, ?, ?, NULL, NULL, ?, ?, ?)""",
            (network_id, public_key, virtual_ip, now, display_name, os_family),
        )
        return Peer(
            id=cur.lastrowid,
            network_id=network_id,
            public_key=public_key,
            virtual_ip=virtual_ip,
            last_endpoint=None,
            last_local_endpoint=None,
            last_seen=now,
            display_name=display_name,
            os_family=os_family,
        )


def update_peer_endpoint(
    peer_id: int,
    endpoint: Optional[str],
    local_endpoint: Optional[str] = None,
    local_endpoints: Optional[str] = None,
) -> None:
    """
    Met à jour les dernières adresses connues d'un pair : publique (`endpoint`,
    découverte par STUN), locale LAN (`local_endpoint`) — utilisée en priorité par les
    autres pairs du même sous-réseau pour éviter de dépendre du hairpin NAT du routeur
    (peu fiable en pratique, voir README) — et la LISTE complète de ses adresses locales
    (`local_endpoints`), qui seule permet de traiter une machine bi-domiciliée.

    `COALESCE` sur les trois champs : un client d'une version antérieure n'envoie pas
    `local_endpoints`, et son silence ne doit pas effacer une valeur connue. Même règle
    que pour `os_family` (point 33) et `display_name` (point 69) — c'est le SILENCE qui
    est préservé, pas la valeur.
    """
    with get_connection() as conn:
        conn.execute(
            """UPDATE peers
               SET last_endpoint = COALESCE(?, last_endpoint),
                   last_local_endpoint = COALESCE(?, last_local_endpoint),
                   last_local_endpoints = COALESCE(?, last_local_endpoints),
                   last_seen = ?
               WHERE id = ?""",
            (endpoint, local_endpoint, local_endpoints, time.time(), peer_id),
        )


def update_peer_service(
    peer_id: int,
    label: Optional[str],
    port: Optional[int],
    services: Optional[str] = None,
) -> None:
    """
    Définit (ou efface) les services locaux déclarés par ce pair — voir gui.py,
    "Mes services hébergés", diffusés aux autres pairs pour qu'ils puissent s'y
    connecter sans se coordonner manuellement.

    `services` est le JSON complet ; `label`/`port` sont l'ancien couple, conservé pour
    les clients d'une version antérieure qui ne lisent que lui. **L'appelant y met le
    premier service de type `jeu`, et rien s'il n'y en a pas** : un client ancien traite
    ce couple comme un jeu (sonde A2S, favoris Steam, `steam://connect`), donc y placer
    un service web lui ferait faire trois choses fausses. Mieux vaut qu'il ne voie rien
    que quelque chose de faux.

    Contrairement à update_peer_endpoint, c'est un SET direct et non un COALESCE : le
    client envoie toujours l'état désiré complet (déclarer OU effacer), pas une mise à
    jour partielle de signaux indépendants. Effacer doit rester possible.
    """
    with get_connection() as conn:
        conn.execute(
            "UPDATE peers SET hosted_service_label = ?, hosted_service_port = ?, "
            "hosted_services = ? WHERE id = ?",
            (label, port, services, peer_id),
        )


def list_peers(network_id: int, exclude_peer_id: Optional[int] = None) -> list[Peer]:
    with get_connection() as conn:
        rows = conn.execute(
            "SELECT * FROM peers WHERE network_id = ? AND id != ?",
            (network_id, exclude_peer_id or -1),
        ).fetchall()
        return [_peer_from_row(row) for row in rows]


def get_peer(network_id: int, peer_id: int) -> Optional[Peer]:
    """
    Un pair précis de ce réseau, ou None s'il n'existe pas / plus.

    `network_id` fait partie de la condition et n'est pas décoratif : sans lui, un
    identifiant de pair valide dans un AUTRE réseau passerait pour membre de celui-ci.
    Même raison qu'au point 61 pour `delete_peer`.
    """
    with get_connection() as conn:
        row = conn.execute(
            "SELECT * FROM peers WHERE network_id = ? AND id = ?", (network_id, peer_id)
        ).fetchone()
        return _peer_from_row(row) if row else None


def get_network_by_name(name: str) -> Optional[VirtualNetwork]:
    with get_connection() as conn:
        row = conn.execute("SELECT * FROM networks WHERE name = ?", (name,)).fetchone()
        return VirtualNetwork(**dict(row)) if row else None
