#!/usr/bin/env python3
"""
Passe API du cahier de recette — les étapes qui ne demandent pas d'écran.

    RECETTE_PASSWORD='…' python3 tools/recette-api/passe_api.py

Sur les 133 étapes du cahier, une cinquantaine portent sur une DONNÉE, un DROIT
ou une ABSENCE : elles se jouent par l'API, sans navigateur. C'est ce que ce
script fait, et rien de plus.

**Une vérification ici ne suit pas toujours une étape du cahier.** Deux d'entre
elles n'en ont pas : `v_idor_fiche_apprenant` forge un geste que l'interface ne
propose jamais, et `v_liste_et_detail_accordes` compare deux routes qu'aucun
parcours ne confronte. Un cahier écrit du point de vue de l'utilisateur ne peut
pas décrire une fuite — l'écran ne l'offre pas — ni un désaccord entre deux
routes dont chacune, prise seule, répond correctement.

**Pourquoi le faire avant la passe navigateur.** Chaque appel porte son propre
jeton : aucune session partagée, donc aucune collision, donc parallélisme réel.
Là où la passe navigateur prendra une journée à cause des changements de compte,
celle-ci prend quelques minutes — et elle élimine le bruit. Inutile d'ouvrir un
écran pour découvrir que la route derrière renvoie 500.

**Ce qu'elle NE teste pas**, et qu'il ne faut pas lui demander :

  - la logique interne du back — c'est le rôle des 1 326 tests PHPUnit ;
  - les migrations, les courriels, les traitements de fond ;
  - l'exhaustivité des routes — seulement ce que les parcours traversent ;
  - tout ce qui s'AFFICHE. Une route qui répond 200 ne dit rien de l'écran.

**Convention de sortie** : la même que le cahier — OK / KO / BUG, avec le
numéro d'étape. Un KO désigne une issue connue, un BUG n'en a pas encore.
"""

import concurrent.futures
import json
import os
import sys
import urllib.error
import urllib.request

API = os.environ.get(
    "RECETTE_API",
    "https://api-staging.efektiv-academie-dev.com/e-learning-api/public/api",
)
FRONT = os.environ.get("RECETTE_FRONT", "https://staging.efektiv-academie-dev.com")
MOT_DE_PASSE = os.environ.get("RECETTE_PASSWORD")

# Le jeu de comptes réservé à Claude. Les trois autres — david, eng, lilian —
# appartiennent aux testeurs humains : jouer sur les leurs modifierait les
# données sous leurs yeux pendant qu'ils recettent.
QUI = os.environ.get("RECETTE_QUI", "claude")

ROLES = {
    "admin": "Administrateur",
    "managerrh": "Manager RH",
    "managergeneral": "Manager Général",
    "directeur": "Directeur",
    "manager": "Manager d'équipe",
    "formateur": "Formateur",
    "apprenant": "Apprenant",
}


def adresse(role):
    return f"recette-{QUI}-{role}@yopmail.com"


def appel(chemin, jeton=None, methode="GET", corps=None, base=None):
    """Un appel d'API. Rend (code, données) — jamais d'exception qui masque un cas."""
    url = (base or API) + chemin
    donnees = json.dumps(corps).encode() if corps is not None else None
    requete = urllib.request.Request(url, data=donnees, method=methode)
    requete.add_header("Accept", "application/json")
    if donnees:
        requete.add_header("Content-Type", "application/json")
    if jeton:
        requete.add_header("Authorization", f"Bearer {jeton}")

    try:
        with urllib.request.urlopen(requete, timeout=30) as reponse:
            brut = reponse.read()
            try:
                return reponse.status, json.loads(brut or b"{}")
            except json.JSONDecodeError:
                # Le front est une application d'une seule page : il rend du
                # HTML pour TOUTE adresse. Ce n'est pas une panne.
                return reponse.status, {"html": True, "taille": len(brut)}
    except urllib.error.HTTPError as e:
        # Un 401 ou un 403 est un RÉSULTAT, pas une panne : c'est souvent
        # exactement ce qu'on vient vérifier.
        corps_erreur = e.read()
        try:
            return e.code, json.loads(corps_erreur or b"{}")
        except json.JSONDecodeError:
            return e.code, {"brut": corps_erreur[:200].decode(errors="replace")}
    except Exception as e:  # réseau, DNS, délai
        return 0, {"erreur": str(e)}


def connexion(role):
    """Un jeton par rôle. C'est ce qui rend le parallélisme possible."""
    code, d = appel(
        "/session/login",
        methode="POST",
        corps={"email": adresse(role), "password": MOT_DE_PASSE},
    )
    if code != 200:
        return None
    return d.get("token") or (d.get("data") or {}).get("token")


# ── Les vérifications ───────────────────────────────────────────────────────
#
# Chacune rend (marqueur, étape, message). Le marqueur suit la convention du
# cahier. Une vérification ne « plante » jamais : elle constate.

def v_connexion_par_role(jetons):
    """P0.3 — chaque rôle obtient un jeton."""
    resultats = []
    for role in ROLES:
        if jetons.get(role):
            resultats.append(("OK", "P0.3", f"{ROLES[role]} se connecte"))
        else:
            resultats.append(
                ("BUG", "P0.3", f"{ROLES[role]} NE PEUT PAS se connecter — vital")
            )
    return resultats


