#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Plan de classe — tirage au sort avec alternance filles / garçons.

Utilisation :
    python plan_de_classe.py           -> lance l'interface graphique
    python plan_de_classe.py --selftest -> tests automatiques (sans interface)

Aucune bibliothèque à installer : uniquement la bibliothèque standard Python.

Principe :
  1. « Charger CSV » : import de l'export de la liste d'élèves (Pronote ou autre).
     Si le fichier ne contient pas de colonne « Sexe », il est accepté quand
     même : seul le tirage aléatoire simple est alors proposé.
     Les boutons « Ajouter / Modifier / Supprimer un élève » permettent ensuite
     de rattraper à la main une arrivée tardive, une faute d'orthographe ou un
     sexe manquant, sans repasser par le CSV.
  2. On règle le nombre de rangées / places, puis on clique dans la grille pour
     dire où sont les places réellement existantes (clic = place ↔ pas de place).
  3. « Placer les élèves » : remplissage à partir du premier rang (celui du
     tableau). Cinq modes au choix dans la liste déroulante :
       · « Alternance filles / garçons » : damier (alternance sur la rangée ET
         avec la rangée précédente) ;
       · « Aléatoire complet » : hasard pur, le sexe n'est pas pris en compte ;
       · « Ordre alphabétique (A → Z) » et « (Z → A) » ;
       · « Placement à la main » : la salle est vidée et l'on installe chaque
         élève d'un clic sur la place voulue.
  4. Mode « Déplacer les élèves » : on attrape un élève à la souris et on le
     dépose sur une autre place (échange si elle est occupée). Clic droit pour
     retirer un élève ou installer un élève resté sans place.
  5. Mode « Aménager la salle » : clic (ou glisser) pour ajouter/supprimer des
     places, à tout moment, même après le tirage.
  6. « Enregistrer le plan… » / « Ouvrir un plan… » : le plan complet (salle,
     élèves et placement) est sauvegardé dans un fichier .plan.json.
     Le dernier état est de toute façon restauré au lancement suivant.
  7. « Exporter en PDF » : deux vues au choix (ou les deux d'un coup), en A4
     paysage.
       · « Vue du professeur » : tableau en bas, la salle telle qu'on la voit
         depuis le tableau — c'est la version à imprimer.
       · « Vue des élèves » : la même salle tournée d'un demi-tour (tableau en
         haut et colonnes inversées). Projetée au tableau, chaque élève se
         retrouve du bon côté : plus d'effet miroir.

Créé par Erwan Pinsault.
"""

import csv
import json
import os
import random
import sys
import unicodedata
from datetime import date

APP_NAME = "Plan de classe"
AUTEUR = "Erwan Pinsault"
MODES = (
    ("alternance", "Alternance filles / garçons"),
    ("aleatoire",  "Aléatoire complet"),
    ("alpha",      "Ordre alphabétique (A → Z)"),
    ("alpha_desc", "Ordre alphabétique (Z → A)"),
    ("manuel",     "Placement à la main"),
)
LIBELLE_MODE = dict(MODES)
CLE_MODE = {lib: cle for cle, lib in MODES}
TIRAGES = tuple(lib for _, lib in MODES)
CONFIG_FILE = os.path.join(os.path.expanduser("~"), ".plan_de_classe.json")

# ---------------------------------------------------------------------------
# 1. Lecture du CSV
# ---------------------------------------------------------------------------

FEMININ = {"f", "feminin", "fille", "filles", "femme", "female"}
MASCULIN = {"m", "g", "masculin", "garcon", "garcons", "homme", "male"}


def _strip_accents(txt):
    txt = unicodedata.normalize("NFD", txt)
    return "".join(c for c in txt if unicodedata.category(c) != "Mn")


def _norm(txt):
    return _strip_accents((txt or "").strip().lower())


def _sexe_of(value):
    """Retourne 'F', 'G' ou None."""
    v = _norm(value)
    if v in FEMININ:
        return "F"
    if v in MASCULIN:
        return "G"
    return None


ENTETES_NOM = {"eleves", "eleve", "eleve(s)", "nom", "noms", "nom prenom",
               "nom et prenom", "prenom nom", "etudiant", "eleve-e", "identite"}

ENTETES_PRENOM = {"prenom", "prenoms", "prenom(s)"}

DEBUTS_PARASITES = ("total", "effectif", "nombre", "nb ", "sous-total")

MOTS_ENTETE = ENTETES_NOM | ENTETES_PRENOM | {"sexe", "genre", "classe", "date", "ne(e)", "nee",
                             "ne", "naissance", "e-mail", "email", "mail",
                             "adresse", "regime", "option", "tuteur", "entree",
                             "sortie", "numero", "n", "civilite", "observation",
                             "encouragement/valorisation", "le"}


def _est_entete(ligne):
    """Vrai si la ligne ressemble à une ligne de titres de colonnes."""
    cellules = [_norm(c) for c in ligne if (c or "").strip()]
    if not cellules:
        return False
    return sum(1 for c in cellules if c in MOTS_ENTETE) >= max(1, len(cellules) // 3)


def _ressemble_a_un_nom(txt):
    """Filtre grossier : au moins deux caractères dont une lettre, et pas une
    date, un nombre ni une adresse e-mail."""
    t = (txt or "").strip()
    if len(t) < 2 or "@" in t:
        return False
    if not any(c.isalpha() for c in t):
        return False
    sans = t.replace("/", "").replace("-", "").replace(".", "").replace(" ", "")
    if sans.isdigit():
        return False
    n = _norm(t)
    if ":" in t or n.startswith(DEBUTS_PARASITES):   # « Total : 27 élèves »
        return False
    return n not in MOTS_ENTETE


def lire_csv(chemin):
    """Lit un export CSV d'élèves.

    Détecte automatiquement le séparateur, la colonne du nom et, si elle
    existe, la colonne du sexe ; ignore les lignes parasites (lignes vides,
    en-tête, pieds de page...).

    Quand aucune colonne « Sexe » n'est trouvée, la lecture réussit quand même :
    chaque élève reçoit alors un sexe `None`, et l'alternance filles/garçons
    n'est simplement pas proposée.

    Retourne (eleves, message) où eleves est une liste de dicts
    {'nom': ..., 'sexe': 'F' | 'G' | None}.
    """
    with open(chemin, "r", encoding="utf-8-sig", newline="", errors="replace") as f:
        brut = f.read()

    # séparateur : celui qui apparaît le plus sur la 1re ligne non vide
    premiere = next((l for l in brut.splitlines() if l.strip()), "")
    sep = max([",", ";", "\t", "|"], key=premiere.count)

    lignes = list(csv.reader(brut.splitlines(), delimiter=sep))
    lignes = [l for l in lignes if any((c or "").strip() for c in l)]
    if not lignes:
        raise ValueError("Le fichier est vide.")

    # colonne du sexe = celle qui contient le plus de valeurs reconnues
    nb_col = max(len(l) for l in lignes)
    scores = [sum(1 for l in lignes if i < len(l) and _sexe_of(l[i]))
              for i in range(nb_col)]
    col_sexe = max(range(nb_col), key=lambda i: scores[i])
    avec_sexe = scores[col_sexe] > 0
    if not avec_sexe:
        col_sexe = None

    # on écarte l'en-tête, puis les lignes sans nom exploitable : un élève dont
    # le sexe n'est pas renseigné reste dans la liste
    candidates = [l for l in lignes if not _est_entete(l)]

    # colonne du nom : en-tête explicite, sinon la colonne la plus « nominale »
    col_nom = col_prenom = None
    for i, cell in enumerate(lignes[0]):
        if col_nom is None and _norm(cell) in ENTETES_NOM:
            col_nom = i
        elif col_prenom is None and _norm(cell) in ENTETES_PRENOM:
            col_prenom = i
    if col_nom is None and candidates:
        def note(i):
            valeurs = [l[i].strip() for l in candidates if i < len(l)]
            noms = [v for v in valeurs if _ressemble_a_un_nom(v)]
            # un nom complet contient souvent une espace (NOM Prénom)
            return len(noms) + 0.5 * sum(1 for v in noms if " " in v)
        meilleur = max(range(nb_col), key=note)
        if note(meilleur) > 0:
            col_nom = meilleur
    if col_nom is None:
        col_nom = 0

    lignes_eleves = [l for l in candidates
                     if col_nom < len(l) and _ressemble_a_un_nom(l[col_nom])]

    eleves, vus = [], set()
    for l in lignes_eleves:
        nom = l[col_nom].strip()
        if col_prenom is not None and col_prenom < len(l) and l[col_prenom].strip():
            nom = "%s %s" % (nom, l[col_prenom].strip())   # colonnes Nom + Prénom
        sexe = _sexe_of(l[col_sexe]) if avec_sexe and col_sexe < len(l) else None
        cle = (_norm(nom), sexe)
        if cle in vus:            # doublon strict
            continue
        vus.add(cle)
        eleves.append({"nom": nom, "sexe": sexe})

    if not eleves:
        raise ValueError("Aucun élève trouvé dans ce fichier.")

    if avec_sexe:
        f = sum(1 for e in eleves if e["sexe"] == "F")
        g = sum(1 for e in eleves if e["sexe"] == "G")
        msg = "%d élèves : %d filles, %d garçons" % (len(eleves), f, g)
        if f + g < len(eleves):
            msg += ", %d sans sexe renseigné" % (len(eleves) - f - g)
    else:
        msg = ("%d élèves — pas de colonne « Sexe » dans ce fichier : "
               "tirage aléatoire simple" % len(eleves))
    return eleves, msg


def couper_nom(nom):
    """Sépare 'DUPOND Marie' en ('DUPOND', 'Marie') pour l'affichage."""
    mots = nom.split()
    maj = [m for m in mots if m == m.upper() and any(c.isalpha() for c in m)]
    if maj and len(maj) < len(mots):
        i = len(maj)
        return " ".join(mots[:i]), " ".join(mots[i:])
    if len(mots) >= 2:
        return " ".join(mots[:-1]), mots[-1]
    return nom, ""


