"""
Stockage des licences : inscriptions, clés, révocations.

**Base SÉPARÉE de celle de la signalisation, et service séparé.** Le point d'entrée
d'inscription est public et non authentifié — c'est exactement ce qu'on ne veut pas
voir tourner dans le processus qui détient les réseaux et les pairs. Une faille dans
le code de vente ne doit pas donner accès à la base de signalisation. Même principe
que la séparation `relay.py` / `app.py`, qui ne partagent aucune mémoire (point 86).

**Deux tables, et ce n'est pas de la décoration.** `licences` porte le porteur
d'autorisation ; `inscriptions` porte les données personnelles. Le chemin
d'ACTIVATION — celui qui tourne à chaque démarrage de client — ne touche jamais la
seconde. On peut donc dire sans réserve que l'activation ne lit pas votre courriel,
et purger les données personnelles reviendra à vider une table.

Politique de la phase de démonstration (2026-08-07), surchargeable par
l'environnement : **60 jours** de validité et **100 clés maximum**. Ce sont des
décisions de produit, pas de format — contrairement à `licence_common.py`, elles se
changent sans conséquence pour les clés déjà distribuées.
"""

from __future__ import annotations

import logging
import os
import secrets
import sqlite3
import sys
import time
from contextlib import contextmanager
from dataclasses import dataclass
from enum import Enum
from typing import Iterator, Optional

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

import config
import licence_common

# --------------------------------------------------------------------------- #
# Politique de la phase de démonstration
# --------------------------------------------------------------------------- #

# 60 jours : assez pour que quelqu'un installe, joue, oublie, revienne — et assez
# court pour que l'échéance force une reprise de contact, donc une occasion de
# récolter un retour. C'est le but de la phase.
DUREE_CLE_SECONDES = int(os.environ.get("MESHAMOTO_LICENCE_DUREE", str(60 * 86400)))

# Plafond dur, pour éprouver le serveur sur un parc connu. Compte les licences NON
# RÉVOQUÉES **et de palier `demo` uniquement** : révoquer libère donc une place, ce
# qui permet de recycler pendant la phase, et les licences permanentes — celles du
# développement — n'en consomment aucune. Le plafond borne combien d'INCONNUS entrent,
# pas combien de machines on possède.
PLAFOND_CLES = int(os.environ.get("MESHAMOTO_LICENCE_PLAFOND", "100"))

# Durée du jeton d'habilitation. Beaucoup plus courte que la clé : c'est ce qui borne
# la latence d'une révocation. Assez longue pour qu'une panne du service de licence
# ne déconnecte personne — voir l'en-tête de licence_common.py.
DUREE_JETON_SECONDES = int(os.environ.get("MESHAMOTO_LICENCE_JETON", str(7 * 86400)))

# Jeton plus long pour une licence permanente, et le compromis est différent : la
# latence de révocation n'a aucune importance sur SA PROPRE clé, tandis que la
# friction en a — une machine de développement éteinte deux semaines doit repartir
# sans dépendre du service de licence.
DUREE_JETON_PERMANENT_SECONDES = int(
    os.environ.get("MESHAMOTO_LICENCE_JETON_PERMANENT", str(30 * 86400))
)

PALIER_DEMO = "demo"

# Sans échéance, hors plafond. Réservé aux machines du projet : développement, essais,
# démonstrations. Ce n'est PAS un palier commercial — c'est l'équivalent d'une clé
# interne, et rien dans le code ne l'empêche d'en devenir un plus tard.
PALIER_PERMANENT = "permanent"


def duree_jeton(palier: str) -> int:
    """Durée du jeton selon le palier. Un seul endroit décide, pour les deux moitiés."""
    return (
        DUREE_JETON_PERMANENT_SECONDES
        if palier == PALIER_PERMANENT
        else DUREE_JETON_SECONDES
    )