def v_mauvais_mot_de_passe(_):
    """P0.2 — le refus ne doit pas révéler si l'adresse existe."""
    code, d = appel(
        "/session/login",
        methode="POST",
        corps={"email": adresse("admin"), "password": "manifestement-faux"},
    )
    msg = str(d.get("message", "")).lower()
    if code == 200:
        return [("BUG", "P0.2", "un mot de passe FAUX est accepté — vital")]

    # Le message ne doit pas distinguer « compte inconnu » de « mot de passe faux ».
    code2, d2 = appel(
        "/session/login",
        methode="POST",
        corps={"email": "personne-nexiste-pas@yopmail.com", "password": "x"},
    )
    msg2 = str(d2.get("message", "")).lower()
    if msg and msg2 and msg != msg2:
        # KO et non BUG : l'issue existe depuis le premier passage (BACK#448).
        # Un BUG désigne un défaut SANS issue ; le laisser en BUG faisait sortir
        # le script en 1 à chaque exécution et noyait les vrais défauts nouveaux.
        return [
            (
                "KO",
                "P0.2",
                f"le message DIFFÈRE selon que le compte existe — BACK#448, attendu "
                f"(« {msg[:40]} » contre « {msg2[:40]} »)",
            )
        ]
    return [("OK", "P0.2", "refus explicite, sans révéler l'existence du compte")]


def v_sans_jeton(_):
    """P0.8 / P9.12 — aucune donnée sans authentification."""
    resultats = []
    for chemin, etape in [
        ("/moi/profil", "P0.8"),
        ("/learner/certificates", "P9.12"),
        ("/manager/collaborators", "P9.12"),
    ]:
        code, _d = appel(chemin)
        if code in (401, 403):
            resultats.append(("OK", etape, f"{chemin} refuse sans jeton ({code})"))
        elif code == 0:
            resultats.append(("BUG", etape, f"{chemin} injoignable"))
        else:
            resultats.append(
                ("BUG", etape, f"{chemin} répond {code} SANS jeton — vital")
            )
    return resultats


def v_progression_non_apprenant(jetons):
    """
    P2.17 / BACK#447 — la progression doit être servie à TOUT INSCRIT.

    C'est l'étape qui a fait naître l'issue : `progress` était conditionné au
    RÔLE `learner`, pas à l'inscription. 16 personnes inscrites sur staging ne
    voyaient donc jamais où elles en étaient.
    """
    jeton = jetons.get("manager")
    if not jeton:
        return [("BUG", "P2.17", "pas de jeton Manager d'équipe — vérification impossible")]

    # POST, pas GET — relevé dans `CourseService.jsx:157`. Un premier essai en
    # GET rendait 405 « méthode non autorisée », que j'avais lu comme « route
    # inconnue » : le code disait exactement ce qu'il fallait entendre.
    code, d = appel("/learner/my-courses?page=1&search=", jeton=jeton, methode="POST", corps={})
    if code != 200:
        return [("KO", "P2.17", f"la liste des cours répond {code} pour un manager — BACK#447")]

    # La clé est `Course`, avec une majuscule — relevée sur la réponse réelle.
    # Cinquième clé supposée de ce script, et la dernière : les noms se lisent,
    # ils ne se devinent pas.
    cours = d.get("Course") or d.get("data") or {}
    liste = cours.get("data") if isinstance(cours, dict) else cours
    if not liste:
        return [
            ("OK", "P2.17", "ce manager n'est inscrit à aucune formation — cas non éprouvé ici")
        ]

    sans_progression = [c for c in liste if c.get("progress") is None]
    if sans_progression:
        return [
            (
                "KO",
                "P2.17",
                f"{len(sans_progression)} formation(s) sans progression pour un manager "
                f"inscrit — BACK#447, VITAL",
            )
        ]
    return [("OK", "P2.17", "la progression est servie à un manager inscrit")]


def v_cloisonnement(jetons):
    """
    P3.10 / P5.17 — un manager d'équipe ne voit QUE ses rattachés.

    Test de sécurité : une fuite ici n'est pas un défaut d'affichage.
    """
    resultats = []
    rh, equipe = jetons.get("managerrh"), jetons.get("manager")
    if not (rh and equipe):
        return [("BUG", "P3.10", "jetons manquants — vérification impossible")]

    def compter(jeton):
        code, d = appel("/manager/collaborators", jeton=jeton)
        if code != 200:
            return None
        # La clé est `collaborators`, pas `data` — relevée sur la réponse réelle.
        # La supposer a produit un faux BUG : le script concluait « ne répond
        # pas » sur une route qui répondait très bien.
        bloc = d.get("collaborators") or d.get("data") or {}
        liste = bloc.get("data") if isinstance(bloc, dict) else bloc
        return len(liste) if isinstance(liste, list) else None

    n_rh, n_equipe = compter(rh), compter(equipe)
    if n_rh is None or n_equipe is None:
        resultats.append(("BUG", "P3.10", "la liste des collaborateurs ne répond pas"))
    elif n_equipe > n_rh:
        resultats.append(
            (
                "BUG",
                "P3.10",
                f"le Manager d'équipe voit PLUS que le Manager RH ({n_equipe} > {n_rh}) — vital",
            )
        )
    else:
        resultats.append(
            ("OK", "P3.10", f"périmètres distincts — RH {n_rh}, équipe {n_equipe}")
        )
    return resultats