# ---------------------------------------------------------------------------
# 2. Placement
# ---------------------------------------------------------------------------

def placer(eleves, places, rng=None, mode=None, alterner=None):
    """Répartit les élèves sur les places.

    `mode` :
      · "alternance" : alternance filles/garçons en damier (défaut) ;
      · "aleatoire"  : hasard pur, le sexe n'est pas pris en compte ;
      · "alpha"      : ordre alphabétique croissant (A → Z) ;
      · "alpha_desc" : ordre alphabétique décroissant (Z → A) ;
      · "manuel"     : personne n'est placé, tous les élèves restent à installer
                       à la main.

    Les élèves dont le sexe est inconnu (colonne absente du CSV) sont placés
    sur les sièges restants ; si personne n'a de sexe renseigné, l'alternance
    revient à un tirage purement aléatoire.

    `places` : liste de (rangee, colonne), rangée 0 = premier rang (tableau).
    Le remplissage se fait du premier rang vers le fond, de gauche à droite ;
    l'alternance filles/garçons suit un damier ((rangee+colonne) pair/impair),
    de sorte qu'on alterne le long d'une rangée et aussi d'une rangée à l'autre.
    Quand les effectifs ne permettent plus l'alternance, les élèves en trop sont
    placés en dernier, donc au fond de la salle.

    Retourne (affectation {place: eleve}, non_places [eleves]).
    """
    if mode is None:      # compatibilité avec l'ancien paramètre booléen
        mode = "alternance" if alterner in (None, True) else "aleatoire"
    rng = rng or random
    places = sorted(places)                      # (rangée, colonne) croissant

    if mode == "manuel":
        return {}, list(eleves)

    if mode in ("alpha", "alpha_desc"):
        tries = sorted(eleves, key=lambda e: _norm(e["nom"]),
                       reverse=(mode == "alpha_desc"))
        return (dict(zip(places, tries)), tries[len(places):])

    utilisees = places[: len(eleves)]
    non_places = []
    if len(eleves) > len(places):
        # on tire au sort qui reste debout plutôt que de prendre les derniers
        eleves = list(eleves)
        rng.shuffle(eleves)
        non_places = eleves[len(places):]
        eleves = eleves[: len(places)]

    if mode == "aleatoire":
        tires = list(eleves)
        rng.shuffle(tires)
        return dict(zip(utilisees, tires)), non_places

    filles = [e for e in eleves if e["sexe"] == "F"]
    garcons = [e for e in eleves if e["sexe"] == "G"]
    autres = [e for e in eleves if e["sexe"] not in ("F", "G")]
    rng.shuffle(filles)
    rng.shuffle(garcons)
    rng.shuffle(autres)
    if not filles and not garcons:      # aucun sexe connu : hasard pur
        return dict(zip(utilisees, autres)), non_places

    n0 = sum(1 for (r, c) in utilisees if (r + c) % 2 == 0)
    n1 = len(utilisees) - n0

    # quelle parité pour les filles ? celle qui minimise les ruptures
    ecart_a = abs(n0 - len(filles)) + abs(n1 - len(garcons))
    ecart_b = abs(n0 - len(garcons)) + abs(n1 - len(filles))
    if ecart_a < ecart_b:
        parite_filles = 0
    elif ecart_b < ecart_a:
        parite_filles = 1
    else:
        parite_filles = rng.choice([0, 1])

    pools = {"F": filles, "G": garcons}
    affectation = {}
    for (r, c) in utilisees:
        voulu = "F" if (r + c) % 2 == parite_filles else "G"
        autre = "G" if voulu == "F" else "F"
        pool = pools[voulu] or pools[autre] or autres
        affectation[(r, c)] = pool.pop()
    return affectation, non_places


def compter_ruptures(affectation):
    """Nombre de paires de places voisines (horizontal/vertical) de même sexe."""
    n = 0
    for (r, c), e in affectation.items():
        if e["sexe"] not in ("F", "G"):
            continue
        for (dr, dc) in ((0, 1), (1, 0)):
            v = affectation.get((r + dr, c + dc))
            if v and v["sexe"] == e["sexe"]:
                n += 1
    return n


# ---------------------------------------------------------------------------
# 3. Export PDF (écrit à la main, aucune dépendance)
# ---------------------------------------------------------------------------

_W_HELV = {
    " ": 278, "!": 278, '"': 355, "#": 556, "$": 556, "%": 889, "&": 667, "'": 191,
    "(": 333, ")": 333, "*": 389, "+": 584, ",": 278, "-": 333, ".": 278, "/": 278,
    ":": 278, ";": 278, "<": 584, "=": 584, ">": 584, "?": 556, "@": 1015,
    "A": 667, "B": 667, "C": 722, "D": 722, "E": 667, "F": 611, "G": 778, "H": 722,
    "I": 278, "J": 500, "K": 667, "L": 556, "M": 833, "N": 722, "O": 778, "P": 667,
    "Q": 778, "R": 722, "S": 667, "T": 611, "U": 722, "V": 667, "W": 944, "X": 667,
    "Y": 667, "Z": 611, "[": 278, "\\": 278, "]": 278, "^": 469, "_": 556, "`": 333,
    "a": 556, "b": 556, "c": 500, "d": 556, "e": 556, "f": 278, "g": 556, "h": 556,
    "i": 222, "j": 222, "k": 500, "l": 222, "m": 833, "n": 556, "o": 556, "p": 556,
    "q": 556, "r": 333, "s": 500, "t": 278, "u": 556, "v": 500, "w": 722, "x": 500,
    "y": 500, "z": 500, "{": 334, "|": 260, "}": 334, "~": 584,
}
for _d in "0123456789":
    _W_HELV[_d] = 556


def _largeur(texte, taille, gras=False):
    total = 0
    for ch in texte:
        base = _W_HELV.get(ch)
        if base is None:
            base = _W_HELV.get(_strip_accents(ch), 556)
        total += base + (35 if gras else 0)
    return total * taille / 1000.0


def _tronquer(texte, taille, largeur_max, gras=False):
    if _largeur(texte, taille, gras) <= largeur_max:
        return texte
    while texte and _largeur(texte + "…", taille, gras) > largeur_max:
        texte = texte[:-1]
    return texte + "…" if texte else ""


class _PDF:
    """Générateur PDF minimal : rectangles + texte, page unique A4 paysage."""

    LARGEUR, HAUTEUR = 841.89, 595.28

    def __init__(self):
        self.ops = []

    def rect(self, x, y, w, h, fill=None, stroke=(0.35, 0.35, 0.4), lw=0.8, radius=0):
        if fill:
            self.ops.append("%.3f %.3f %.3f rg" % fill)
        if stroke:
            self.ops.append("%.3f %.3f %.3f RG" % stroke)
        self.ops.append("%.2f w" % lw)
        self.ops.append("%.2f %.2f %.2f %.2f re" % (x, y, w, h))
        if fill and stroke:
            self.ops.append("B")
        elif fill:
            self.ops.append("f")
        else:
            self.ops.append("S")

    def texte(self, x, y, txt, taille=10, gras=False, couleur=(0, 0, 0), centre_sur=None):
        if centre_sur is not None:
            x = centre_sur - _largeur(txt, taille, gras) / 2.0
        enc = txt.encode("cp1252", "replace").decode("cp1252")
        enc = enc.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)")
        self.ops.append("BT /%s %.2f Tf %.3f %.3f %.3f rg %.2f %.2f Td (%s) Tj ET"
                        % ("FB" if gras else "F1", taille, couleur[0], couleur[1],
                           couleur[2], x, y, enc))

    def enregistrer(self, chemin):
        flux = "\n".join(self.ops).encode("cp1252", "replace")
        objets = [
            b"<< /Type /Catalog /Pages 2 0 R >>",
            b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
            ("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2f %.2f] "
             "/Resources << /Font << /F1 5 0 R /FB 6 0 R >> >> /Contents 4 0 R >>"
             % (self.LARGEUR, self.HAUTEUR)).encode("ascii"),
            b"<< /Length " + str(len(flux)).encode() + b" >>\nstream\n" + flux + b"\nendstream",
            b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
            b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>",
        ]
        out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
        offsets = []
        for i, obj in enumerate(objets, start=1):
            offsets.append(len(out))
            out += str(i).encode() + b" 0 obj\n" + obj + b"\nendobj\n"
        xref = len(out)
        out += b"xref\n0 " + str(len(objets) + 1).encode() + b"\n0000000000 65535 f \n"
        for off in offsets:
            out += ("%010d 00000 n \n" % off).encode()
        out += (b"trailer\n<< /Size " + str(len(objets) + 1).encode()
                + b" /Root 1 0 R >>\nstartxref\n" + str(xref).encode() + b"\n%%EOF\n")
        with open(chemin, "wb") as f:
            f.write(bytes(out))