# Base et clé de signature vivent hors de l'arbre de code, comme la base de
# signalisation (point 45) : le défaut « à côté du module » tombe dans /usr/share
# une fois empaqueté, en lecture seule, ou dans un dossier d'extraction temporaire
# une fois figé par PyInstaller. `config.dossier_de_base` porte déjà ce raisonnement
# et ses deux pièges — le réutiliser plutôt que de le refaire est délibéré.
_DOSSIER_DEFAUT = config.dossier_de_base(
    getattr(sys, "frozen", False), sys.executable, __file__
)
DB_PATH = os.environ.get(
    "MESHAMOTO_LICENCE_DB_PATH", os.path.join(_DOSSIER_DEFAUT, "licences.db")
)
CLE_PRIVEE_PATH = os.environ.get(
    "MESHAMOTO_LICENCE_CLE_PRIVEE", os.path.join(_DOSSIER_DEFAUT, "signature.pem")
)
CLE_PUBLIQUE_PATH = os.environ.get(
    "MESHAMOTO_LICENCE_CLE_PUBLIQUE", os.path.join(_DOSSIER_DEFAUT, "signature.pub")
)


# --------------------------------------------------------------------------- #
# Résultats nommés
# --------------------------------------------------------------------------- #


class RefusInscription(str, Enum):
    PLAFOND = "plafond"            # les 100 places sont prises
    DEJA_INSCRIT = "deja_inscrit"  # cette adresse a déjà une clé
    COURRIEL = "courriel"          # adresse manifestement invalide


class RefusActivation(str, Enum):
    """
    Quatre motifs distincts, parce qu'ils appellent quatre conduites différentes.

    `FORME` dit « vous avez mal recopié » et `INCONNUE` dit « cette clé n'existe
    pas » : les confondre enverrait quelqu'un qui a oublié un groupe de caractères
    croire que sa clé est fausse, et réclamer une réémission dont il n'a pas besoin.
    """

    FORME = "forme"
    INCONNUE = "inconnue"
    REVOQUEE = "revoquee"
    EXPIREE = "expiree"


@dataclass(frozen=True)
class Inscription:
    """La clé en clair n'existe qu'ici, une seule fois, juste après l'inscription."""

    cle: str
    licence_id: str
    expire_le: Optional[int]  # None = permanente


@dataclass(frozen=True)
class Licence:
    licence_id: str
    palier: str
    expire_le: Optional[int]  # None = permanente


@dataclass(frozen=True)
class Resume:
    """Vue d'administration — ne contient JAMAIS la clé, qui n'est pas stockée."""

    licence_id: str
    nom: str
    courriel: str
    creee_le: int
    expire_le: Optional[int]
    palier: str
    revoquee_le: Optional[int]
    activations: int


# --------------------------------------------------------------------------- #
# Connexion et schéma
# --------------------------------------------------------------------------- #


@contextmanager
def get_connection() -> Iterator[sqlite3.Connection]:
    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()


_SCHEMA_LICENCES = """
    CREATE TABLE IF NOT EXISTS licences (
        id                  TEXT PRIMARY KEY,
        empreinte           TEXT NOT NULL UNIQUE,
        palier              TEXT NOT NULL,
        creee_le            INTEGER NOT NULL,
        -- NULL = aucune échéance (palier permanent). Volontairement nullable plutôt
        -- qu'une date sentinelle très lointaine : le projet a déjà payé une sentinelle
        -- ambiguë au point 56 (0.0 pour « jamais relevé »), et « an 2100 » se lit comme
        -- une vraie date dans une liste d'administration.
        expire_le           INTEGER,
        revoquee_le         INTEGER,
        motif_revocation    TEXT,
        activations         INTEGER NOT NULL DEFAULT 0,
        derniere_activation INTEGER
    )
"""