def v_filtre_ne_donne_jamais_plus(jetons):
    """
    P5.5 — un filtre RESTREINT, il n'élargit jamais.

    L'invariant est écrit dans `DashboardScope` : « les filtres ne peuvent que
    RESTREINDRE ». Une affirmation de sécurité mérite d'être éprouvée.

    ⚠️ **Cette vérification avait un angle mort, et il était total.** Elle ne
    posait qu'une question — « le filtre élargit-il ? » — à laquelle un filtre
    qui rend TOUJOURS ZÉRO répond parfaitement : 0 ≤ 52, invariant respecté,
    OK vert. C'est exactement ce que fait le filtre société aujourd'hui, sur
    les quatre sociétés, y compris celles qui comptent 23 personnes.

    La leçon dépasse ce script : **un test de sécurité formulé comme une borne
    (« jamais plus que ») est satisfait par une fonction cassée qui ne rend
    rien.** Il faut donc DEUX questions, et c'est `v_filtre_restreint_vraiment`
    qui pose la seconde.
    """
    jeton = jetons.get("managerrh")
    if not jeton:
        return [("BUG", "P5.5", "pas de jeton — vérification impossible")]

    code_sans, d_sans = appel("/manager/pilotage/dashboard", jeton=jeton)
    if code_sans != 200:
        return [("BUG", "P5.5", f"le tableau de bord répond {code_sans}")]

    # Un identifiant inconnu doit être REFUSÉ (422), pas silencieusement ignoré :
    # un filtre ignoré rendrait le périmètre entier en prétendant l'avoir réduit.
    code_faux, _ = appel("/manager/pilotage/dashboard?company_id=999999", jeton=jeton)
    resultats = []
    if code_faux == 422:
        resultats.append(("OK", "P5.5", "un company_id inconnu est refusé (422), pas ignoré"))
    elif code_faux == 200:
        resultats.append(
            ("BUG", "P5.5", "un company_id inconnu est ACCEPTÉ — le filtre est ignoré en silence")
        )
    else:
        resultats.append(("BUG", "P5.5", f"un company_id inconnu fait répondre {code_faux}"))

    base = _learners(d_sans)
    for sid, nom, _n in _societes(jetons):
        code, d = appel(f"/manager/pilotage/dashboard?company_id={sid}", jeton=jeton)
        if code != 200:
            continue
        v = _learners(d)
        if base is not None and v is not None and v > base:
            resultats.append(
                ("BUG", "P5.5", f"filtrer sur « {nom} » ÉLARGIT le périmètre ({v} > {base}) — vital")
            )
    if len(resultats) == 1:
        resultats.append(("OK", "P5.5", f"aucun filtre société n'élargit le périmètre (base {base})"))
    return resultats


def _learners(d):
    """Le compte de personnes du tableau de bord — clé relevée sur la réponse réelle."""
    p = d.get("scope") or {}
    return p.get("learners") if isinstance(p, dict) else None


def _societes(jetons):
    """Les sociétés réelles, lues au référentiel. Aucun identifiant en dur."""
    code, d = appel("/manager/referentials/companies", jeton=jetons.get("managerrh"))
    if code != 200:
        return []
    return [(s["id"], s.get("name"), s.get("users_count")) for s in d.get("data", [])]


def v_filtre_restreint_vraiment(jetons):
    """
    P5.5 (second volet) — le filtre société rend-il autre chose que ZÉRO ?

    La question que l'invariant de sécurité ne pose pas. Une société qui compte
    des personnes rattachées doit produire un tableau de bord non vide pour qui
    voit ces personnes sans filtre. Sinon le filtre n'est pas une restriction :
    c'est un effacement, et le manager lit « personne ne se forme » sur un
    périmètre plein.
    """
    jeton = jetons.get("managerrh")
    if not jeton:
        return [("BUG", "P5.5b", "pas de jeton Manager RH")]

    code, d = appel("/manager/pilotage/dashboard", jeton=jeton)
    if code != 200:
        return [("BUG", "P5.5b", f"le tableau de bord répond {code}")]
    base = _learners(d)
    if not base:
        return [("OK", "P5.5b", "périmètre vide sans filtre — cas non éprouvé ici")]

    peuplees = [(i, n, c) for i, n, c in _societes(jetons) if c]
    if not peuplees:
        return [("OK", "P5.5b", "aucune société peuplée au référentiel — cas non éprouvé")]

    vides = []
    for sid, nom, n in peuplees:
        c, dd = appel(f"/manager/pilotage/dashboard?company_id={sid}", jeton=jeton)
        if c == 200 and not _learners(dd):
            vides.append(f"{nom} ({n} personnes)")

    if len(vides) == len(peuplees):
        return [
            (
                "BUG",
                "P5.5b",
                f"le filtre société rend ZÉRO sur TOUTES les sociétés peuplées "
                f"({', '.join(vides)}) alors que le périmètre non filtré en compte {base} — majeur",
            )
        ]
    if vides:
        return [("BUG", "P5.5b", f"le filtre société rend zéro sur : {', '.join(vides)}")]
    return [("OK", "P5.5b", f"chaque société peuplée rend un tableau de bord non vide")]