COUL_F = (0.93, 0.87, 0.97)
COUL_G = (0.85, 0.92, 0.98)


VUES = {
    "prof": ("Vue du professeur — depuis le tableau", True, False),
    "projection": ("Vue des élèves — à projeter au tableau", False, True),
}


def exporter_pdf(chemin, affectation, nb_rangees, nb_colonnes, titre="",
                 vue="prof", avec_couleurs=True):
    """Écrit le plan de classe dans un PDF A4 paysage.

    `vue="prof"`       : la salle telle que le professeur la voit depuis le
                         tableau (bandeau TABLEAU en bas, premier rang juste
                         au-dessus).
    `vue="projection"` : la même salle vue par les élèves, c'est-à-dire tournée
                         d'un demi-tour — bandeau TABLEAU en haut ET colonnes
                         inversées gauche/droite. Projetée au tableau, chaque
                         élève se retrouve du bon côté, sans effet miroir.
    """
    libelle_vue, tableau_en_bas, miroir = VUES.get(vue, VUES["prof"])
    pdf = _PDF()
    W, H = pdf.LARGEUR, pdf.HAUTEUR
    marge = 34.0
    haut_titre = 26.0
    haut_tableau = 24.0

    pdf.texte(marge, H - marge - 4, titre or "Plan de classe", 15, gras=True)
    sous = date.today().strftime("%d/%m/%Y") + "  —  %d élèves" % len(affectation)
    pdf.texte(W - marge - _largeur(sous, 9), H - marge - 3, sous, 9,
              couleur=(0.4, 0.4, 0.45))
    pdf.texte(marge, H - marge - 17, libelle_vue, 8.5, couleur=(0.42, 0.45, 0.52))

    # zone réservée à la grille (le tableau occupe une bande en haut ou en bas)
    if tableau_en_bas:
        g_bas = marge + haut_tableau + 12
        g_haut = H - marge - haut_titre
    else:
        g_bas = marge
        g_haut = H - marge - haut_titre - haut_tableau - 12
    zone_h = g_haut - g_bas
    zone_w = W - 2 * marge

    ecart = 6.0
    cw = (zone_w - ecart * (nb_colonnes - 1)) / nb_colonnes
    ch = min((zone_h - ecart * (nb_rangees - 1)) / nb_rangees, 78.0)
    grille_h = ch * nb_rangees + ecart * (nb_rangees - 1)
    y0 = g_bas + (zone_h - grille_h) / 2.0          # bas de la grille, centrée

    for (r, c), eleve in sorted(affectation.items()):
        # rangée 0 = premier rang = côté tableau
        ligne = r if tableau_en_bas else (nb_rangees - 1 - r)
        colonne = (nb_colonnes - 1 - c) if miroir else c
        y = y0 + (ch + ecart) * ligne
        x = marge + (cw + ecart) * colonne
        fill = None
        if avec_couleurs and eleve["sexe"] in ("F", "G"):
            fill = COUL_F if eleve["sexe"] == "F" else COUL_G
        pdf.rect(x, y, cw, ch, fill=fill)
        nom, prenom = couper_nom(eleve["nom"])
        t_nom = min(12.0, max(6.5, (cw - 10) / max(1, len(nom)) * 1.75))
        if prenom:
            pdf.texte(0, y + ch / 2 + 2, _tronquer(nom, t_nom, cw - 8, True),
                      t_nom, gras=True, centre_sur=x + cw / 2)
            t_p = max(6.0, t_nom - 1)
            pdf.texte(0, y + ch / 2 - t_p - 2, _tronquer(prenom, t_p, cw - 8),
                      t_p, couleur=(0.25, 0.25, 0.3), centre_sur=x + cw / 2)
        else:
            pdf.texte(0, y + ch / 2 - t_nom / 3, _tronquer(nom, t_nom, cw - 8, True),
                      t_nom, gras=True, centre_sur=x + cw / 2)

    y_tab = marge if tableau_en_bas else H - marge - haut_titre - haut_tableau
    pdf.rect(marge + zone_w * 0.2, y_tab, zone_w * 0.6, haut_tableau,
             fill=(0.16, 0.18, 0.22), stroke=None)
    pdf.texte(0, y_tab + 8, "TABLEAU", 11, gras=True, couleur=(1, 1, 1),
              centre_sur=W / 2)

    signature = "Plan de classe — outil créé par %s" % AUTEUR
    pdf.texte(W - marge - _largeur(signature, 7.5), 13, signature, 7.5,
              couleur=(0.55, 0.55, 0.6))
    pdf.enregistrer(chemin)
    return chemin


# ---------------------------------------------------------------------------
# 4. Enregistrement / relecture d'un plan
# ---------------------------------------------------------------------------

def etat_vers_dict(titre, rangees, colonnes, tableau_en_bas, places,
                   affectation, eleves, non_places, mode="alternance"):
    """Sérialise un plan complet (salle + élèves + placement)."""
    return {
        "version": 1,
        "date": date.today().isoformat(),
        "titre": titre,
        "rangees": rangees,
        "colonnes": colonnes,
        "tableau_en_bas": bool(tableau_en_bas),
        "mode": mode,
        "alterner": mode == "alternance",       # relu par les versions < 2026-09
        "places": sorted([list(p) for p in places]),
        "affectation": [{"rangee": r, "colonne": c,
                         "nom": e["nom"], "sexe": e["sexe"]}
                        for (r, c), e in sorted(affectation.items())],
        "eleves": [{"nom": e["nom"], "sexe": e["sexe"]} for e in eleves],
        "non_places": [{"nom": e["nom"], "sexe": e["sexe"]} for e in non_places],
    }


def dict_vers_etat(d):
    """Relit un plan enregistré. Retourne un dict prêt à l'emploi.

    La liste des élèves, le placement et les élèves sans place partagent les
    mêmes objets : corriger un nom ou un sexe à un endroit le corrige partout.
    """
    eleves = [dict(e) for e in d.get("eleves", [])]
    disponibles = {}
    for e in eleves:
        disponibles.setdefault((e["nom"], e.get("sexe")), []).append(e)

    def reference(nom, sexe):
        restants = disponibles.get((nom, sexe))
        if restants:
            return restants.pop(0)
        e = {"nom": nom, "sexe": sexe}      # présent sur le plan, absent de la liste
        eleves.append(e)
        return e

    affectation = {}
    for p in d.get("affectation", []):
        affectation[(int(p["rangee"]), int(p["colonne"]))] = reference(
            p["nom"], p.get("sexe"))
    non_places = [reference(x["nom"], x.get("sexe"))
                  for x in d.get("non_places", [])]
    return {
        "titre": d.get("titre", "Plan de classe"),
        "rangees": int(d.get("rangees", 5)),
        "colonnes": int(d.get("colonnes", 6)),
        "tableau_en_bas": bool(d.get("tableau_en_bas", True)),
        "mode": d.get("mode") or ("alternance" if d.get("alterner", True)
                                  else "aleatoire"),
        "places": {(int(r), int(c)) for r, c in d.get("places", [])},
        "affectation": affectation,
        "eleves": eleves,
        "non_places": non_places,
    }


def enregistrer_plan(chemin, etat):
    with open(chemin, "w", encoding="utf-8") as f:
        json.dump(etat, f, ensure_ascii=False, indent=1)
    return chemin


def ouvrir_plan(chemin):
    with open(chemin, "r", encoding="utf-8") as f:
        return dict_vers_etat(json.load(f))


# ---------------------------------------------------------------------------
# 5. Interface graphique (Tkinter)
# ---------------------------------------------------------------------------