def _migrer_expire_le_nullable(conn: sqlite3.Connection) -> bool:
    """
    Rend `expire_le` nullable sur une base créée avant les licences permanentes.

    SQLite ne sait pas retirer un NOT NULL par `ALTER TABLE` : il faut recréer la
    table. **Et c'est là qu'est le danger** — `inscriptions.licence_id` référence
    `licences(id)` avec `ON DELETE CASCADE`, et `get_connection` active
    `PRAGMA foreign_keys`. Un `DROP TABLE licences` dans ces conditions **effacerait
    toutes les inscriptions**, donc toutes les données personnelles, en silence.

    D'où la procédure officielle de SQLite, dans cet ordre exact : couper les clés
    étrangères AVANT d'ouvrir la transaction (le PRAGMA est ignoré à l'intérieur),
    recréer, vérifier l'intégrité, puis les rétablir. Un test migre une base peuplée
    et vérifie que les deux tables survivent — sans lui, le défaut ne se verrait qu'en
    production, sur les seules données qu'on ne peut pas reconstituer.

    Idempotente : appelée à chaque démarrage, elle ne fait rien si c'est déjà fait.
    """
    colonnes = {r["name"]: r for r in conn.execute("PRAGMA table_info(licences)")}
    if "expire_le" not in colonnes or not colonnes["expire_le"]["notnull"]:
        return False

    conn.execute("PRAGMA foreign_keys = OFF")
    try:
        conn.execute("BEGIN")
        conn.execute(_SCHEMA_LICENCES.replace("licences", "licences_nouvelle", 1))
        conn.execute("INSERT INTO licences_nouvelle SELECT * FROM licences")
        conn.execute("DROP TABLE licences")
        conn.execute("ALTER TABLE licences_nouvelle RENAME TO licences")
        anomalies = conn.execute("PRAGMA foreign_key_check").fetchall()
        if anomalies:
            raise RuntimeError(f"migration abandonnée, références orphelines : {anomalies}")
        conn.execute("COMMIT")
    except Exception:
        conn.execute("ROLLBACK")
        raise
    finally:
        conn.execute("PRAGMA foreign_keys = ON")
    return True