def v_colonnes_dashboard(jetons):
    """P5.4 / BACK#328 — les quatre colonnes ne doivent plus être vides."""
    jeton = jetons.get("managerrh")
    if not jeton:
        return [("BUG", "P5.4", "pas de jeton")]

    code, d = appel("/manager/pilotage/dashboard", jeton=jeton)
    if code != 200:
        code, d = appel("/manager/pilotage/dashboard", jeton=jeton)
    if code != 200:
        return [("BUG", "P5.4", f"le tableau par module répond {code}")]

    brut = json.dumps(d)
    attendues = ["views", "active_learners", "average_seconds", "average_quiz_score"]
    absentes = [c for c in attendues if f'"{c}"' not in brut]
    if absentes:
        return [("KO", "P5.4", f"colonnes absentes de la réponse : {', '.join(absentes)} — BACK#328")]
    return [("OK", "P5.4", "les quatre colonnes sont servies")]


def v_routes_supprimees(_):
    """
    P9 — ce qui doit être ABSENT.

    ⚠️ Ces routes sont servies par le FRONT (une application d'une seule page) :
    le serveur rend `index.html` pour toute adresse, et c'est le routeur qui
    décide. Un 200 ne prouve donc RIEN ici — seule la passe navigateur peut
    trancher. On vérifie seulement que le serveur ne rend pas d'erreur.
    """
    resultats = []
    for chemin, etape in [
        ("/catalog", "P9.3"),
        ("/plans", "P9.4"),
        ("/signup", "P9.5"),
        ("/edit-quiz/1", "P9.7"),
    ]:
        code, _ = appel(chemin, base=FRONT)
        if code == 0:
            resultats.append(("BUG", etape, f"{chemin} injoignable"))
        else:
            resultats.append(
                ("OK", etape, f"{chemin} — HTTP {code}, à confirmer AU NAVIGATEUR")
            )
    return resultats


def v_chat_ferme(jetons):
    """P1.16 / P9.1 / BACK#391 — la fermeture du chat est SERVEUR, pas visuelle."""
    jeton = jetons.get("apprenant")
    if not jeton:
        return [("BUG", "P9.1", "pas de jeton apprenant")]

    code, d = appel("/chat/users", jeton=jeton)
    if code in (403, 404):
        return [("OK", "P9.1", f"le chat est fermé côté serveur ({code})")]
    if code == 200:
        return [("BUG", "P9.1", "le chat RÉPOND encore à un apprenant — masquage visuel seulement")]
    return [("OK", "P9.1", f"le chat répond {code}")]


def v_manques_formateur(jetons):
    """P7.3 à P7.7 — quatre manques back tracés. Des KO sont ATTENDUS."""
    jeton = jetons.get("formateur")
    if not jeton:
        return [("BUG", "P7.3", "pas de jeton formateur")]

    resultats = []
    code, d = appel("/teacher/my-course", jeton=jeton)
    if code != 200:
        return [("BUG", "P7.3", f"/teacher/my-course répond {code}")]

    brut = json.dumps(d)
    if '"updated_at"' in brut:
        resultats.append(("OK", "P7.3", "updated_at est servi — BACK#434 serait résolue"))
    else:
        resultats.append(("KO", "P7.3", "updated_at absent — BACK#434, attendu"))

    if '"sub_category"' in brut:
        resultats.append(("OK", "P7.4", "sub_category est chargée — BACK#435 serait résolue"))
    else:
        resultats.append(("KO", "P7.4", "sub_category absente — BACK#435, attendu"))

    code_e, _ = appel("/teacher/my-course/export", jeton=jeton)
    if code_e in (404, 405):
        resultats.append(("KO", "P7.5", f"aucune route d'export ({code_e}) — BACK#436, attendu"))
    else:
        resultats.append(("OK", "P7.5", f"une route d'export répond {code_e}"))

    return resultats