def lancer_interface(_hook=None):
    import tkinter as tk
    from tkinter import ttk, filedialog, messagebox

    BG = "#f4f5f7"
    C_VIDE = "#e4e6ea"        # pas de place à cet endroit
    C_LIBRE = "#ffffff"       # place existante, personne dessus
    C_F = "#ead9f7"
    C_G = "#d5e6f8"
    C_NEUTRE = "#eef0f4"      # élève dont le sexe n'est pas renseigné
    C_SEL = "#f7b955"         # contour de la place en cours de déplacement

    class App:
        def __init__(self, root):
            self.root = root
            root.title("%s — %s" % (APP_NAME, AUTEUR))
            root.configure(bg=BG)
            root.minsize(960, 680)

            self.eleves = []          # effectif complet
            self.affectation = {}     # {(rangée, colonne): élève}
            self.non_places = []      # élèves sans place
            self.nb_rangees = tk.IntVar(value=5)
            self.nb_colonnes = tk.IntVar(value=6)
            self.tableau_en_bas = tk.BooleanVar(value=True)
            self.titre = tk.StringVar(value="Plan de classe")
            self.mode = tk.StringVar(value="salle")
            self.type_tirage = tk.StringVar(value=TIRAGES[0])
            self.places = set()
            self.fichier_csv = None
            self.drag = None          # (rangée, colonne) en cours de déplacement
            self.pointeur = None      # position souris pendant le déplacement

            self._barre()
            self.canvas = tk.Canvas(root, bg=BG, highlightthickness=0)
            self.canvas.pack(fill="both", expand=True, padx=12, pady=(0, 6))
            self.canvas.bind("<Button-1>", self.presser)
            self.canvas.bind("<B1-Motion>", self.bouger)
            self.canvas.bind("<ButtonRelease-1>", self.relacher)
            self.canvas.bind("<Button-3>", self.menu_contextuel)      # Windows/Linux
            self.canvas.bind("<Button-2>", self.menu_contextuel)      # macOS
            self.canvas.bind("<Configure>", lambda e: self.dessiner())
            root.bind("<Control-s>", lambda e: self.enregistrer())
            root.bind("<Control-o>", lambda e: self.ouvrir())

            bas = tk.Frame(root, bg=BG)
            bas.pack(fill="x", padx=14, pady=(0, 10))
            self.statut = tk.Label(bas, text="Charge un CSV, dessine la salle, "
                                             "puis lance le tirage.",
                                   anchor="w", bg=BG, fg="#4a4a55")
            self.statut.pack(fill="x")
            self.lbl_reste = tk.Label(bas, text="", anchor="w", bg=BG, fg="#a3521a")
            self.lbl_reste.pack(fill="x")
            pied = tk.Frame(bas, bg=BG)
            pied.pack(fill="x")
            self.aide = tk.Label(pied, text="", anchor="w", bg=BG, fg="#8b8f9c")
            self.aide.pack(side="left")
            tk.Label(pied, text="Plan de classe — outil créé par %s" % AUTEUR,
                     anchor="e", bg=BG, fg="#9aa0ad",
                     font=("Helvetica", 8)).pack(side="right")

            self.charger_config()
            self.maj_choix_tirage()
            if not self.places:
                self.reconstruire_places()
            self.nb_rangees.trace_add("write", self.redimensionner)
            self.nb_colonnes.trace_add("write", self.redimensionner)
            self.maj_aide()
            self.dessiner()

        # ---- barre d'outils -------------------------------------------------
        def _barre(self):
            b1 = tk.Frame(self.root, bg=BG)
            b1.pack(fill="x", padx=12, pady=(10, 6))
            ttk.Button(b1, text="Charger CSV…", command=self.charger).pack(side="left")
            self.lbl_csv = tk.Label(b1, text="aucun fichier", bg=BG, fg="#6a6a77")
            self.lbl_csv.pack(side="left", padx=10)

            mb = ttk.Menubutton(b1, text="Exporter en PDF  ▾", direction="below",
                                style="TButton")
            menu_pdf = tk.Menu(mb, tearoff=0)
            menu_pdf.add_command(label="Vue du professeur (à imprimer)",
                                 command=lambda: self.export("prof"))
            menu_pdf.add_command(label="Vue des élèves (à projeter au tableau)",
                                 command=lambda: self.export("projection"))
            menu_pdf.add_separator()
            menu_pdf.add_command(label="Les deux vues", command=self.export_les_deux)
            mb["menu"] = menu_pdf
            mb.pack(side="right")
            ttk.Button(b1, text="Enregistrer le plan…",
                       command=self.enregistrer).pack(side="right", padx=6)
            ttk.Button(b1, text="Ouvrir un plan…",
                       command=self.ouvrir).pack(side="right")

            b_el = tk.Frame(self.root, bg=BG)
            b_el.pack(fill="x", padx=12, pady=(0, 6))
            ttk.Label(b_el, text="Titre :").pack(side="left", padx=(0, 4))
            ttk.Entry(b_el, textvariable=self.titre, width=24).pack(side="left")
            ttk.Label(b_el, text="Élèves :").pack(side="left", padx=(24, 6))
            ttk.Button(b_el, text="Ajouter un élève…", width=19,
                       command=self.ajouter_eleve).pack(side="left")
            ttk.Button(b_el, text="Modifier un élève…", width=19,
                       command=self.modifier_eleve).pack(side="left", padx=6)
            ttk.Button(b_el, text="Supprimer un élève…", width=20,
                       command=self.supprimer_eleve).pack(side="left")

            b2 = tk.Frame(self.root, bg=BG)
            b2.pack(fill="x", padx=12, pady=(0, 6))
            Spin = getattr(ttk, "Spinbox", tk.Spinbox)
            ttk.Label(b2, text="Rangées :").pack(side="left")
            Spin(b2, from_=1, to=12, width=4, textvariable=self.nb_rangees,
                 command=self.redimensionner).pack(side="left", padx=(4, 12))
            ttk.Label(b2, text="Places par rangée :").pack(side="left")
            Spin(b2, from_=1, to=14, width=4, textvariable=self.nb_colonnes,
                 command=self.redimensionner).pack(side="left", padx=(4, 12))
            ttk.Button(b2, text="Tout activer", width=13,
                       command=lambda: self.tout(True)).pack(side="left")
            ttk.Button(b2, text="Tout vider", width=13,
                       command=lambda: self.tout(False)).pack(side="left", padx=6)
            ttk.Checkbutton(b2, text="Tableau en bas", variable=self.tableau_en_bas,
                            command=self.dessiner).pack(side="left", padx=12)

            b3 = tk.Frame(self.root, bg=BG)
            b3.pack(fill="x", padx=12, pady=(0, 8))
            ttk.Label(b3, text="Mode :").pack(side="left", padx=(0, 6))
            ttk.Radiobutton(b3, text="Aménager la salle", value="salle",
                            variable=self.mode,
                            command=self.maj_aide).pack(side="left")
            ttk.Radiobutton(b3, text="Déplacer les élèves", value="eleves",
                            variable=self.mode,
                            command=self.maj_aide).pack(side="left", padx=10)
            self.cb_tirage = ttk.Combobox(b3, textvariable=self.type_tirage,
                                          values=TIRAGES, state="readonly", width=26)
            self.cb_tirage.pack(side="left", padx=(28, 6))
            self.cb_tirage.bind("<<ComboboxSelected>>",
                                lambda e: setattr(self, "_alternance_retiree", False))
            self.btn_tirage = ttk.Button(b3, text="Placer les élèves", width=17,
                                         command=self.tirer)
            self.btn_tirage.pack(side="left", padx=(0, 10))
            ttk.Button(b3, text="Vider les places", width=16,
                       command=self.effacer).pack(side="left")

        def maj_aide(self):
            if self.mode.get() == "salle":
                txt = ("clic (ou glisser) = ajouter / retirer une place  ·  "
                       "clic droit = menu")
            else:
                txt = ("glisser un élève sur une autre place = échanger  ·  "
                       "clic sur une place libre = installer un élève  ·  "
                       "clic droit = menu")
            self.aide.config(text=txt)

        # ---- données --------------------------------------------------------
        def charger(self):
            chemin = filedialog.askopenfilename(
                title="Liste des élèves",
                filetypes=[("Fichiers CSV", "*.csv"), ("Tous les fichiers", "*.*")])
            if not chemin:
                return
            try:
                self.eleves, msg = lire_csv(chemin)
            except Exception as exc:
                messagebox.showerror(APP_NAME, "Lecture impossible :\n%s" % exc)
                return
            self.fichier_csv = chemin
            self.lbl_csv.config(text=os.path.basename(chemin), fg="#1f6f3f")
            self.affectation, self.non_places = {}, []
            self.maj_choix_tirage()
            self.statut.config(text=msg + "  —  %d places définies. "
                                          "Lance le tirage quand la salle est prête."
                                          % len(self.places))
            self.lbl_reste.config(text="")
            self.dessiner()

        def dimensions(self):
            """Lecture sûre des spinbox (l'utilisateur peut vider le champ)."""
            try:
                R = max(1, min(12, int(self.nb_rangees.get())))
            except Exception:
                R = 1
            try:
                C = max(1, min(14, int(self.nb_colonnes.get())))
            except Exception:
                C = 1
            return R, C

        def redimensionner(self, *_):
            """Change la taille de la salle sans perdre le travail en cours."""
            R, C = self.dimensions()
            anciennes = self.places
            R0 = max([r for (r, c) in anciennes], default=-1) + 1
            C0 = max([c for (r, c) in anciennes], default=-1) + 1
            nouvelles = {(r, c) for (r, c) in anciennes if r < R and c < C}
            for r in range(R):
                for c in range(C):
                    if r >= R0 or c >= C0:      # cases ajoutées : actives par défaut
                        nouvelles.add((r, c))
            self.places = nouvelles
            for cle in [k for k in self.affectation if k not in self.places]:
                self.non_places.append(self.affectation.pop(cle))
            self.maj_statut()
            self.dessiner()

        def reconstruire_places(self):
            R, C = self.dimensions()
            self.places = {(r, c) for r in range(R) for c in range(C)}

        def tout(self, actif):
            R, C = self.dimensions()
            if actif:
                self.places = {(r, c) for r in range(R) for c in range(C)}
            else:
                self.places = set()
                self.non_places.extend(self.affectation.values())
                self.affectation = {}
            self.maj_statut()
            self.dessiner()

        def effacer(self):
            """Retire tous les élèves des places, sans toucher à la salle."""
            self.non_places.extend(self.affectation.values())
            self.affectation = {}
            self.maj_statut()
            self.dessiner()

        def tirer(self):
            if not self.eleves:
                messagebox.showinfo(APP_NAME, "Charge d'abord un fichier CSV d'élèves.")
                return
            if not self.places:
                messagebox.showinfo(APP_NAME, "Clique dans la grille pour indiquer "
                                              "où sont les places disponibles.")
                return
            self.affectation, self.non_places = placer(
                self.eleves, sorted(self.places), mode=self.mode_placement())
            if self.mode_placement() == "manuel":
                self.mode.set("eleves")          # prêt à installer au clic
                self.maj_aide()
            self.maj_statut()
            self.dessiner()

        def sexes_connus(self):
            return any(e["sexe"] in ("F", "G") for e in self.eleves)

        def mode_placement(self):
            cle = CLE_MODE.get(self.type_tirage.get(), "alternance")
            if cle == "alternance" and not self.sexes_connus():
                return "aleatoire"
            return cle

        def maj_choix_tirage(self):
            """Retire l'alternance des choix quand le CSV n'a pas de sexe, et la
            remet dès qu'une liste avec le sexe est chargée."""
            if self.sexes_connus():
                self.cb_tirage.config(values=TIRAGES)
                if getattr(self, "_alternance_retiree", False):
                    self.type_tirage.set(LIBELLE_MODE["alternance"])
                    self._alternance_retiree = False
            else:
                self.cb_tirage.config(
                    values=tuple(t for t in TIRAGES
                                 if t != LIBELLE_MODE["alternance"]))
                if self.type_tirage.get() == LIBELLE_MODE["alternance"]:
                    self.type_tirage.set(LIBELLE_MODE["aleatoire"])
                    self._alternance_retiree = True

        # ---- gestion de la liste des élèves ---------------------------------
        def _centrer(self, win):
            """Place la fenêtre modale au milieu de la fenêtre principale."""
            win.update_idletasks()
            x = self.root.winfo_rootx() + (self.root.winfo_width()
                                           - win.winfo_width()) // 2
            y = self.root.winfo_rooty() + (self.root.winfo_height()
                                           - win.winfo_height()) // 3
            win.geometry("+%d+%d" % (max(0, x), max(0, y)))

        def _saisie_eleve(self, titre, nom="", sexe=None, bouton="Ajouter"):
            """Fenêtre modale « nom + sexe ». Retourne (nom, sexe) ou None."""
            win = tk.Toplevel(self.root)
            win.title(titre)
            win.configure(bg=BG, padx=18, pady=16)
            win.resizable(False, False)
            win.transient(self.root)
            v_nom = tk.StringVar(value=nom)
            v_sexe = tk.StringVar(value=sexe or "")
            resultat = {}

            ttk.Label(win, text="Nom et prénom :").grid(row=0, column=0, sticky="w")
            champ = ttk.Entry(win, textvariable=v_nom, width=32)
            champ.grid(row=0, column=1, columnspan=3, sticky="we",
                       padx=(8, 0), pady=(0, 12))
            ttk.Label(win, text="Sexe :").grid(row=1, column=0, sticky="w")
            for i, (val, lib) in enumerate((("F", "Fille"), ("G", "Garçon"),
                                            ("", "Non renseigné"))):
                ttk.Radiobutton(win, text=lib, value=val,
                                variable=v_sexe).grid(row=1, column=1 + i,
                                                      sticky="w", padx=(8, 0))

            def valider(*_):
                if not v_nom.get().strip():
                    messagebox.showinfo(APP_NAME, "Indique au moins un nom.",
                                        parent=win)
                    return
                resultat["v"] = (v_nom.get().strip(), v_sexe.get() or None)
                win.destroy()

            barre = tk.Frame(win, bg=BG)
            barre.grid(row=2, column=0, columnspan=4, sticky="e", pady=(16, 0))
            ttk.Button(barre, text="Annuler",
                       command=win.destroy).pack(side="right", padx=(6, 0))
            ttk.Button(barre, text=bouton, command=valider).pack(side="right")
            win.bind("<Return>", valider)
            win.bind("<Escape>", lambda e: win.destroy())
            self._centrer(win)
            champ.focus_set()
            champ.select_range(0, "end")
            win.grab_set()
            self.root.wait_window(win)
            return resultat.get("v")

        def _choisir_eleve(self, titre, bouton="Continuer"):
            """Fenêtre modale de choix dans la classe. Retourne l'élève ou None."""
            if not self.eleves:
                messagebox.showinfo(APP_NAME, "Il n'y a encore aucun élève : "
                                              "charge un CSV ou ajoute-les un à un.")
                return None
            ordre = sorted(self.eleves, key=lambda e: _norm(e["nom"]))
            libelles = []
            for e in ordre:
                place = next((k for k, v in self.affectation.items() if v is e), None)
                ou = ("rang %d, place %d" % (place[0] + 1, place[1] + 1)
                      if place else "sans place")
                sexe = {"F": "fille", "G": "garçon"}.get(e["sexe"], "sexe non renseigné")
                libelles.append("%s  —  %s, %s" % (e["nom"], sexe, ou))

            win = tk.Toplevel(self.root)
            win.title(titre)
            win.configure(bg=BG, padx=18, pady=16)
            win.resizable(False, False)
            win.transient(self.root)
            choix = tk.StringVar(value=libelles[0])
            resultat = {}
            ttk.Label(win, text="Élève :").grid(row=0, column=0, sticky="w")
            cb = ttk.Combobox(win, textvariable=choix, values=libelles,
                              state="readonly", width=52)
            cb.grid(row=0, column=1, sticky="we", padx=(8, 0))
            cb.current(0)

            def valider(*_):
                resultat["v"] = ordre[cb.current()]
                win.destroy()

            barre = tk.Frame(win, bg=BG)
            barre.grid(row=1, column=0, columnspan=2, sticky="e", pady=(16, 0))
            ttk.Button(barre, text="Annuler",
                       command=win.destroy).pack(side="right", padx=(6, 0))
            ttk.Button(barre, text=bouton, command=valider).pack(side="right")
            win.bind("<Return>", valider)
            win.bind("<Escape>", lambda e: win.destroy())
            self._centrer(win)
            cb.focus_set()
            win.grab_set()
            self.root.wait_window(win)
            return resultat.get("v")

        def ajouter_eleve(self):
            saisie = self._saisie_eleve("Ajouter un élève", bouton="Ajouter")
            if not saisie:
                return
            nom, sexe = saisie
            if any(_norm(e["nom"]) == _norm(nom) for e in self.eleves):
                if not messagebox.askyesno(
                        APP_NAME, "%s figure déjà dans la classe.\n\n"
                                  "L'ajouter quand même ?" % nom):
                    return
            eleve = {"nom": nom, "sexe": sexe}
            self.eleves.append(eleve)
            self.non_places.append(eleve)
            self.maj_choix_tirage()
            self.maj_statut()
            self.dessiner()
            self.statut.config(
                text="%s ajouté à la classe. Passe en mode « Déplacer les élèves » "
                     "et clique sur une place libre pour l'installer." % nom)

        def modifier_eleve(self):
            eleve = self._choisir_eleve("Modifier un élève", bouton="Modifier…")
            if eleve is None:
                return
            saisie = self._saisie_eleve("Modifier un élève", eleve["nom"],
                                        eleve["sexe"], bouton="Enregistrer")
            if not saisie:
                return
            ancien = eleve["nom"]
            # modification sur place : le placement et la liste suivent
            eleve["nom"], eleve["sexe"] = saisie
            self.maj_choix_tirage()
            self.maj_statut()
            self.dessiner()
            if ancien != eleve["nom"]:
                self.statut.config(text="« %s » devient « %s »." % (ancien, eleve["nom"]))
            else:
                self.statut.config(text="%s : fiche mise à jour." % eleve["nom"])

        def supprimer_eleve(self):
            eleve = self._choisir_eleve("Supprimer un élève", bouton="Supprimer…")
            if eleve is None:
                return
            if not messagebox.askyesno(
                    APP_NAME, "Retirer %s de la classe ?\n\n"
                              "Sa place sera libérée." % eleve["nom"]):
                return
            for place in [k for k, v in self.affectation.items() if v is eleve]:
                del self.affectation[place]
            self.non_places = [e for e in self.non_places if e is not eleve]
            self.eleves = [e for e in self.eleves if e is not eleve]
            self.maj_choix_tirage()
            self.maj_statut()
            self.dessiner()
            self.statut.config(text="%s a été retiré de la classe." % eleve["nom"])

        def maj_statut(self):
            n = len(self.affectation)
            if not n and not self.non_places:
                self.statut.config(text="%d places définies." % len(self.places))
                self.lbl_reste.config(text="")
                return
            txt = "%d élèves placés sur %d places." % (n, len(self.places))
            if self.mode_placement() in ("alpha", "alpha_desc"):
                txt += "   Ordre alphabétique, du premier rang vers le fond."
            elif self.mode_placement() == "manuel":
                txt += ("   Placement à la main : clic sur une place libre "
                        "(mode « Déplacer les élèves ») pour installer un élève.")
            elif self.sexes_connus():
                ruptures = compter_ruptures(self.affectation)
                txt += "   Mixité : %s" % ("alternance parfaite" if ruptures == 0
                                           else "%d voisin(s) de même sexe" % ruptures)
            else:
                txt += "   Sexe non renseigné dans le fichier : tirage aléatoire."
            self.statut.config(text=txt)
            if self.non_places:
                self.lbl_reste.config(
                    text="⚠ Sans place (%d) : %s   —  clic droit sur une place "
                         "libre pour les installer."
                         % (len(self.non_places),
                            ", ".join(e["nom"] for e in self.non_places)))
            else:
                self.lbl_reste.config(text="")

        # ---- géométrie ------------------------------------------------------
        def geometrie(self):
            R, C = self.dimensions()
            w = max(self.canvas.winfo_width(), 200)
            h = max(self.canvas.winfo_height(), 200)
            marge, ecart, h_tab = 14, 8, 34
            cw = (w - 2 * marge - ecart * (C - 1)) / C
            chh = (h - 2 * marge - h_tab - ecart * (R - 1)) / R
            chh = max(28, min(chh, 96))
            cw = max(30, cw)
            return R, C, marge, ecart, h_tab, cw, chh, w, h

        def coord(self, r, c):
            R, C, marge, ecart, h_tab, cw, chh, w, h = self.geometrie()
            x = marge + (cw + ecart) * c
            grille_h = chh * R + ecart * (R - 1)
            if self.tableau_en_bas.get():
                y0 = h - marge - h_tab - grille_h
                y = y0 + (chh + ecart) * (R - 1 - r)
            else:
                y0 = marge + h_tab
                y = y0 + (chh + ecart) * r
            return x, y, cw, chh

        def case_sous(self, ex, ey):
            R, C = self.dimensions()
            for r in range(R):
                for c in range(C):
                    x, y, cw, chh = self.coord(r, c)
                    if x <= ex <= x + cw and y <= ey <= y + chh:
                        return (r, c)
            return None

        # ---- souris ---------------------------------------------------------
        def presser(self, ev):
            case = self.case_sous(ev.x, ev.y)
            if case is None:
                return
            if self.mode.get() == "salle":
                self._mode_glisse = case not in self.places
                self.basculer_place(case, self._mode_glisse)
            else:
                if case in self.affectation:
                    self.drag = case
                    self.pointeur = (ev.x, ev.y)
                    self.dessiner()
                elif case in self.places and self.non_places:
                    # place libre : on propose directement les élèves à installer
                    self.menu_installer(case, ev)

        def bouger(self, ev):
            if self.mode.get() == "salle":
                case = self.case_sous(ev.x, ev.y)
                if case is not None and getattr(self, "_mode_glisse", None) is not None:
                    self.basculer_place(case, self._mode_glisse)
            elif self.drag is not None:
                self.pointeur = (ev.x, ev.y)
                self.dessiner()

        def relacher(self, ev):
            if self.mode.get() == "salle":
                self._mode_glisse = None
                return
            if self.drag is None:
                return
            depart, self.drag, self.pointeur = self.drag, None, None
            cible = self.case_sous(ev.x, ev.y)
            if cible and cible != depart and cible in self.places:
                a = self.affectation.get(depart)
                b = self.affectation.get(cible)
                self.affectation[cible] = a
                if b is not None:
                    self.affectation[depart] = b       # échange
                else:
                    self.affectation.pop(depart, None)  # simple déplacement
                self.maj_statut()
            self.dessiner()

        def basculer_place(self, case, activer):
            if activer:
                if case in self.places:
                    return
                self.places.add(case)
            else:
                if case not in self.places:
                    return
                self.places.discard(case)
                occupant = self.affectation.pop(case, None)
                if occupant is not None:
                    self.non_places.append(occupant)
            self.maj_statut()
            self.dessiner()

        def menu_installer(self, case, ev):
            """Petit menu listant les élèves qui n'ont pas encore de place."""
            m = tk.Menu(self.root, tearoff=0)
            m.add_command(label="Installer ici :", state="disabled")
            m.add_separator()
            for e in sorted(self.non_places, key=lambda x: _norm(x["nom"])):
                m.add_command(label=e["nom"],
                              command=lambda el=e: self.installer(case, el))
            try:
                m.tk_popup(ev.x_root, ev.y_root)
            finally:
                m.grab_release()

        def menu_contextuel(self, ev):
            case = self.case_sous(ev.x, ev.y)
            if case is None:
                return
            m = tk.Menu(self.root, tearoff=0)
            occupant = self.affectation.get(case)
            if case not in self.places:
                m.add_command(label="Ajouter une place ici",
                              command=lambda: self.basculer_place(case, True))
            else:
                if occupant:
                    m.add_command(label="Retirer %s du plan" % occupant["nom"],
                                  command=lambda: self.retirer(case))
                if self.non_places:
                    sm = tk.Menu(m, tearoff=0)
                    for e in sorted(self.non_places, key=lambda x: _norm(x["nom"])):
                        sm.add_command(label=e["nom"],
                                       command=lambda el=e: self.installer(case, el))
                    m.add_cascade(label=("Remplacer par" if occupant
                                         else "Installer ici"), menu=sm)
                m.add_separator()
                m.add_command(label="Supprimer cette place",
                              command=lambda: self.basculer_place(case, False))
            try:
                m.tk_popup(ev.x_root, ev.y_root)
            finally:
                m.grab_release()

        def retirer(self, case):
            occupant = self.affectation.pop(case, None)
            if occupant is not None:
                self.non_places.append(occupant)
            self.maj_statut()
            self.dessiner()

        def installer(self, case, eleve):
            for i, e in enumerate(self.non_places):
                if e is eleve:
                    self.non_places.pop(i)
                    break
            ancien = self.affectation.get(case)
            self.affectation[case] = eleve
            if ancien is not None:
                self.non_places.append(ancien)
            self.maj_statut()
            self.dessiner()

        # ---- dessin ---------------------------------------------------------
        def dessiner(self):
            cv = self.canvas
            cv.delete("all")
            R, C, marge, ecart, h_tab, cw, chh, w, h = self.geometrie()
            for r in range(R):
                for c in range(C):
                    x, y, cw2, chh2 = self.coord(r, c)
                    eleve = self.affectation.get((r, c))
                    if (r, c) not in self.places:
                        coul, bord, ep = C_VIDE, "#d2d4d9", 1
                    elif eleve:
                        coul = {"F": C_F, "G": C_G}.get(eleve["sexe"], C_NEUTRE)
                        bord, ep = "#8f93a3", 1
                    else:
                        coul, bord, ep = C_LIBRE, "#b9bcc6", 1
                    if self.drag == (r, c):
                        bord, ep = C_SEL, 3
                    cv.create_rectangle(x, y, x + cw2, y + chh2, fill=coul,
                                        outline=bord, width=ep)
                    if eleve:
                        nom, prenom = couper_nom(eleve["nom"])
                        cv.create_text(x + cw2 / 2, y + chh2 / 2 - 7, text=nom,
                                       font=("Helvetica", 9, "bold"), width=cw2 - 8)
                        cv.create_text(x + cw2 / 2, y + chh2 / 2 + 8, text=prenom,
                                       font=("Helvetica", 9), fill="#3a3a45",
                                       width=cw2 - 8)
                    elif (r, c) in self.places:
                        cv.create_text(x + cw2 / 2, y + chh2 / 2,
                                       text="rang %d" % (r + 1),
                                       font=("Helvetica", 8), fill="#a9adb8")

            # étiquette qui suit la souris pendant un déplacement
            if self.drag is not None and self.pointeur is not None:
                eleve = self.affectation.get(self.drag)
                if eleve:
                    px, py = self.pointeur
                    lw, lh = 118, 30
                    cv.create_rectangle(px - lw / 2, py - lh / 2, px + lw / 2,
                                        py + lh / 2,
                                        fill={"F": C_F, "G": C_G}.get(
                                            eleve["sexe"], C_NEUTRE),
                                        outline=C_SEL, width=2)
                    cv.create_text(px, py, text=eleve["nom"],
                                   font=("Helvetica", 9, "bold"), width=lw - 8)

            y_tab = h - marge - h_tab + 6 if self.tableau_en_bas.get() else marge
            grille_w = cw * C + ecart * (C - 1)
            cx = marge + grille_w / 2
            cv.create_rectangle(cx - grille_w * 0.3, y_tab,
                                cx + grille_w * 0.3, y_tab + h_tab - 12,
                                fill="#2a2e38", outline="")
            cv.create_text(cx, y_tab + (h_tab - 12) / 2, text="TABLEAU",
                           fill="white", font=("Helvetica", 10, "bold"))

        # ---- fichiers -------------------------------------------------------
        def etat(self):
            R, C = self.dimensions()
            return etat_vers_dict(self.titre.get(), R, C, self.tableau_en_bas.get(),
                                  self.places, self.affectation, self.eleves,
                                  self.non_places, self.mode_placement())

        def appliquer_etat(self, e):
            self.titre.set(e["titre"])
            self.nb_rangees.set(e["rangees"])
            self.nb_colonnes.set(e["colonnes"])
            self.tableau_en_bas.set(e["tableau_en_bas"])
            self.type_tirage.set(LIBELLE_MODE.get(e.get("mode"), TIRAGES[0]))
            self.places = e["places"]
            self.affectation = e["affectation"]
            self.eleves = e["eleves"]
            self.non_places = e["non_places"]
            self.maj_choix_tirage()
            self.maj_statut()
            self.dessiner()

        def enregistrer(self, *_):
            chemin = filedialog.asksaveasfilename(
                title="Enregistrer le plan",
                defaultextension=".plan.json",
                filetypes=[("Plan de classe", "*.json"), ("Tous les fichiers", "*.*")],
                initialfile=(self.titre.get().replace(" ", "_") or "plan") + ".plan.json")
            if not chemin:
                return
            enregistrer_plan(chemin, self.etat())
            self.statut.config(text="Plan enregistré : %s" % chemin)

        def ouvrir(self, *_):
            chemin = filedialog.askopenfilename(
                title="Ouvrir un plan",
                filetypes=[("Plan de classe", "*.json"), ("Tous les fichiers", "*.*")])
            if not chemin:
                return
            try:
                self.appliquer_etat(ouvrir_plan(chemin))
            except Exception as exc:
                messagebox.showerror(APP_NAME, "Fichier illisible :\n%s" % exc)
                return
            self.lbl_csv.config(text=os.path.basename(chemin), fg="#1f6f3f")
            self.statut.config(text="Plan chargé : %s" % os.path.basename(chemin))

        def _nom_fichier(self, suffixe):
            base = _strip_accents(self.titre.get()).strip() or "plan_de_classe"
            base = "".join(c if (c.isalnum() or c in " -_") else " " for c in base)
            base = "_".join(base.split()) or "plan_de_classe"
            return "%s_%s.pdf" % (base, suffixe)

        def export(self, vue="prof"):
            if not self.affectation:
                messagebox.showinfo(APP_NAME, "Place d'abord les élèves.")
                return
            suffixe = "prof" if vue == "prof" else "projection"
            chemin = filedialog.asksaveasfilename(
                title="Exporter — %s" % VUES[vue][0],
                defaultextension=".pdf", filetypes=[("PDF", "*.pdf")],
                initialfile=self._nom_fichier(suffixe))
            if not chemin:
                return
            R, C = self.dimensions()
            exporter_pdf(chemin, self.affectation, R, C, titre=self.titre.get(),
                         vue=vue)
            self.statut.config(text="PDF enregistré : %s" % chemin)
            self._ouvrir(chemin)

        def export_les_deux(self):
            """Écrit les deux vues côte à côte dans le dossier choisi."""
            if not self.affectation:
                messagebox.showinfo(APP_NAME, "Place d'abord les élèves.")
                return
            dossier = filedialog.askdirectory(
                title="Dossier où enregistrer les deux PDF")
            if not dossier:
                return
            R, C = self.dimensions()
            ecrits = []
            for vue, suffixe in (("prof", "prof"), ("projection", "projection")):
                chemin = os.path.join(dossier, self._nom_fichier(suffixe))
                exporter_pdf(chemin, self.affectation, R, C,
                             titre=self.titre.get(), vue=vue)
                ecrits.append(chemin)
            self.statut.config(text="2 PDF enregistrés dans %s" % dossier)
            self._ouvrir(ecrits[0])

        def _ouvrir(self, chemin):
            try:
                if sys.platform.startswith("win"):
                    os.startfile(chemin)
                elif sys.platform == "darwin":
                    os.system('open "%s"' % chemin)
                else:
                    os.system('xdg-open "%s" &' % chemin)
            except Exception:
                pass

        # ---- reprise automatique de la dernière session ----------------------
        def charger_config(self):
            try:
                with open(CONFIG_FILE, "r", encoding="utf-8") as f:
                    self.appliquer_etat(dict_vers_etat(json.load(f)))
            except Exception:
                pass

        def sauver_config(self):
            try:
                enregistrer_plan(CONFIG_FILE, self.etat())
            except Exception:
                pass

    root = tk.Tk()
    app = App(root)
    root.protocol("WM_DELETE_WINDOW", lambda: (app.sauver_config(), root.destroy()))
    if _hook is not None:            # utilisé par les tests automatiques
        root.after(300, lambda: _hook(root, app))
    root.mainloop()