def init_db() -> None:
    with get_connection() as conn:
        conn.execute(_SCHEMA_LICENCES)
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS inscriptions (
                licence_id  TEXT PRIMARY KEY REFERENCES licences(id) ON DELETE CASCADE,
                nom         TEXT NOT NULL,
                courriel    TEXT NOT NULL UNIQUE,
                reponses    TEXT,
                inscrite_le INTEGER NOT NULL
            )
            """
        )
        # APRÈS la création des deux tables : la migration recrée `licences` et a
        # besoin qu'`inscriptions` existe pour que `foreign_key_check` ait un sens.
        if _migrer_expire_le_nullable(conn):
            logging.getLogger("meshamoto.licence").info(
                "Base migrée : `expire_le` accepte désormais NULL (licences permanentes)"
            )


def charger_ou_creer_paire() -> tuple[Ed25519PrivateKey, bool]:
    """
    Paire de signature, créée au premier démarrage si elle est absente.

    Renvoie `(clé privée, créée)`. **Le drapeau doit être journalisé par l'appelant**,
    et ce n'est pas cosmétique : si le chemin est mal configuré — variable absente
    dans l'unité systemd, donc repli sur le dossier du module — le service génère
    silencieusement une NOUVELLE paire, et tous les jetons déjà émis deviennent
    invalides sans qu'aucune erreur n'apparaisse. C'est exactement le piège de la base
    SQLite au point 45 et celui du canal de trafic du relais au point 86, tous deux
    payés une fois. Le journal doit dire « chargée » ou « CRÉÉE », avec le chemin
    absolu, à chaque démarrage.
    """
    if os.path.exists(CLE_PRIVEE_PATH):
        with open(CLE_PRIVEE_PATH, "rb") as fichier:
            return licence_common.charger_cle_privee(fichier.read()), False

    privee = Ed25519PrivateKey.generate()
    pem_privee, pem_publique = licence_common.serialiser_paire(privee)

    os.makedirs(os.path.dirname(os.path.abspath(CLE_PRIVEE_PATH)), exist_ok=True)
    # 0600 posé À LA CRÉATION et non après coup : un `chmod` qui suit l'écriture
    # laisse une fenêtre pendant laquelle la clé privée est lisible par tous.
    descripteur = os.open(CLE_PRIVEE_PATH, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    with os.fdopen(descripteur, "wb") as fichier:
        fichier.write(pem_privee)

    with open(CLE_PUBLIQUE_PATH, "wb") as fichier:
        fichier.write(pem_publique)
    os.chmod(CLE_PUBLIQUE_PATH, 0o644)  # le serveur de signalisation doit la lire

    return privee, True


# --------------------------------------------------------------------------- #
# Inscription
# --------------------------------------------------------------------------- #


def normaliser_courriel(saisie: str) -> str | None:
    """
    Minuscules et sans espaces, ou `None` si l'adresse est manifestement fautive.

    Validation volontairement grossière : la seule chose qu'on veut attraper est la
    faute de frappe évidente. Une validation stricte de RFC 5322 rejetterait des
    adresses parfaitement valides, et la vraie vérification — le fait que la personne
    lise ce qu'on lui envoie — n'existe pas dans cette phase, où la clé est affichée
    à l'écran plutôt qu'expédiée.
    """
    adresse = (saisie or "").strip().lower()
    if len(adresse) < 5 or adresse.count("@") != 1:
        return None
    local, _, domaine = adresse.partition("@")
    if not local or "." not in domaine or domaine.startswith(".") or domaine.endswith("."):
        return None
    if any(caractere.isspace() for caractere in adresse):
        return None
    return adresse


def places_restantes() -> int:
    with get_connection() as conn:
        return max(0, PLAFOND_CLES - _compter_places_demo(conn))


def _compter_places_demo(conn: sqlite3.Connection) -> int:
    """
    Licences d'ESSAI actives. Les permanentes sont exclues, délibérément : le plafond
    borne combien d'inconnus entrent pendant la phase, pas combien de machines le
    projet possède. Les compter ferait rétrécir l'essai à chaque poste de
    développement ajouté.
    """
    return conn.execute(
        "SELECT COUNT(*) FROM licences WHERE revoquee_le IS NULL AND palier = ?",
        (PALIER_DEMO,),
    ).fetchone()[0]


def inscrire(
    nom: str,
    courriel: str,
    reponses: str | None = None,
    maintenant: int | None = None,
    palier: str = PALIER_DEMO,
) -> Inscription | RefusInscription:
    """
    Crée une licence et renvoie sa clé — la seule fois où elle existe en clair.

    **Le plafond est vérifié sous `BEGIN IMMEDIATE`**, donc en prenant le verrou
    d'écriture avant de compter. Un `SELECT COUNT` suivi d'un `INSERT` hors
    transaction laisserait deux inscriptions simultanées franchir la centième place
    ensemble — improbable avec un seul processus uvicorn, mais le jour où quelqu'un
    ajoute un second worker, le plafond cesserait d'en être un sans que rien ne le
    signale.
    """
    maintenant = int(time.time() if maintenant is None else maintenant)

    adresse = normaliser_courriel(courriel)
    if adresse is None:
        return RefusInscription.COURRIEL

    with get_connection() as conn:
        conn.execute("BEGIN IMMEDIATE")

        deja = conn.execute(
            "SELECT 1 FROM inscriptions WHERE courriel = ?", (adresse,)
        ).fetchone()
        if deja is not None:
            # On ne renvoie SURTOUT PAS la licence existante : sans vérification de
            # l'adresse, n'importe qui pourrait récupérer la clé d'un tiers en tapant
            # son courriel. Le refus dit qu'une clé existe, jamais laquelle.
            #
            # La contrainte UNIQUE sur `inscriptions.courriel` est la seconde ligne de
            # défense — vérifié par mutation : en retirant ce test, l'insertion échoue
            # sur la contrainte au lieu de créer un doublon. Une double inscription
            # produirait donc une erreur, jamais une fuite de clé.
            return RefusInscription.DEJA_INSCRIT

        if palier == PALIER_DEMO and _compter_places_demo(conn) >= PLAFOND_CLES:
            return RefusInscription.PLAFOND

        cle = licence_common.generer_cle()
        licence_id = "lic-" + secrets.token_hex(8)
        # None = aucune échéance. C'est la seule différence de fond entre les deux
        # paliers ; tout le reste du parcours est identique.
        echeance = None if palier == PALIER_PERMANENT else maintenant + DUREE_CLE_SECONDES
        conn.execute(
            """
            INSERT INTO licences (id, empreinte, palier, creee_le, expire_le)
            VALUES (?, ?, ?, ?, ?)
            """,
            (
                licence_id,
                licence_common.empreinte_cle(cle),
                palier,
                maintenant,
                echeance,
            ),
        )
        conn.execute(
            """
            INSERT INTO inscriptions (licence_id, nom, courriel, reponses, inscrite_le)
            VALUES (?, ?, ?, ?, ?)
            """,
            (licence_id, (nom or "").strip()[:120], adresse, reponses, maintenant),
        )

    return Inscription(cle=cle, licence_id=licence_id, expire_le=echeance)


# --------------------------------------------------------------------------- #
# Activation
# --------------------------------------------------------------------------- #


def activer(cle_saisie: str, maintenant: int | None = None) -> Licence | RefusActivation:
    """
    Vérifie une clé et enregistre l'activation. Ne touche PAS la table des
    inscriptions : ce chemin tourne à chaque démarrage de client, il n'a aucune
    raison de lire des données personnelles.
    """
    maintenant = int(time.time() if maintenant is None else maintenant)

    canonique = licence_common.normaliser_cle(cle_saisie)
    if canonique is None:
        return RefusActivation.FORME

    with get_connection() as conn:
        ligne = conn.execute(
            "SELECT id, palier, expire_le, revoquee_le FROM licences WHERE empreinte = ?",
            (licence_common.empreinte_cle(canonique),),
        ).fetchone()

        if ligne is None:
            return RefusActivation.INCONNUE
        if ligne["revoquee_le"] is not None:
            return RefusActivation.REVOQUEE
        # `expire_le` nul = permanente. Le test doit être écrit dans CE sens : un
        # `maintenant >= None` lèverait, et un `or 0` la ferait expirer aussitôt.
        if ligne["expire_le"] is not None and maintenant >= ligne["expire_le"]:
            return RefusActivation.EXPIREE

        conn.execute(
            """
            UPDATE licences
               SET activations = activations + 1, derniere_activation = ?
             WHERE id = ?
            """,
            (maintenant, ligne["id"]),
        )
        return Licence(
            licence_id=ligne["id"], palier=ligne["palier"], expire_le=ligne["expire_le"]
        )


# --------------------------------------------------------------------------- #
# Administration — en ligne de commande, jamais par HTTP
# --------------------------------------------------------------------------- #
#
# Aucun point d'entrée d'administration n'est exposé sur le réseau, et c'est un
# choix : une surface d'administration demande une authentification, donc un
# mécanisme de plus à ne pas rater. Sur un parc de 100 clés, un accès SSH au VPS
# fait le même travail sans rien exposer.


def lister(maintenant: int | None = None) -> list[Resume]:
    with get_connection() as conn:
        lignes = conn.execute(
            """
            SELECT l.id, l.creee_le, l.expire_le, l.palier, l.revoquee_le,
                   l.activations, i.nom, i.courriel
              FROM licences l
              LEFT JOIN inscriptions i ON i.licence_id = l.id
             ORDER BY l.creee_le
            """
        ).fetchall()
    return [
        Resume(
            licence_id=r["id"],
            nom=r["nom"] or "",
            courriel=r["courriel"] or "",
            creee_le=r["creee_le"],
            expire_le=r["expire_le"],
            palier=r["palier"],
            revoquee_le=r["revoquee_le"],
            activations=r["activations"],
        )
        for r in lignes
    ]


def revoquer(licence_id: str, motif: str = "", maintenant: int | None = None) -> bool:
    """Révoque une licence et LIBÈRE sa place sous le plafond."""
    maintenant = int(time.time() if maintenant is None else maintenant)
    with get_connection() as conn:
        curseur = conn.execute(
            "UPDATE licences SET revoquee_le = ?, motif_revocation = ? "
            "WHERE id = ? AND revoquee_le IS NULL",
            (maintenant, motif, licence_id),
        )
        return curseur.rowcount > 0


def prolonger(licence_id: str, jours: int, maintenant: int | None = None) -> bool:
    """
    Repousse l'échéance à partir de MAINTENANT, pas de l'ancienne échéance.

    Une licence déjà expirée doit repartir pour la durée entière ; ajouter les jours
    à une date passée pourrait la laisser expirée, ce qui donnerait une commande sans
    effet visible.
    """
    maintenant = int(time.time() if maintenant is None else maintenant)
    with get_connection() as conn:
        # `palier <> permanent` dans la condition : prolonger une licence SANS échéance
        # lui en donnerait une, donc la RÉTROGRADERAIT — l'inverse de ce que la commande
        # promet. Une garde silencieuse serait pire qu'un refus : l'administrateur
        # croirait avoir prolongé ce qu'il vient de limiter.
        curseur = conn.execute(
            "UPDATE licences SET expire_le = ? "
            "WHERE id = ? AND revoquee_le IS NULL AND palier <> ?",
            (maintenant + jours * 86400, licence_id, PALIER_PERMANENT),
        )
        return curseur.rowcount > 0


def _cli() -> int:
    import argparse
    import datetime

    analyseur = argparse.ArgumentParser(description="Administration des licences.")
    sous = analyseur.add_subparsers(dest="commande", required=True)
    sous.add_parser("places", help="places restantes sous le plafond")
    sous.add_parser("lister", help="toutes les licences")
    p_em = sous.add_parser(
        "emettre", help="émet une clé à la main (usage interne : développement, essais)"
    )
    p_em.add_argument("--nom", required=True)
    p_em.add_argument("--courriel", required=True)
    p_em.add_argument(
        "--palier", choices=[PALIER_DEMO, PALIER_PERMANENT], default=PALIER_PERMANENT
    )
    p_rev = sous.add_parser("revoquer", help="révoque une licence et libère sa place")
    p_rev.add_argument("licence_id")
    p_rev.add_argument("--motif", default="")
    p_pro = sous.add_parser("prolonger", help="repousse l'échéance depuis maintenant")
    p_pro.add_argument("licence_id")
    p_pro.add_argument("--jours", type=int, default=60)
    args = analyseur.parse_args()

    init_db()
    if args.commande == "places":
        print(f"{places_restantes()} place(s) restante(s) sur {PLAFOND_CLES}")
    elif args.commande == "lister":
        for r in lister():
            if r.revoquee_le:
                etat = "REVOQUEE"
            elif r.expire_le is None:
                etat = "PERMANENTE"
            else:
                etat = f"jusqu'au {datetime.date.fromtimestamp(r.expire_le).isoformat()}"
            print(
                f"{r.licence_id}  {r.palier:>10}  {etat:>22}  "
                f"{r.activations:>3} act.  {r.nom}"
            )
    elif args.commande == "emettre":
        resultat = inscrire(args.nom, args.courriel, palier=args.palier)
        if isinstance(resultat, RefusInscription):
            print(f"refusé : {resultat.value}")
            return 1
        # La clé n'est affichée QU'ICI : elle est stockée hachée, on ne peut pas la
        # relire. Perdue, elle se révoque et se réémet.
        echeance = "aucune (permanente)" if resultat.expire_le is None else (
            datetime.date.fromtimestamp(resultat.expire_le).isoformat()
        )
        print(f"  clé      : {resultat.cle}")
        print(f"  licence  : {resultat.licence_id}")
        print(f"  palier   : {args.palier}")
        print(f"  échéance : {echeance}")
        print("  Notez-la : elle ne sera plus jamais affichée.")
    elif args.commande == "revoquer":
        print("révoquée" if revoquer(args.licence_id, args.motif) else "introuvable")
    elif args.commande == "prolonger":
        print("prolongée" if prolonger(args.licence_id, args.jours) else "introuvable")
    return 0


if __name__ == "__main__":
    raise SystemExit(_cli())