def v_bornes_invitation(jetons):
    """
    P1.2 / P3.3 / BACK#403 — 5 destinataires, 400 caractères.

    ⚠️ SIXIÈME nom supposé, et le plus instructif : la route écrite ici était
    `/manager/invitation-by-email`, qui n'existe pas. Elle rendait 404, et le
    script concluait « à confirmer au navigateur » — un OK de complaisance sur
    une vérification qui n'avait rien vérifié. Un 404 sur une route qu'on a
    inventée ne dit RIEN du produit ; il dit qu'on n'a pas lu `route:list`.

    La vraie route est `POST api/invite-mail` (relevée dans route:list).

    ⚠️ **Et un SEPTIÈME faux positif, le plus retors : un 422 lu sans son motif.**
    Une fois la vraie route en place, six destinataires rendaient bien 422 — le
    script a conclu « la borne tient ». Elle ne tient pas : le 422 disait « Le
    message ne peut pas être vide », et **un seul** destinataire rendait le même
    422. Le refus portait sur un autre champ. Un code de retour n'est pas un
    verdict ; c'est le motif qui l'est.

    On ne lève pas l'ambiguïté en envoyant six invitations pour voir — cela
    part réellement par courriel (BACK#316 : la clé SMTP est partagée). On la
    lève en demandant au serveur de se contredire : si le seul grief contre six
    destinataires est le même que contre un seul, aucune borne n'est posée.
    """
    jeton = jetons.get("managerrh")
    if not jeton:
        return [("BUG", "P1.2", "pas de jeton Manager RH")]

    def griefs(n):
        corps = {"emails": [f"recette-borne-{i}@yopmail.com" for i in range(n)]}
        code, d = appel("/invite-mail", jeton=jeton, methode="POST", corps=corps)
        return code, set((d.get("errors") or {}).keys())

    code6, g6 = griefs(6)
    code1, g1 = griefs(1)

    if code6 == 404:
        return [("BUG", "P1.2", "/invite-mail rend 404 — la route lue dans route:list a disparu")]
    if code6 in (200, 201):
        return [("KO", "P1.2", "SIX destinataires acceptés — le serveur ne borne rien, BACK#403")]
    if "emails" in g6 and "emails" not in g1:
        return [("OK", "P1.2", "six destinataires refusés SUR LE CHAMP emails — la borne tient")]
    return [
        (
            "KO",
            "P1.2",
            f"le serveur ne reproche RIEN à six destinataires qu'il ne reproche déjà à un seul "
            f"(griefs identiques : {', '.join(sorted(g6)) or 'aucun'}) — aucune borne de 5, BACK#403",
        )
    ]


# ── P3 / P5 — le cloisonnement, et la porte laissée ouverte ─────────────────

def _gens(jetons, chemin="/manager/all-collaborators"):
    """Les personnes visibles par le Manager RH — la population de référence."""
    code, d = appel(chemin, jeton=jetons.get("managerrh"))
    if code != 200:
        return []
    bloc = d.get("collaborators") or {}
    return [(u["id"], u.get("email")) for u in (bloc.get("data") or [])]


def _mes_rattaches(jetons):
    code, d = appel("/manager/collaborators", jeton=jetons.get("manager"))
    if code != 200:
        return []
    return [u["id"] for u in ((d.get("collaborators") or {}).get("data") or [])]


def v_onglet_tous_collaborateurs(jetons):
    """
    P3.9 / P3.10 / P5.17 / BACK#63 — « Tous les collaborateurs » est réservé.

    Le cahier le dit vital : un manager d'équipe qui voit toute la plateforme
    est une fuite, pas un défaut d'affichage.
    """
    resultats = []
    attendu = {"admin": 200, "managerrh": 200, "manager": 403, "directeur": 403,
               "managergeneral": 403, "formateur": 403, "apprenant": 403}
    for role, veut in attendu.items():
        jeton = jetons.get(role)
        if not jeton:
            resultats.append(("BUG", "P3.9", f"pas de jeton {ROLES[role]}"))
            continue
        code, _ = appel("/manager/all-collaborators", jeton=jeton)
        etape = "P3.10" if veut == 403 else "P3.9"
        if code == veut:
            mot = "y accède" if veut == 200 else "en est écarté"
            resultats.append(("OK", etape, f"{ROLES[role]} {mot} ({code})"))
        elif veut == 403:
            resultats.append(
                ("BUG", etape, f"{ROLES[role]} ATTEINT « Tous les collaborateurs » ({code}) — vital")
            )
        else:
            resultats.append(("BUG", etape, f"{ROLES[role]} ne peut pas y accéder ({code})"))
    return resultats


def v_idor_fiche_apprenant(jetons):
    """
    P5.11 / P5.17 — consulter QUELQU'UN D'AUTRE en changeant un identifiant.

    `CockpitController` porte la règle en toutes lettres : « un manager d'équipe
    ne doit pas pouvoir ouvrir le cockpit de n'importe qui en changeant un
    identifiant dans l'URL », et l'applique via `HierarchyResolver`. Cette
    vérification demande si les routes VOISINES, qui servent les mêmes données
    sur les mêmes personnes, portent la même garde.

    Le cahier n'a pas d'étape pour ça : une fuite ne se voit pas en jouant un
    parcours, puisque l'interface ne propose jamais le geste. Il faut le forger.
    """
    jeton = jetons.get("manager")
    if not jeton:
        return [("BUG", "P5.11", "pas de jeton Manager d'équipe")]

    miens = set(_mes_rattaches(jetons))
    etrangers = [(i, e) for i, e in _gens(jetons) if i not in miens][:8]
    if not etrangers:
        return [("OK", "P5.11", "aucun étranger au périmètre — cas non éprouvé ici")]

    # Chaque route sert la même personne sous un angle différent. Elles doivent
    # donc répondre la MÊME chose à la question « ai-je le droit de la voir ? ».
    routes = {
        "/manager/learner/detail/{id}": "sa fiche et son état civil",
        "/manager/learners/{id}/progress": "son avancement de formation",
        "/manager/users/{id}/enrollments": "ses inscriptions",
        "/tracking/learner/{id}": "son suivi",
        "/manager/cockpit/{id}/1": "son cockpit",
        # #512 (19/08) — la porte sœur oubliée de l'énumération : user/show servait
        # l'identité + les inscriptions sans garde. Inscrite ici pour toujours.
        "/submanager/user/show/{id}": "sa fiche détaillée (user/show)",
    }
    resultats = []
    for gabarit, quoi in routes.items():
        servis = [
            (i, e) for i, e in etrangers
            if appel(gabarit.replace("{id}", str(i)), jeton=jeton)[0] == 200
        ]
        if servis:
            exemple = servis[0][1]
            resultats.append(
                (
                    "BUG",
                    "P5.11",
                    f"FUITE — {gabarit} sert {quoi} de {len(servis)}/{len(etrangers)} personnes "
                    f"hors périmètre au Manager d'équipe (ex. {exemple}) — confidentialité",
                )
            )
        else:
            resultats.append(("OK", "P5.11", f"{gabarit} refuse hors périmètre"))
    return resultats