# ---------------------------------------------------------------------------
# 6. Autotest
# ---------------------------------------------------------------------------

def selftest(chemin_csv=None):
    ok = True

    def check(nom, cond):
        nonlocal ok
        print(("  OK   " if cond else "  ÉCHEC") + " | " + nom)
        ok = ok and bool(cond)

    print("— Lecture CSV —")
    if chemin_csv and os.path.exists(chemin_csv):
        eleves, msg = lire_csv(chemin_csv)
        print("  " + msg)
        check("4 élèves détectés", len(eleves) == 4)
        check("3 filles / 1 garçon",
              sum(1 for e in eleves if e["sexe"] == "F") == 3)
        check("lignes parasites ignorées",
              all(e["nom"].upper().startswith("DUPON") for e in eleves))

    print("— Placement —")
    rng = random.Random(1)
    eleves = ([{"nom": "F%d" % i, "sexe": "F"} for i in range(15)]
              + [{"nom": "G%d" % i, "sexe": "G"} for i in range(15)])
    places = [(r, c) for r in range(5) for c in range(6)]
    for _ in range(200):
        aff, reste = placer(eleves, places, rng)
        assert not reste
        if compter_ruptures(aff) != 0:
            check("alternance parfaite quand 15F/15G sur 5x6", False)
            break
    else:
        check("alternance parfaite quand 15F/15G sur 5x6", True)

    aff, _ = placer(eleves[:12], places, rng)
    check("remplissage à partir du 1er rang",
          {r for (r, c) in aff} == {0, 1} and len(aff) == 12)

    des = [{"nom": "F%d" % i, "sexe": "F"} for i in range(20)] + \
          [{"nom": "G%d" % i, "sexe": "G"} for i in range(6)]
    aff, _ = placer(des, places, rng)
    rangs_ruptures = [r for (r, c), e in aff.items()
                      if aff.get((r, c + 1)) and aff[(r, c + 1)]["sexe"] == e["sexe"]]
    check("déséquilibre repoussé vers le fond",
          not rangs_ruptures or min(rangs_ruptures) >= 1)

    aff, reste = placer(eleves + [{"nom": "X", "sexe": "F"}] * 5, places, rng)
    check("élèves en surnombre signalés", len(reste) == 5 and len(aff) == 30)

    aff, _ = placer([{"nom": "Seul Unique", "sexe": "G"}], [(0, 0)], rng)
    check("cas d'un seul élève", len(aff) == 1)

    print("— CSV sans colonne « Sexe » —")
    dossier = os.path.dirname(os.path.abspath(__file__))
    tmp_csv = os.path.join(dossier, "_test_sans_sexe.csv")
    with open(tmp_csv, "w", encoding="utf-8") as fh:
        fh.write("Nom;Classe;Né(e) le\n")
        fh.write("MARTIN Léa;2nde 7;12/03/2011\n")
        fh.write("DAVID Lucas;2nde 7;07/09/2010\n")
        fh.write("MÜLLER Noé;2nde 7;01/01/2011\n")
        fh.write(";;\n")
        fh.write("x;;\n")
    sans, msg2 = lire_csv(tmp_csv)
    print("  " + msg2)
    check("lecture réussie sans colonne Sexe", len(sans) == 3)
    check("noms corrects", [e["nom"] for e in sans]
          == ["MARTIN Léa", "DAVID Lucas", "MÜLLER Noé"])
    check("sexe laissé à None", all(e["sexe"] is None for e in sans))
    check("message explicite", "Sexe" in msg2)
    aff, _ = placer(sans, [(0, 0), (0, 1), (0, 2)], rng)
    check("placement possible malgré l'alternance demandée", len(aff) == 3)
    check("aucune rupture comptée sans sexe", compter_ruptures(aff) == 0)
    mixte = sans + [{"nom": "A", "sexe": "F"}, {"nom": "B", "sexe": "G"}]
    aff, _ = placer(mixte, [(0, i) for i in range(5)], rng)
    check("fichier partiellement renseigné accepté", len(aff) == 5)
    varie = {
        "colonnes Nom + Prénom": ("Nom;Prénom;Classe\nMARTIN;Léa;2nde 7\n"
                                  "DAVID;Lucas;2nde 7\n", ["MARTIN Léa", "DAVID Lucas"]),
        "pied de page ignoré": ("Élèves\nMARTIN Léa\nDAVID Lucas\n\n"
                                "Total : 2 élèves\n", ["MARTIN Léa", "DAVID Lucas"]),
        "sans en-tête": ("MARTIN Léa\nDAVID Lucas\n", ["MARTIN Léa", "DAVID Lucas"]),
        "nom en 3e colonne": ("N°;Classe;Élèves\n1;2nde 7;MARTIN Léa\n"
                              "2;2nde 7;DAVID Lucas\n", ["MARTIN Léa", "DAVID Lucas"]),
    }
    for libelle, (contenu, attendu) in varie.items():
        with open(tmp_csv, "w", encoding="utf-8") as fh:
            fh.write(contenu)
        check(libelle, [e["nom"] for e in lire_csv(tmp_csv)[0]] == attendu)
    with open(tmp_csv, "w", encoding="utf-8") as fh:
        fh.write("Élèves,Sexe\nMARTIN Léa,Féminin\nDAVID Lucas,\nROUX Nathan,Masculin\n")
    partiel, msg3 = lire_csv(tmp_csv)
    check("sexe manquant sur une ligne : élève conservé",
          [e["sexe"] for e in partiel] == ["F", None, "G"] and "sans sexe" in msg3)

    dest_pdf = os.path.join(dossier, "_test_sans_sexe.pdf")
    exporter_pdf(dest_pdf, aff, 1, 5, titre="sans sexe")
    check("PDF généré sans couleur de sexe", os.path.getsize(dest_pdf) > 1000)
    os.remove(tmp_csv); os.remove(dest_pdf)

    print("— Tirage aléatoire pur —")
    vus, ruptures_vues = set(), set()
    for _ in range(200):
        aff, reste = placer(eleves, places, rng, mode="aleatoire")
        assert not reste
        vus.add(tuple(sorted((r, c, aff[(r, c)]["nom"]) for (r, c) in aff)))
        ruptures_vues.add(compter_ruptures(aff))
    check("tous les élèves sont placés", len(aff) == 30)
    check("tirages tous différents (vrai hasard)", len(vus) == 200)
    check("l'alternance n'est plus imposée", max(ruptures_vues) > 0)
    aff, _ = placer(eleves[:12], places, rng, mode="aleatoire")
    check("remplissage toujours depuis le 1er rang",
          {r for (r, c) in aff} == {0, 1} and len(aff) == 12)
    aff, reste = placer(eleves + [{"nom": "Y", "sexe": "G"}] * 4, places, rng,
                        mode="aleatoire")
    check("surnombre signalé aussi en mode aléatoire",
          len(reste) == 4 and len(aff) == 30)

    print("— Ordre alphabétique et placement à la main —")
    alpha = [{"nom": n, "sexe": None} for n in
             ["ZOLA Émile", "abbé Pierre", "MARTIN Léa", "Éluard Paul", "BLANC Louis"]]
    sieges = [(0, 0), (0, 1), (1, 0), (1, 1), (2, 0)]
    aff, reste = placer(alpha, sieges, rng, mode="alpha")
    ordre = [aff[s]["nom"] for s in sieges]
    check("A → Z, en partant du premier rang",
          ordre == ["abbé Pierre", "BLANC Louis", "Éluard Paul", "MARTIN Léa",
                    "ZOLA Émile"])
    check("accents et majuscules ignorés dans le tri",
          ordre.index("Éluard Paul") == 2)
    aff, _ = placer(alpha, sieges, rng, mode="alpha_desc")
    check("Z → A", [aff[s]["nom"] for s in sieges] == list(reversed(ordre)))
    aff, reste = placer(alpha, sieges[:3], rng, mode="alpha")
    check("trop d'élèves : les derniers de la liste restent debout",
          [e["nom"] for e in reste] == ["MARTIN Léa", "ZOLA Émile"])
    aff, reste = placer(alpha, sieges, rng, mode="manuel")
    check("placement à la main : aucune place occupée", aff == {})
    check("placement à la main : tout le monde à installer",
          len(reste) == len(alpha))

    print("— Enregistrement / relecture —")
    aff, _ = placer(eleves, places, rng)
    tmp = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_test_plan.json")
    etat = etat_vers_dict("2nde 7", 5, 6, True, set(places), aff, eleves, [])
    enregistrer_plan(tmp, etat)
    relu = ouvrir_plan(tmp)
    check("salle relue à l'identique", relu["places"] == set(places))
    check("titre et orientation conservés",
          relu["titre"] == "2nde 7" and relu["tableau_en_bas"] is True)
    check("placement relu à l'identique",
          {k: v["nom"] for k, v in relu["affectation"].items()}
          == {k: v["nom"] for k, v in aff.items()})
    check("effectif complet conservé", len(relu["eleves"]) == len(eleves))
    aff2, reste2 = placer(eleves, places, rng)
    e2 = etat_vers_dict("x", 5, 6, False, set(places[:10]), aff2, eleves, reste2)
    enregistrer_plan(tmp, e2)
    check("un plan partiel se relit sans erreur",
          ouvrir_plan(tmp)["places"] == set(places[:10]))
    e3 = etat_vers_dict("x", 5, 6, True, set(places), aff2, eleves, reste2,
                        mode="alpha_desc")
    enregistrer_plan(tmp, e3)
    check("mode de placement conservé", ouvrir_plan(tmp)["mode"] == "alpha_desc")

    # la liste et le placement doivent partager les mêmes objets élèves
    relu2 = ouvrir_plan(tmp)
    place = relu2["affectation"][sorted(relu2["affectation"])[0]]
    check("liste et placement partagent les mêmes élèves",
          any(e is place for e in relu2["eleves"]))
    place["nom"] = "CORRIGÉ Nom"
    check("corriger un nom le corrige partout",
          sum(1 for e in relu2["eleves"] if e["nom"] == "CORRIGÉ Nom") == 1)
    orphelin = dict(e3)
    orphelin["eleves"] = []          # plan dont la liste d'élèves a été perdue
    recup = dict_vers_etat(orphelin)
    check("élèves placés mais absents de la liste : récupérés",
          len(recup["eleves"]) == len(recup["affectation"])
          and all(any(x is v for x in recup["eleves"])
                  for v in recup["affectation"].values()))
    ancien = dict(e3)
    ancien.pop("mode"); ancien["alterner"] = False
    check("plan enregistré par une version précédente relu sans erreur",
          dict_vers_etat(ancien)["mode"] == "aleatoire")
    os.remove(tmp)

    print("— Export PDF : vue prof et vue projetée —")
    petit = {(0, 0): {"nom": "GAUCHE Alice", "sexe": "F"},
             (0, 2): {"nom": "DROITE Bob", "sexe": "G"}}
    chemins = {}
    for vue in ("prof", "projection"):
        chemins[vue] = os.path.join(
            os.path.dirname(os.path.abspath(__file__)), "_test_%s.pdf" % vue)
        exporter_pdf(chemins[vue], petit, 2, 3, titre="miroir", vue=vue)

    def _x_de(chemin, nom):
        """Abscisse à laquelle ce nom est écrit dans le flux PDF."""
        texte = open(chemin, "rb").read().decode("cp1252", "replace")
        avant = texte[:texte.index("(" + nom)]      # « ... x y Td (NOM) Tj »
        return float(avant.split()[-3])

    xg_prof = _x_de(chemins["prof"], "GAUCHE")
    xd_prof = _x_de(chemins["prof"], "DROITE")
    xg_proj = _x_de(chemins["projection"], "GAUCHE")
    xd_proj = _x_de(chemins["projection"], "DROITE")
    check("vue prof : colonne 0 à gauche", xg_prof < xd_prof)
    check("vue projetée : colonnes inversées (pas d'effet miroir)",
          xg_proj > xd_proj)
    check("vue projetée : le tableau passe en haut",
          "Vue des" in open(chemins["projection"], "rb").read()
          .decode("cp1252", "replace"))
    for c in chemins.values():
        os.remove(c)

    print("— Export PDF —")
    aff, _ = placer(eleves, places, rng)
    dest = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_test_plan.pdf")
    exporter_pdf(dest, aff, 5, 6, titre="2nde 7 — test")
    taille = os.path.getsize(dest)
    with open(dest, "rb") as f:
        contenu = f.read()
    check("PDF créé et non vide (%d octets)" % taille, taille > 2000)
    check("en-tête et fin de fichier PDF valides",
          contenu.startswith(b"%PDF-1.4") and contenu.rstrip().endswith(b"%%EOF"))
    exporter_pdf(dest, aff, 5, 6, titre="test signature")
    with open(dest, "rb") as f:
        contenu = f.read()
    check("signature « %s » présente dans le PDF" % AUTEUR,
          AUTEUR.encode("cp1252") in contenu)
    check("noms accentués acceptés",
          exporter_pdf(dest, {(0, 0): {"nom": "MÜLLER Éloïse", "sexe": "F"}}, 1, 1) == dest)

    print("\nRésultat : " + ("tout est OK" if ok else "au moins un test a échoué"))
    return 0 if ok else 1


if __name__ == "__main__":
    if "--selftest" in sys.argv:
        arg = [a for a in sys.argv[1:] if not a.startswith("--")]
        sys.exit(selftest(arg[0] if arg else None))
    lancer_interface()