# ── P4 — inscrire et traiter les demandes ───────────────────────────────────

def _demandes(jeton, requete=""):
    code, d = appel(f"/manager/course/enrollments/requestList{requete}", jeton=jeton)
    if code != 200:
        return code, None
    # La clé est `enrollments` — relevée sur la réponse réelle, jamais devinée.
    return code, (d.get("enrollments") or {})


def v_demandes_inscription(jetons):
    """P4.6 — la liste des demandes répond, pour les rôles qui y ont droit."""
    resultats = []
    for role in ["admin", "managerrh", "manager", "directeur", "managergeneral"]:
        jeton = jetons.get(role)
        if not jeton:
            continue
        code, bloc = _demandes(jeton)
        if code == 200:
            resultats.append(("OK", "P4.6", f"{ROLES[role]} — {bloc.get('total')} demande(s)"))
        elif code >= 500:
            resultats.append(
                ("BUG", "P4.6", f"la liste des demandes rend {code} à {ROLES[role]} — écran mort")
            )
        else:
            resultats.append(("BUG", "P4.6", f"la liste des demandes rend {code} à {ROLES[role]}"))
    return resultats


def v_filtre_statut_demandes(jetons):
    """
    P4.7 / BACK#410 — le paramètre `status` est lu et jeté. `KO` attendu.

    Éprouvé en comparant DEUX filtres contradictoires : s'ils rendent la même
    liste, le paramètre ne sert à rien. C'est plus solide que de regarder si la
    liste est « cohérente » — elle l'est toujours quand on l'ignore.
    """
    jeton = jetons.get("managerrh")
    if not jeton:
        return [("BUG", "P4.7", "pas de jeton Manager RH")]

    _, a = _demandes(jeton, "?status=Pending")
    _, b = _demandes(jeton, "?status=Rejected")
    if a is None or b is None:
        return [("BUG", "P4.7", "la liste des demandes ne répond pas")]

    ids_a = [x["id"] for x in (a.get("data") or [])]
    ids_b = [x["id"] for x in (b.get("data") or [])]
    if ids_a and ids_a == ids_b:
        statuts = sorted({x.get("status") for x in (a.get("data") or [])})
        return [
            (
                "KO",
                "P4.7",
                f"« Pending » et « Rejected » rendent la MÊME liste (statuts servis : "
                f"{', '.join(statuts)}) — le paramètre est jeté, BACK#410, attendu",
            )
        ]
    return [("OK", "P4.7", "le filtre de statut change la liste — BACK#410 serait résolue")]


def v_trace_decision(jetons):
    """
    P4.9 / BACK#411 — qui a accepté, et quand ? `KO` attendu.

    Le cahier le rattache à la preuve QUALIOPI : une inscription acceptée sans
    trace de qui l'a décidée est un trou dans le dossier de formation.
    """
    jeton = jetons.get("managerrh")
    if not jeton:
        return [("BUG", "P4.9", "pas de jeton Manager RH")]

    _, bloc = _demandes(jeton)
    lignes = (bloc or {}).get("data") or []
    if not lignes:
        return [("OK", "P4.9", "aucune demande — cas non éprouvé ici")]

    champs = set(lignes[0].keys())
    traces = {"approved_by", "decided_by", "updated_by", "handled_by", "decided_at", "updated_at"}
    trouves = champs & traces
    if trouves:
        return [("OK", "P4.9", f"trace servie : {', '.join(sorted(trouves))} — BACK#411 résolue")]
    return [
        (
            "KO",
            "P4.9",
            f"aucune trace de décision — la ligne ne sert que {', '.join(sorted(champs))} "
            f"(BACK#411, attendu, touche la preuve QUALIOPI)",
        )
    ]


# ── P6 — publication et slug ────────────────────────────────────────────────

def _cours_admin(jetons):
    code, d = appel("/admin/courses?per_page=100", jeton=jetons.get("admin"))
    if code != 200:
        return []
    return (d.get("Course") or {}).get("data") or []


def v_slug_et_publication(jetons):
    """
    P6.11 / P6.12 / BACK#337 — un cours PUBLIÉ s'ouvre par son slug.

    ⚠️ Le premier essai a conclu « le slug ne résout rien » sur trois cours qui
    rendaient 404. Ils étaient tous NON PUBLIÉS : le 404 était le bon
    comportement, pas une panne. Il a fallu séparer les deux populations pour
    que la mesure veuille dire quelque chose. **Un 404 ne se lit pas sans savoir
    ce qu'on a demandé.**
    """
    jeton = jetons.get("apprenant")
    cours = _cours_admin(jetons)
    if not (jeton and cours):
        return [("BUG", "P6.12", "pas de jeton apprenant ou pas de cours")]

    publies = [c for c in cours if str(c.get("status")) == "1"]
    if not publies:
        return [("OK", "P6.12", "aucun cours publié sur cet environnement — cas non éprouvé")]

    echecs = [
        c.get("slug") for c in publies[:5]
        if appel(f"/learner/courses/show/{c.get('slug')}", jeton=jeton)[0] != 200
    ]
    if echecs:
        return [
            ("BUG", "P6.12", f"cours publiés injoignables par leur slug : {', '.join(echecs)} — vital")
        ]
    return [
        ("OK", "P6.12", f"les {min(len(publies), 5)} cours publiés éprouvés s'ouvrent par leur slug")
    ]


def v_liste_et_detail_accordes(jetons):
    """
    P1.12 / P2.1 — « Mes formations » ne promet que ce que la page tient.

    Vérification née d'un constat : la liste et le détail appliquent des règles
    de publication DIFFÉRENTES. La liste sert la formation, la page renvoie
    « Aucun enregistrement trouvé ». L'apprenant voit une tuile qui ne s'ouvre
    pas — et aucune des deux routes n'est en panne, prise isolément.
    """
    resultats = []
    for role in ["apprenant", "manager"]:
        jeton = jetons.get(role)
        if not jeton:
            continue
        code, d = appel("/learner/my-courses?page=1", jeton=jeton, methode="POST", corps={})
        if code != 200:
            resultats.append(("BUG", "P2.1", f"« Mes formations » rend {code} à {ROLES[role]}"))
            continue
        mes = (d.get("Course") or {}).get("data") or []
        if not mes:
            resultats.append(("OK", "P2.1", f"{ROLES[role]} n'est inscrit à rien — cas non éprouvé"))
            continue

        muettes = [
            c.get("slug") for c in mes
            if appel(f"/learner/courses/show/{c.get('slug')}", jeton=jeton)[0] != 200
        ]
        if muettes:
            resultats.append(
                (
                    "BUG",
                    "P2.1",
                    f"{ROLES[role]} : {len(muettes)}/{len(mes)} formation(s) listées dont la page "
                    f"rend 404 ({', '.join(muettes)}) — la liste promet ce que le détail refuse",
                )
            )
        else:
            resultats.append(
                ("OK", "P2.1", f"{ROLES[role]} : les {len(mes)} formations listées s'ouvrent")
            )
    return resultats


# ── P8 — rattachements et désactivation ─────────────────────────────────────

def v_orphelins_reserves(jetons):
    """P8.9 / #58 — « Apprenants sans manager » est un écran d'administration."""
    resultats = []
    for role, veut in [("admin", 200), ("managerrh", 403), ("manager", 403), ("apprenant", 403)]:
        jeton = jetons.get(role)
        if not jeton:
            continue
        code, d = appel("/admin/users/unassigned/count", jeton=jeton)
        if code == veut == 200:
            resultats.append(("OK", "P8.9", f"l'admin voit {d.get('unassigned_users')} orphelin(s)"))
        elif code == veut:
            resultats.append(("OK", "P8.9", f"{ROLES[role]} en est écarté ({code})"))
        elif veut == 403:
            resultats.append(("BUG", "P8.9", f"{ROLES[role]} ATTEINT la liste des orphelins ({code})"))
        else:
            resultats.append(("BUG", "P8.9", f"l'admin ne peut pas lire les orphelins ({code})"))
    return resultats


def v_rattachement_a_soi_meme(jetons):
    """
    P8.4 / BACK#52 — se rattacher à soi-même est refusé.

    Un cycle dans la hiérarchie casse toute descente de périmètre : le refus
    n'est pas de la validation de confort.
    """
    jeton = jetons.get("admin")
    if not jeton:
        return [("BUG", "P8.4", "pas de jeton admin")]

    miens = _mes_rattaches(jetons)
    if not miens:
        return [("OK", "P8.4", "aucun rattaché sous la main — cas non éprouvé ici")]
    cible = miens[0]

    code, d = appel(
        "/admin/users/assign-manager", jeton=jeton, methode="POST",
        corps={"user_ids": [cible], "manager_id": cible},
    )
    if code in (400, 422):
        return [("OK", "P8.4", f"se rattacher à soi-même est refusé ({code})")]
    if code in (200, 201):
        return [("BUG", "P8.4", "se rattacher à SOI-MÊME est accepté — cycle hiérarchique, vital")]
    return [("BUG", "P8.4", f"/admin/users/assign-manager répond {code} — inattendu")]


def v_desactivation(jetons):
    """
    P8.8 — désactiver empêche d'entrer, et n'efface rien (fonction vitale n°8).

    ⚠️ **La seule vérification de ce script qui ÉCRIT.** Elle ne touche qu'un
    compte du jeu `claude` — jamais celui d'un testeur humain — et le remet
    dans son état d'origine, y compris si une assertion échoue. Le garde-fou
    ci-dessous refuse d'agir sur toute adresse hors du jeu courant : une faute
    de frappe sur un identifiant désactiverait sinon quelqu'un en pleine
    recette.
    """
    admin = jetons.get("admin")
    if not admin:
        return [("BUG", "P8.8", "pas de jeton admin")]

    # On cherche un compte du jeu courant, et le moins sollicité : le 4e apprenant.
    code, d = appel("/manager/teams", jeton=jetons.get("managerrh"))
    gens = ((d.get("collaborators") or {}).get("data") or []) if code == 200 else []
    cible = next(
        (u for u in gens if u.get("email") == f"recette-{QUI}-apprenant4@yopmail.com"), None
    )
    if not cible:
        return [("OK", "P8.8", f"pas de compte recette-{QUI}-apprenant4 — vérification non jouée")]

    # Garde-fou : refuser tout ce qui n'appartient pas au jeu de comptes courant.
    if not str(cible.get("email", "")).startswith(f"recette-{QUI}-"):
        return [("BUG", "P8.8", "garde-fou : la cible n'appartient pas au jeu courant")]

    uid, mail = cible["id"], cible["email"]
    resultats = []
    try:
        c, _ = appel(f"/admin/user/update-status/{uid}", jeton=admin,
                     methode="PUT", corps={"is_active": 0})
        if c != 200:
            return [("BUG", "P8.8", f"la désactivation répond {c}")]

        code_co, _ = appel("/session/login", methode="POST",
                           corps={"email": mail, "password": MOT_DE_PASSE})
        if code_co == 200:
            resultats.append(
                ("BUG", "P8.8", "un compte DÉSACTIVÉ se connecte encore — vital")
            )
        else:
            resultats.append(("OK", "P8.8", f"un compte désactivé ne se connecte plus ({code_co})"))

        # Fonction vitale n°8 : désactiver ne doit RIEN effacer.
        c2, d2 = appel(f"/manager/learner/detail/{uid}", jeton=admin)
        if c2 == 200:
            resultats.append(("OK", "P8.8", "les données du compte désactivé sont conservées"))
        else:
            resultats.append(
                ("BUG", "P8.8", f"la fiche du compte désactivé rend {c2} — désactiver efface, vital")
            )
    finally:
        # Remise en état INCONDITIONNELLE : une recette ne laisse pas de trace.
        appel(f"/admin/user/update-status/{uid}", jeton=admin,
              methode="PUT", corps={"is_active": 1})
    return resultats


VERIFICATIONS = [
    v_connexion_par_role,
    v_mauvais_mot_de_passe,
    v_sans_jeton,
    v_progression_non_apprenant,
    v_cloisonnement,
    v_filtre_ne_donne_jamais_plus,
    v_filtre_restreint_vraiment,
    v_colonnes_dashboard,
    v_routes_supprimees,
    v_chat_ferme,
    v_manques_formateur,
    v_bornes_invitation,
    # Extension du 17/08 — P3/P5 cloisonnement, P4, P6, P8.
    v_onglet_tous_collaborateurs,
    v_idor_fiche_apprenant,
    v_demandes_inscription,
    v_filtre_statut_demandes,
    v_trace_decision,
    v_slug_et_publication,
    v_liste_et_detail_accordes,
    v_orphelins_reserves,
    v_rattachement_a_soi_meme,
    v_desactivation,
]


def main():
    if not MOT_DE_PASSE:
        print("RECETTE_PASSWORD manquant. Rien n'est joué.", file=sys.stderr)
        return 2

    print(f"Passe API — {API}")
    print(f"Jeu de comptes : {QUI}\n")

    # Les connexions d'abord, en parallèle : chaque rôle a SON jeton, il n'y a
    # aucun état partagé. C'est toute la différence avec la passe navigateur.
    with concurrent.futures.ThreadPoolExecutor(max_workers=7) as executeur:
        jetons = dict(
            zip(ROLES, executeur.map(connexion, ROLES))
        )

    obtenus = sum(1 for j in jetons.values() if j)
    print(f"Jetons obtenus : {obtenus} / {len(ROLES)}\n")

    resultats = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executeur:
        for lot in executeur.map(lambda v: v(jetons), VERIFICATIONS):
            resultats.extend(lot)

    for marqueur, etape, message in sorted(resultats, key=lambda r: r[1]):
        print(f"{marqueur:4} {etape:6} {message}")

    compte = {}
    for marqueur, _, _ in resultats:
        compte[marqueur] = compte.get(marqueur, 0) + 1
    print("\n" + " · ".join(f"{m} : {n}" for m, n in sorted(compte.items())))

    # Un BUG est un défaut sans issue : il justifie qu'on s'arrête. Un KO est
    # attendu, il confirme une issue connue.
    return 1 if compte.get("BUG") else 0


if __name__ == "__main__":
    sys.exit(main())
