"""
generators/planning_pptx.py
Générateur de planning Gantt PowerPoint — Python pur (python-pptx).

Architecture générale :
  1. generate_pptx()       → point d'entrée public
  2. build_ordered_rows()  → construit la liste hiérarchique des lignes
  3. compute_date_range()  → calcule la fenêtre temporelle du calendrier
  4. iter_columns()        → génère les colonnes selon l'échelle (jour/semaine/mois)
  5. split_into_slides()   → découpe les lignes en groupes (1 groupe = 1 slide)
  6. draw_slide()          → dessine un slide complet
     ├── draw_time_headers()     → en-têtes dates (2 niveaux)
     ├── _draw_domain_row()      → ligne de séparation de domaine
     ├── _draw_task_row()        → ligne chantier / tâche / jalon
     ├── _draw_reunion_row()     → ligne de réunions récurrentes
     ├── draw_vertical_lines()   → traits verticaux entre périodes
     └── draw_today_line()       → trait rouge "aujourd'hui"

Coordonnées : toutes les fonctions travaillent en inches (pouces).
python-pptx attend des EMU (English Metric Units) → on passe par inch().
1 inch = 914400 EMU.

Pour modifier l'apparence :
  - Couleurs    → section "Palette" (constantes hexadécimales sans #)
  - Dimensions  → section "Zones de mise en page"
  - Polices     → chercher font_size= dans add_text()
  - Épaisseur   → chercher line_w= dans add_line() / add_rect()
"""

from __future__ import annotations  # PEP 585 (`list[Task]`) sans Python 3.9 : l'hébergement est en 3.7


import os
from datetime import datetime, timedelta
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
from pptx.enum.shapes import MSO_SHAPE

from core.data_model import (
    Task, ReunionGroup, TYPE_CHANTIER, TYPE_JALON, TYPE_TACHE, TYPE_REUNION,
    group_reunions
)
from core.design_kit import (
    BLUE_DARK, ORANGE as DK_ORANGE, new_presentation,
    SIMPLE_LAYOUT_IDX, SIMPLE_PROJECT_NAME_PLACEHOLDER_IDX, SIMPLE_TITLE_PLACEHOLDER_IDX,
)


# ══════════════════════════════════════════════════════════════════
# CONSTANTES DE MISE EN PAGE
# Toutes les valeurs sont en inches (pouces).
# Le slide LAYOUT_WIDE mesure 13.33" × 7.5".
# ══════════════════════════════════════════════════════════════════

SLIDE_W = Inches(13.33)   # largeur totale du slide
SLIDE_H = Inches(7.5)     # hauteur totale du slide

# Marges extérieures
ML = 0.35   # marge gauche (début de la colonne labels)
MR = 0.25   # marge droite
MT = 1.55   # marge haute — espace réservé au titre + en-têtes dates
MB = 0.60   # marge basse — espace réservé au pied de page

# Bord droit du vrai logo posé par le layout "Simple" (x=0.42"-3.49" — voir
# `design_kit.SIMPLE_LAYOUT_IDX`), avec un peu de marge. Sert à ne pas faire
# passer la ligne de pied de page par-dessus.
LOGO_RIGHT_EDGE = 3.6

# Colonne des labels (noms de tâches, à gauche)
LABEL_W = 3.4    # largeur de la colonne labels

# Zone Gantt (calendrier, à droite des labels)
GANTT_X = ML + LABEL_W + 0.05   # position X du bord gauche du Gantt
GANTT_W = 13.33 - GANTT_X - MR  # largeur totale de la zone Gantt

# Hauteurs de lignes
HDR_H   = 0.28   # hauteur d'un niveau d'en-tête de dates (il y en a 2)
ROW_H   = 0.255  # hauteur d'une ligne normale (chantier, tâche, jalon)
ROW_H_D = 0.30   # hauteur d'une ligne de séparation de domaine
ROW_H_R = 0.225  # hauteur d'une ligne de réunions récurrentes

# Nombre maximum de lignes "normales" par slide avant de couper
# (utilisé en mode page_mode="fill" uniquement)
MAX_ROWS = 20

# Largeur minimale d'une barre, en inches. Un élément ponctuel — ou une tâche
# d'un jour à l'échelle du mois — produit une barre de largeur nulle, donc
# invisible. Ce plancher en fait un point lisible, sans fausser la lecture des
# barres longues.
MIN_MARK_W = 0.08


# ══════════════════════════════════════════════════════════════════
# PALETTE DE COULEURS
# Toutes les couleurs sont des chaînes hex 6 caractères SANS #.
# Exemple : "234964" = bleu foncé Oncopole.
# ══════════════════════════════════════════════════════════════════

def rgb(hex_str):
    """Convertit une chaîne hex 6 chars en objet RGBColor python-pptx."""
    return RGBColor(int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16))


# Couleurs fixes de l'interface — BLUE et ORANGE viennent de `design_kit`
# (source unique) depuis que `generate_pptx()` ouvre le vrai template
# Oncopole ; les autres n'ont pas d'équivalent dans le socle partagé pour
# l'instant, elles restent locales à ce générateur.
BLUE      = BLUE_DARK   # bleu Oncopole — en-têtes de dates (niveau 1)
ORANGE    = DK_ORANGE   # orange Oncopole — titre et jalons
WHITE     = "FFFFFF"   # blanc pur
GRAY_MID  = "DDE3EA"   # gris moyen — bordures, pied de page
GRAY_TEXT = "6B7A8D"   # gris texte secondaire
TEXT_DARK = "1A2733"   # texte principal quasi-noir
VIOLET    = "6C3483"   # violet — réunions récurrentes
RED_TODAY = "E53935"   # rouge — ligne "aujourd'hui"

# Paires de couleurs par domaine : (couleur forte chantier, couleur light tâche).
# La couleur forte sert pour la barre Gantt du chantier et le fond de son label.
# La couleur light sert pour la barre Gantt des tâches enfants.
# Les couleurs s'assignent dans l'ordre d'apparition des domaines.
# Au-delà de 8 domaines, on repart du début (modulo).
DOMAIN_PALETTE = [
    ("234964", "BDD5E3"),   # 1 — Bleu Oncopole
    ("1A6B4A", "C2E8D8"),   # 2 — Vert sauge
    ("6B2D7B", "E8D0EE"),   # 3 — Prune
    ("8B6914", "F0E4B8"),   # 4 — Ocre
    ("3D5A6C", "C8D8E0"),   # 5 — Ardoise
    ("9E3D2B", "F2C9C2"),   # 6 — Terracotta
    ("3B3B8E", "CECEF5"),   # 7 — Indigo
    ("555555", "E0E0E0"),   # 8 — Gris (débordement)
]


# ══════════════════════════════════════════════════════════════════
# HELPERS DE DESSIN
# Fonctions utilitaires de bas niveau pour ajouter des formes,
# du texte et des lignes sur un slide python-pptx.
# ══════════════════════════════════════════════════════════════════

def inch(v):
    """Convertit une valeur en inches vers des EMU (unité python-pptx)."""
    return Inches(v)


def add_rect(slide, x, y, w, h, fill_hex, line_hex=None, line_w=0, shadow=False):
    """
    Ajoute un rectangle plein sur le slide.

    Paramètres :
        slide    — objet Slide python-pptx
        x, y     — position du coin supérieur gauche (inches)
        w, h     — largeur et hauteur (inches)
        fill_hex — couleur de remplissage (hex 6 chars, ex: "234964")
        line_hex — couleur de bordure (optionnel, None = pas de bordure)
        line_w   — épaisseur de la bordure en points (0 = pas de bordure)

    Retourne : l'objet Shape créé.
    """
    shape = slide.shapes.add_shape(
        1,  # 1 = MSO_SHAPE_TYPE.RECTANGLE
        inch(x), inch(y), inch(w), inch(h)
    )
    shape.fill.solid()
    shape.fill.fore_color.rgb = rgb(fill_hex)
    if shadow == False:
        shape.shadow.inherit = False
        shape.shadow.visible = False
    if line_hex and line_w > 0:
        shape.line.color.rgb = rgb(line_hex)
        shape.line.width = Pt(line_w)
    else:
        shape.line.fill.background()  # pas de bordure
    return shape


def add_rounded_bar(slide, x, y, w, h, fill_hex, shadow=False):
    """
    Comme `add_rect()`, mais pour les barres de Gantt uniquement (retour du
    22/09 : coins très légèrement arrondis) — fonction séparée plutôt qu'un
    paramètre sur `add_rect()`, pour que les fonds de ligne (colonne label)
    et les en-têtes de dates, qui appellent `add_rect()` ailleurs dans ce
    fichier, restent des rectangles francs sans y toucher.
    """
    shape = slide.shapes.add_shape(
        MSO_SHAPE.ROUNDED_RECTANGLE,
        inch(x), inch(y), inch(w), inch(h)
    )
    shape.adjustments[0] = 0.3
    shape.fill.solid()
    shape.fill.fore_color.rgb = rgb(fill_hex)
    if shadow == False:
        shape.shadow.inherit = False
        shape.shadow.visible = False
    shape.line.fill.background()
    return shape


def add_text(slide, text, x, y, w, h, font_size=9, bold=False,
             color_hex=TEXT_DARK, align=PP_ALIGN.LEFT, italic=False):
    """
    Ajoute une zone de texte sur le slide.

    Le texte ne revient pas à la ligne (word_wrap=False).
    La police utilisée est Arial dans tous les cas.

    Paramètres :
        slide      — objet Slide python-pptx
        text       — contenu textuel
        x, y       — position du coin supérieur gauche (inches)
        w, h       — largeur et hauteur de la zone (inches)
        font_size  — taille de police en points (défaut : 9)
        bold       — gras (défaut : False)
        color_hex  — couleur du texte (hex 6 chars, défaut : TEXT_DARK)
        align      — alignement horizontal (PP_ALIGN.LEFT/CENTER/RIGHT)
        italic     — italique (défaut : False)

    Retourne : l'objet TextBox créé.

    Note : si le texte est trop long pour la zone, il sera tronqué
    visuellement par PowerPoint mais ne provoquera pas d'erreur.
    """
    txBox = slide.shapes.add_textbox(inch(x), inch(y), inch(w), inch(h))
    tf    = txBox.text_frame
    tf.word_wrap = False
    tf.auto_size = None
    p   = tf.paragraphs[0]
    p.alignment = align
    run = p.add_run()
    run.text           = text
    run.font.size      = Pt(font_size)
    run.font.bold      = bold
    run.font.italic    = italic
    run.font.color.rgb = rgb(color_hex)
    run.font.name      = "Arial"
    return txBox


def add_line(slide, x1, y1, x2, y2, color_hex, line_w=0.75, dash=False):
    """
    Trace un segment de droite entre deux points.

    Paramètres :
        slide     — objet Slide python-pptx
        x1, y1   — point de départ (inches)
        x2, y2   — point d'arrivée (inches)
        color_hex — couleur de la ligne (hex 6 chars)
        line_w    — épaisseur en points (défaut : 0.75)
        dash      — True pour une ligne en tirets (défaut : False)

    Retourne : l'objet Connector créé.

    Note technique : python-pptx utilise add_connector() qui crée
    un connecteur droit entre deux points. C'est équivalent à une
    ligne simple pour notre usage.
    """
    connector = slide.shapes.add_connector(
        1,  # 1 = MSO_CONNECTOR_TYPE.STRAIGHT
        inch(x1), inch(y1), inch(x2), inch(y2)
    )
    
    connector.line.color.rgb = rgb(color_hex)
    connector.line.width = Pt(line_w)    
    connector.shadow.inherit = False
    connector.shadow.visible = False   
    if dash:
        connector.line.dash_style = 4  # 4 = DASH dans l'enum MSO_LINE_DASH_STYLE
    return connector


# ══════════════════════════════════════════════════════════════════
# CALCUL DE LA FENÊTRE TEMPORELLE
# ══════════════════════════════════════════════════════════════════

def _start_of_week(d):
    """Retourne le lundi de la semaine contenant la date d."""
    return d - timedelta(days=d.weekday())


def _start_of_month(d):
    """Retourne le premier jour du mois de la date d."""
    return d.replace(day=1)


def compute_date_range(tasks, scale, range_start=None, range_end=None):
    """
    Calcule la fenêtre temporelle [start, end] à afficher dans le Gantt.

    **Une période imposée gagne sur tout le reste** (Joseph, chantier JOS-69) :
    `range_start`/`range_end` viennent du bloc, où l'utilisateur a choisi « ce
    trimestre » ou « cette année ». Sans eux — le chemin ClickUp historique — la
    fenêtre est déduite des dates des tâches, comme avant.

    Deux règles ne s'appliquent alors plus, et c'est voulu :

    - la fenêtre ne s'étire pas jusqu'à couvrir toutes les tâches ; ce qui tombe
      hors période garde sa ligne, sans barre ;
    - **aujourd'hui n'est plus forcé dans la fenêtre** — sans quoi « l'année
      dernière » s'étirerait jusqu'à maintenant. Le trait rouge disparaît
      simplement du dessin s'il tombe au-dehors.

    L'alignement d'échelle, lui, reste appliqué dans les deux cas : un trimestre
    commence le 1er avril, qui n'est pas un lundi.

    Règles (fenêtre déduite) :
    - La fenêtre couvre toutes les dates de début et d'échéance des tâches.
    - Aujourd'hui est toujours visible dans la fenêtre.
    - En mode jour/semaine : la fenêtre commence sur un lundi.
      Si le lundi de la première semaine tombe dans le mois précédent,
      on avance d'une semaine pour éviter d'afficher un mois parasite.
    - En mode mois : la fenêtre est alignée sur les débuts/fins de mois.
    - Les occurrences des réunions récurrentes (ReunionGroup) sont
      aussi prises en compte pour calculer la borne maximale.

    Paramètres :
        tasks — liste de Task ou ReunionGroup
        scale — "jour", "semaine" ou "mois"

    Retourne : (min_date, max_date) deux objets datetime à minuit.
    """
    today = datetime.today().replace(hour=0, minute=0, second=0, microsecond=0)

    if range_start and range_end:
        min_d = range_start.replace(hour=0, minute=0, second=0, microsecond=0)
        max_d = range_end.replace(hour=0, minute=0, second=0, microsecond=0)
    else:
        dates = []

        for t in tasks:
            if isinstance(t, ReunionGroup):
                # Pour les réunions, on prend toutes les occurrences
                dates.extend(t.occurrences)
                continue
            if hasattr(t, 'start_date') and t.start_date:
                dates.append(t.start_date.replace(hour=0, minute=0, second=0, microsecond=0))
            if hasattr(t, 'due_date') and t.due_date:
                dates.append(t.due_date.replace(hour=0, minute=0, second=0, microsecond=0))

        if dates:
            min_d = min(min(dates), today)
            max_d = max(max(dates), today)
        else:
            # Aucune tâche : fenêtre par défaut centrée sur aujourd'hui
            min_d = today - timedelta(days=30)
            max_d = today + timedelta(days=120)

    if scale in ("jour", "semaine"):
        # Aligner min_d sur le lundi de sa semaine.
        # Cas particulier : si ce lundi est dans le mois précédent
        # (ex: 1er jan 2026 est un jeudi → lundi = 29 déc 2025),
        # on avance d'une semaine pour ne pas afficher décembre.
        sw = _start_of_week(min_d)
        if sw.month < min_d.month or sw.year < min_d.year:
            sw = sw + timedelta(weeks=1)
        min_d = sw
        # max_d : fin de la semaine contenant la dernière date
        max_d = _start_of_week(max_d) + timedelta(days=6)
    else:
        # Mode mois : aligner sur le 1er et le dernier jour du mois
        import calendar
        min_d = _start_of_month(min_d)
        last  = calendar.monthrange(max_d.year, max_d.month)[1]
        max_d = max_d.replace(day=last)

    return min_d, max_d


# ══════════════════════════════════════════════════════════════════
# GÉNÉRATION DES COLONNES
# ══════════════════════════════════════════════════════════════════

def iter_columns(start, end, scale):
    """
    Génère la liste des colonnes du calendrier entre start et end.

    Chaque colonne est un dict contenant :
      - "date"          : datetime du début de la colonne
      - Clés booléennes indiquant les frontières de période :
          "new_week"    (mode jour)   : True si lundi
          "new_month"   (mode semaine): True si 1ère semaine du mois
          "new_quarter" (semaine/mois): True si 1er mois du trimestre
          "new_year"    (mode mois)   : True si janvier
      - Clés de libellés pour les en-têtes :
          "week_num", "month_label", "quarter_label", "year_label"

    En mode "jour"    : 1 colonne = 1 jour
    En mode "semaine" : 1 colonne = 1 semaine (lundi)
    En mode "mois"    : 1 colonne = 1 mois entier

    Paramètres :
        start, end — datetimes délimitant la fenêtre
        scale      — "jour", "semaine" ou "mois"

    Retourne : liste de dicts, un par colonne.
    """
    cols = []
    d    = start

    if scale == "jour":
        while d <= end:
            cols.append({
                "date":        d,
                "is_weekend":  d.weekday() >= 5,          # sam ou dim
                "new_week":    d.weekday() == 0,           # lundi → frontière L2
                "new_month":   d.day == 1,
                "week_num":    d.isocalendar()[1],
                "month_label": f"{_mname(d.month)} {d.year}",
            })
            d += timedelta(days=1)

    elif scale == "semaine":
        while d <= end:
            cols.append({
                "date":          d,
                # new_month : True si c'est la 1ère semaine du mois (jour ≤ 7)
                "new_month":     d.day <= 7 and d.weekday() == 0,
                # new_quarter : True si 1ère semaine de jan/avr/jul/oct
                "new_quarter":   d.month in (1, 4, 7, 10) and d.day <= 7 and d.weekday() == 0,
                "month_label":   _mname(d.month),
                "quarter_label": f"T{(d.month - 1) // 3 + 1} {d.year}",
            })
            d += timedelta(weeks=1)

    else:  # mois
        import calendar
        while d <= end:
            cols.append({
                "date":          d,
                "new_quarter":   d.month in (1, 4, 7, 10),  # frontière L2 : trimestre
                "new_year":      d.month == 1,               # frontière L1 : année
                "month_label":   _mname(d.month),
                "quarter_label": f"T{(d.month - 1) // 3 + 1} {d.year}",
                "year_label":    str(d.year),
            })
            last = calendar.monthrange(d.year, d.month)[1]
            d    = d.replace(day=last) + timedelta(days=1)

    return cols


def _mname(m):
    """Retourne l'abréviation française du mois (1=Jan, 12=Déc)."""
    return ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun",
            "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"][m - 1]


def is_l2_boundary(col, scale):
    """
    Retourne True si cette colonne marque le début d'une période de niveau 2.

    Les niveaux d'en-têtes sont :
      Niveau 1 (haut) : mois / trimestre / année
      Niveau 2 (bas)  : semaine / mois / trimestre

    En mode jour    → L2 = début de semaine (lundi)
    En mode semaine → L2 = début de mois
    En mode mois    → L2 = début de trimestre (jan/avr/jul/oct)

    Utilisé pour tracer les traits verticaux de grille et pour
    les en-têtes de la ligne 2.
    """
    if scale == "jour":    return col.get("new_week", False)
    if scale == "semaine": return col.get("new_month", False)
    if scale == "mois":    return col.get("new_quarter", False)
    return False


def date_to_x(date, range_start, range_end):
    """
    Convertit une date en position X (inches) dans la zone Gantt.

    La zone Gantt s'étend de GANTT_X à GANTT_X + GANTT_W.
    range_start → GANTT_X, range_end → GANTT_X + GANTT_W.
    La date est clampée à l'intérieur de la fenêtre.

    Paramètres :
        date                  — datetime à convertir
        range_start, range_end — bornes de la fenêtre temporelle

    Retourne : float en inches.
    """
    total = (range_end - range_start).total_seconds()
    pos   = max(0, min((date - range_start).total_seconds(), total))
    return GANTT_X + (pos / total) * GANTT_W


def date_to_w(start, end, range_start, range_end):
    """
    Calcule la largeur en inches d'une barre entre deux dates.

    **La borne droite est exclusive : `end` est couvert en entier.** Sans le
    jour ajouté ici, une barre s'arrêtait à l'instant `end 00:00`, donc *avant*
    son propre jour d'échéance — et un sous-élément dû le même jour, dessiné en
    point sur cette date, dépassait visiblement de la barre de son chantier.
    C'est le défaut signalé le 12 août 2026 ; le Gantt de Joseph, lui, comptait
    déjà `(diffInDays + 1)` jours.

    Les dates sont clampées à la fenêtre visible pour éviter que les barres
    débordent hors de la zone Gantt.

    Retourne : float en inches (0.0 si la barre est hors de la fenêtre).
    """
    x1 = date_to_x(max(start, range_start), range_start, range_end)
    x2 = date_to_x(min(end + timedelta(days=1), range_end), range_start, range_end)
    return max(0.0, x2 - x1)


# ══════════════════════════════════════════════════════════════════
# COULEURS PAR DOMAINE
# ══════════════════════════════════════════════════════════════════

def build_domain_map(rows):
    """
    Construit le dictionnaire {nom_domaine: (couleur_forte, couleur_light)}.

    L'assignation se fait dans l'ordre d'apparition des domaines
    dans la liste rows. Le premier domaine rencontré prend la paire 1,
    le second la paire 2, etc. Au-delà de 8 domaines, on repart du début.

    Pour forcer une couleur spécifique à un domaine, il faudrait
    ajouter une surcharge dans config.py et la passer ici en argument.

    Paramètres :
        rows — liste de dicts (tels que retournés par build_ordered_rows)

    Retourne : dict {str: (str, str)}
    """
    seen = {}
    idx  = 0
    for r in rows:
        d = _domain(r)
        if d and d not in seen:
            seen[d] = DOMAIN_PALETTE[idx % len(DOMAIN_PALETTE)]
            idx += 1
    return seen


def _domain(r):
    """
    Extrait le nom du domaine d'une ligne (dict ou objet Task/ReunionGroup).

    Utilisé en interne pour normaliser l'accès au domaine quel que soit
    le type de l'objet.
    """
    if isinstance(r, dict):
        return r.get("folder_name") or r.get("list_name") or "—"
    return getattr(r, "folder_name", None) or getattr(r, "list_name", None) or "—"


# ══════════════════════════════════════════════════════════════════
# TRI HIÉRARCHIQUE
# ══════════════════════════════════════════════════════════════════

def build_ordered_rows(tasks):
    """
    Construit la liste ordonnée des lignes à afficher, en respectant
    la hiérarchie ClickUp : domaine → chantier → enfants (tâches/jalons/réunions).

    Chaque élément de la liste retournée est un dict :
      {"kind": "domain", "name": ..., "folder_name": ...}        — séparateur de domaine
      {"kind": "task",   "obj": <Task>}                          — tâche/chantier/jalon
      {"kind": "reunion","obj": <ReunionGroup>}                  — réunions récurrentes

    Algorithme :
      1. group_reunions() regroupe les sous-tâches réunion en ReunionGroup.
      2. Les chantiers sans parent sont triés par (domaine, orderindex).
      3. Pour chaque domaine, on insère d'abord un dict "domain",
         puis les chantiers du domaine avec leurs enfants,
         puis les orphelins (tâches/jalons/réunions sans parent).

    Paramètres :
        tasks — liste de Task (brute, issue de parse_tasks)

    Retourne : liste de dicts ordonnés.
    """
    tasks_with_groups = group_reunions(tasks)

    # Rang d'arrivée de chaque ligne : **l'ordre du projet**.
    #
    # Le tri d'origine était `(domaine, orderindex)`, donc alphabétique par nom
    # de domaine — et comme le domaine d'un chantier racine est son propre
    # titre, les chantiers sortaient dans l'ordre de l'alphabet. Joseph envoie
    # déjà `tasks[]` dans l'ordre de l'arbre (`ReportScope::elements()`), il
    # suffit de ne pas le défaire. `orderindex` ne suffirait pas : c'est une
    # position **entre frères**, pas un rang global.
    rank = {id(t): i for i, t in enumerate(tasks_with_groups)}

    chantiers = sorted(
        [t for t in tasks_with_groups
         if isinstance(t, Task) and t.item_type == TYPE_CHANTIER and not t.parent_id],
        key=lambda t: rank[id(t)]
    )

    # Index des enfants par parent_id, triés par orderindex
    children = {}
    for t in tasks_with_groups:
        if t.parent_id:
            children.setdefault(t.parent_id, []).append(t)
    for pid in children:
        children[pid].sort(key=lambda t: t.orderindex)

    # Orphelins non-chantiers : tâches/jalons/réunions sans parent
    def is_orphan(t):
        if isinstance(t, Task):         return t.item_type != TYPE_CHANTIER and not t.parent_id
        if isinstance(t, ReunionGroup): return not t.parent_id
        return False

    orphans = sorted(
        [t for t in tasks_with_groups if is_orphan(t)],
        key=lambda t: rank[id(t)]
    )

    # Détermine l'ordre des domaines selon leur première apparition
    domains = []
    for t in chantiers + orphans:
        d = t.folder_name or t.list_name
        if d and d not in domains:
            domains.append(d)

    result  = []
    visited = set()  # IDs déjà traités, pour éviter les doublons

    for domain in domains:
        # Ligne séparatrice de domaine
        result.append({"kind": "domain", "name": domain,
                       "folder_name": domain, "item_type": None})

        # Chantiers du domaine + toute leur descendance (dans l'ordre ClickUp).
        #
        # Récursif et non limité aux enfants directs : un chantier profondeur
        # "Tout" doit descendre à n'importe quel niveau (tâche > sous-tâche >
        # sous-sous-tâche...), pas seulement au premier. `visited` protège
        # aussi contre un cycle qui aurait échappé aux gardes PHP.
        def append_descendants(parent_id):
            for child in children.get(parent_id, []):
                cid = getattr(child, "id", None)
                if cid is not None and cid in visited:
                    continue
                kind = "reunion" if isinstance(child, ReunionGroup) else "task"
                result.append({"kind": kind, "obj": child})
                if cid is not None:
                    visited.add(cid)
                append_descendants(cid)

        for chantier in [c for c in chantiers if (c.folder_name or c.list_name) == domain]:
            if chantier.id in visited:
                continue
            result.append({"kind": "task", "obj": chantier})
            visited.add(chantier.id)
            append_descendants(chantier.id)

        # Orphelins du domaine (tâches/jalons/réunions sans chantier parent)
        for orphan in [o for o in orphans if (o.folder_name or o.list_name) == domain]:
            oid = orphan.id if hasattr(orphan, "id") else id(orphan)
            if oid not in visited:
                kind = "reunion" if isinstance(orphan, ReunionGroup) else "task"
                result.append({"kind": kind, "obj": orphan})
                visited.add(oid)

    return result


# ══════════════════════════════════════════════════════════════════
# DÉCOUPAGE EN SLIDES
# ══════════════════════════════════════════════════════════════════

def _blocks(rows):
    """
    Regroupe les lignes en **blocs insécables** : un chantier de niveau 1 avec
    toute sa descendance, ou la suite des éléments hors chantier.

    C'est l'unité de découpage des deux modes de pagination. Elle n'est pas le
    « domaine » : deux chantiers partagent un domaine dès qu'ils portent le même
    `reporting_group`, et le découpage demandé porte sur le **chantier**.

    Retourne : liste de listes de lignes.
    """
    blocks = []
    current = None
    pending_domain = None

    for r in rows:
        if r["kind"] == "domain":
            # La bande de domaine appartient au bloc qui la suit — seule, elle
            # n'a rien à annoncer.
            pending_domain = r
            continue

        obj = r.get("obj")
        starts_block = (
            r["kind"] == "task"
            and getattr(obj, "item_type", None) == TYPE_CHANTIER
            and not getattr(obj, "parent_id", None)
        )

        if starts_block or current is None:
            current = []
            blocks.append(current)
            if pending_domain is not None:
                current.append(pending_domain)
                pending_domain = None

        current.append(r)

    return [b for b in blocks if b]


def _row_units(row):
    """La hauteur d'une ligne, exprimée en multiples de `ROW_H`."""
    h = ROW_H_D if row["kind"] == "domain" else \
        ROW_H_R if row["kind"] == "reunion" else ROW_H
    return h / ROW_H


def _split_oversized(block):
    """
    Coupe un bloc plus haut qu'une diapositive.

    Nécessaire et non optionnel : les lignes sont dessinées à des ordonnées
    calculées depuis `MT`, donc au-delà de `MAX_ROWS` elles sortiraient
    physiquement du slide. La bande de domaine, si elle ouvre le bloc, est
    reprise en tête de chaque morceau pour qu'on sache toujours où l'on est.
    """
    header = block[0] if block and block[0]["kind"] == "domain" else None
    body = block[1:] if header else block

    chunks = []
    current = [header] if header else []
    units = _row_units(header) if header else 0.0

    for row in body:
        inc = _row_units(row)

        if units + inc > MAX_ROWS and len(current) > (1 if header else 0):
            chunks.append(current)
            current = [header] if header else []
            units = _row_units(header) if header else 0.0

        current.append(row)
        units += inc

    if current and len(current) > (1 if header else 0):
        chunks.append(current)

    return chunks or [block]


def split_into_slides(rows, page_mode):
    """
    Découpe la liste de lignes en groupes, chaque groupe correspondant
    à un slide PowerPoint.

    Deux modes, tous deux fondés sur les **blocs** de `_blocks()` — un chantier
    de niveau 1 et sa descendance, ou la suite des éléments hors chantier :

      "chantier" — une page par chantier de niveau 1. Les éléments hors
      ("domain")   chantier ont la leur, découpée si elle déborde. Un chantier
                   trop haut pour une diapositive est coupé lui aussi, faute de
                   quoi ses dernières lignes sortiraient du slide.

      "fill"     — remplissage maximal, **sans jamais couper un chantier** : si
      ("auto")     le bloc suivant ne tient pas dans la place restante, il
                   ouvre une nouvelle page. Un bloc à lui seul plus haut qu'une
                   page est découpé, comme ci-dessus.

    Paramètres :
        rows      — liste de dicts (tels que retournés par build_ordered_rows)
        page_mode — "chantier"/"domain", ou "fill"/"auto"

    Retourne : liste de listes de dicts (chaque sous-liste = 1 slide).
    """
    blocks = _blocks(rows)

    if page_mode in ("chantier", "domain"):
        slides = []
        for block in blocks:
            slides.extend(_split_oversized(block))
        return slides

    # Remplissage : on empile les blocs entiers tant qu'ils tiennent.
    slides = []
    current = []
    units = 0.0

    for block in blocks:
        height = sum(_row_units(r) for r in block)

        if height > MAX_ROWS:
            # Trop haut pour tenir où que ce soit : on ferme la page courante et
            # on découpe le bloc pour lui seul.
            if current:
                slides.append(current)
                current = []
                units = 0.0
            slides.extend(_split_oversized(block))
            continue

        if units + height > MAX_ROWS and current:
            slides.append(current)
            current = []
            units = 0.0

        current.extend(block)
        units += height

    if current:
        slides.append(current)

    return slides


def draw_slide(prs, rows, range_start, range_end, cols, domain_map,
               scale, slide_num, total_slides, title, show_tasks, project_name=None):
    """
    Dessine un slide PowerPoint complet.

    Structure d'un slide (de haut en bas) :
      Placeholder titre du layout "Simple"  Titre
      [MT - 2×HDR_H → MT - HDR_H]  En-tête niveau 1 (trimestre/année)
      [MT - HDR_H → MT]             En-tête niveau 2 (mois/semaine)
      [MT → MT + somme(hauteurs)]   Lignes de données (domaines, tâches...)
      [7.5" - MB → 7.5"]            Pied de page

    Paramètres :
        prs          — objet Presentation python-pptx
        rows         — liste de dicts pour ce slide (depuis split_into_slides)
        range_start  — borne gauche de la fenêtre temporelle
        range_end    — borne droite de la fenêtre temporelle
        cols         — liste de colonnes (depuis iter_columns)
        domain_map   — dict {domaine: (couleur_forte, couleur_light)}
        scale        — "jour", "semaine" ou "mois"
        slide_num    — numéro de ce slide (1-based)
        total_slides — nombre total de slides (pour l'affichage "N / total")
        title        — titre affiché en haut du slide
        show_tasks   — si False, seuls chantiers et jalons sont affichés
        project_name — si fourni, remplit le placeholder "contenu" du layout
                       "Simple" (le chapeau au-dessus du titre)
    """
    slide = prs.slides.add_slide(prs.slide_layouts[SIMPLE_LAYOUT_IDX])

    if project_name:
        slide.placeholders[SIMPLE_PROJECT_NAME_PLACEHOLDER_IDX].text_frame.text = project_name

    # Fond blanc explicite
    bg = slide.background
    bg.fill.solid()
    bg.fill.fore_color.rgb = rgb(WHITE)

    # Titre principal — retour du 22/09 : dans le vrai placeholder du layout
    # "Simple" (pas un texte à part). Sa boîte (0.55"-1.36") mord légèrement
    # sur le haut de l'en-tête calendrier (dès 0.99") ; sans conséquence tant
    # que le titre tient sur une ligne — taille réduite par prudence.
    title_ph = slide.placeholders[SIMPLE_TITLE_PLACEHOLDER_IDX]
    title_ph.text_frame.text = title
    title_ph.text_frame.paragraphs[0].runs[0].font.size = Pt(24)

    # Numéro de slide (affiché seulement si plusieurs slides)
    if total_slides > 1:
        add_text(slide, f"{slide_num} / {total_slides}",
                 12.4, 0.20, 0.7, 0.30,
                 font_size=9, color_hex=GRAY_TEXT, align=PP_ALIGN.RIGHT)

    # En-têtes de dates
    draw_time_headers(slide, cols, scale, range_start, range_end)

    # Filtrage : si show_tasks=False, on exclut les tâches ordinaires
    filtered = []
    for r in rows:
        if r["kind"] == "domain":
            filtered.append(r)
            continue
        obj = r["obj"]
        if r["kind"] == "reunion":
            filtered.append(r)
        elif isinstance(obj, Task):
            if obj.item_type in (TYPE_CHANTIER, TYPE_JALON):
                filtered.append(r)
            elif show_tasks and obj.item_type == TYPE_TACHE:
                filtered.append(r)

    # Calcul des positions Y de chaque ligne
    today     = datetime.today().replace(hour=0, minute=0, second=0, microsecond=0)
    current_y = MT
    positions = []  # liste de (row_dict, y_position, hauteur)

    for r in filtered:
        h = ROW_H_D if r["kind"] == "domain" else \
            ROW_H_R if r["kind"] == "reunion" else ROW_H
        positions.append((r, current_y, h))
        current_y += h

    # Rendu de chaque ligne
    for r, y, h in positions:
        if r["kind"] == "domain":
            _draw_domain_row(slide, r, y, h, cols, range_start, range_end, scale)
        elif r["kind"] == "reunion":
            _draw_reunion_row(slide, r["obj"], y, h, cols, range_start, range_end, scale, domain_map)
        else:
            _draw_task_row(slide, r["obj"], y, h, cols, range_start, range_end, scale, domain_map, today)

    # Traits verticaux de grille et ligne "aujourd'hui"
    if positions:
        y_top = positions[0][1]
        y_bot = positions[-1][1] + positions[-1][2]
        # Retour du 22/09 : les filets remontent jusqu'au sommet de l'en-tête
        # (MT - 2×HDR_H) — c'est désormais leur seul rôle de séparer les
        # colonnes de dates, plus un fond coloré par cellule.
        draw_vertical_lines(slide, cols, scale, range_start, range_end, MT - 2 * HDR_H, y_bot)
        draw_today_line(slide, today, range_start, range_end, y_top, y_bot)

    # Pied de page — le texte "Oncopole Toulouse" dessiné à la main a été
    # retiré : le layout "Simple" porte désormais le vrai logo de
    # l'établissement (image, toujours visible) exactement dans cette zone
    # (x=0.42"-3.49", y=6.63"-7.28"), le texte le rendait redondant et se
    # serait superposé à lui. La ligne est raccourcie pour ne pas le
    # traverser.
    add_line(slide, LOGO_RIGHT_EDGE, 7.5 - MB + 0.08, 13.33 - MR, 7.5 - MB + 0.08,
             GRAY_MID, line_w=0.5)


def _draw_domain_row(slide, r, y, h, cols, range_start, range_end, scale):
    """
    Dessine une ligne de séparation de domaine.

    Visuellement : nom du domaine en gras sur fond blanc,
    avec une ligne de séparation horizontale en bas (GRAY_MID).
    Pas de rectangle de fond (inutile sur fond blanc).

    Paramètres :
        slide         — objet Slide
        r             — dict {"kind": "domain", "name": ..., ...}
        y             — position Y du haut de la ligne (inches)
        h             — hauteur de la ligne (inches) = ROW_H_D
        cols, range_start, range_end, scale — non utilisés ici, gardés
                        pour cohérence de signature avec les autres _draw_*
    """
    add_text(slide, r["name"],
             ML + 0.08, y, LABEL_W - 0.1, h,
             font_size=9, bold=True, color_hex=TEXT_DARK)
    # Trait de séparation horizontal pleine largeur
    add_line(slide, ML, y + h, 13.33 - MR, y + h, GRAY_MID, line_w=0.75)


def _draw_task_row(slide, task, y, h, cols, range_start, range_end,
                   scale, domain_map, today):
    """
    Dessine une ligne de tâche (chantier, tâche ordinaire ou jalon).

    Partie gauche (label) :
      - Chantier : fond couleur light du domaine, texte couleur forte, gras
      - Jalon    : fond blanc, texte quasi-noir, gras
      - Tâche    : fond blanc, texte gris, normal
      L'indentation est plus marquée pour les tâches que pour les chantiers.

    Partie droite (Gantt) :
      - Chantier : rectangle plein couleur forte, hauteur 65% de la ligne
      - Tâche    : rectangle plein couleur light, hauteur 50% de la ligne
      - Jalon    : losange ◆ orange (caractère Unicode), taille 11pt
      Les barres sont clampées à la fenêtre visible (date_to_w).

    Paramètres :
        slide         — objet Slide
        task          — objet Task
        y             — position Y du haut de la ligne (inches)
        h             — hauteur de la ligne (inches) = ROW_H
        cols          — liste de colonnes (non utilisée ici mais gardée)
        range_start   — borne gauche de la fenêtre temporelle
        range_end     — borne droite de la fenêtre temporelle
        scale         — "jour", "semaine" ou "mois"
        domain_map    — dict {domaine: (couleur_forte, couleur_light)}
        today         — datetime de ce jour (pour détecter les retards)
    """
    domain = task.folder_name or task.list_name or "—"
    colors = domain_map.get(domain, DOMAIN_PALETTE[0])
    strong, light = colors

    # La couleur portée par l'élément l'emporte sur celle du domaine : Joseph la
    # résout — héritage du chantier racine compris — et l'attribution par ordre
    # d'apparition ne sert plus que de repli. Sans ça, un même chantier changeait
    # de couleur d'un rapport à l'autre selon qui apparaissait en premier.
    if getattr(task, "color", None):
        strong = task.color
        light = getattr(task, "color_light", None) or task.color

    is_chantier = task.item_type == TYPE_CHANTIER
    is_jalon    = task.item_type == TYPE_JALON

    # Couleurs de la colonne label selon le type
    if is_chantier:
        bg_label = light     # fond couleur allégée du domaine
        fg_label = strong    # texte couleur forte
    elif is_jalon:
        bg_label = WHITE
        fg_label = TEXT_DARK
    else:  # tâche ordinaire
        bg_label = WHITE
        fg_label = GRAY_TEXT

    # Fond coloré du label (seulement si différent du fond blanc du slide)
    if bg_label != WHITE:
        add_rect(slide, ML, y, LABEL_W, h, bg_label)

    # Nom de la tâche/chantier/jalon
    indent    = 0.08 if is_chantier else 0.22  # chantiers moins indentés
    font_size = 7.5  if is_chantier else 6.5
    bold      = is_chantier or is_jalon
    add_text(slide, task.name,
             ML + indent, y + 0.01, LABEL_W - indent - 0.05, h - 0.02,
             font_size=font_size, bold=bold, color_hex=fg_label)

    # Séparateur horizontal léger entre lignes
    add_line(slide, ML, y + h, 13.33 - MR, y + h, "EEEEEE", line_w=0.3)

    # ── Partie Gantt ──
    if is_jalon:
        # Jalon : losange ◆ positionné sur la date d'échéance
        if task.due_date:
            d  = task.due_date.replace(hour=0, minute=0, second=0, microsecond=0)
            cx = date_to_x(d, range_start, range_end)
            # Le losange est centré sur cx, légèrement décalé pour l'optique
            add_text(slide, "◆",
                     cx - 0.13, y + (h - 0.35) / 2, 0.26, 0.22,
                     font_size=15, bold=True, color_hex=ORANGE, align=PP_ALIGN.CENTER)

    elif task.start_date or task.due_date:
        # `or` et non `and`, et un plancher de largeur : un élément **ponctuel**
        # — une réunion, une tâche dont l'échéance vaut le début, une tâche sans
        # date de début — donnait une barre de largeur nulle, écartée par le
        # `if bw > 0` d'origine. Il disparaissait donc du planning sans rien
        # dire. Il apparaît désormais comme un point à sa date.
        start = task.start_date or task.due_date
        end   = task.due_date or task.start_date

        sd = start.replace(hour=0, minute=0, second=0, microsecond=0)
        ed = end.replace(hour=0, minute=0, second=0, microsecond=0)
        if ed < sd:
            sd, ed = ed, sd

        # Hors fenêtre des deux côtés : il n'y a rien à montrer.
        if ed >= range_start and sd <= range_end:
            bw = max(date_to_w(sd, ed, range_start, range_end), MIN_MARK_W)
            bx = date_to_x(max(sd, range_start), range_start, range_end)

            # Hauteur relative de la barre dans la ligne
            bar_h = h * 0.85 if is_chantier else h * 0.65
            bar_y = y + (h - bar_h) / 2   # centrage vertical
            # Couleur : forte pour chantier, light pour tâche
            bar_c = strong if is_chantier else light
            add_rounded_bar(slide, bx, bar_y, bw, bar_h, bar_c, shadow=True)


def _draw_reunion_row(slide, group, y, h, cols, range_start, range_end,
                      scale, domain_map):
    """
    Dessine une ligne de réunions récurrentes (ReunionGroup).

    Affiche le nom du groupe en violet gras dans la colonne label,
    puis un losange ◆ violet à la position de chaque occurrence
    dans la zone Gantt.

    Si deux occurrences tombent dans la même colonne (modes semaine/mois),
    les losanges se superposent — c'est acceptable visuellement car ça
    reste rare et signale visuellement une période dense.

    Les occurrences hors de la fenêtre [range_start, range_end] sont ignorées.

    Paramètres :
        slide         — objet Slide
        group         — objet ReunionGroup
        y             — position Y du haut de la ligne (inches)
        h             — hauteur de la ligne (inches) = ROW_H_R
        cols          — liste de colonnes (non utilisée directement)
        range_start   — borne gauche de la fenêtre temporelle
        range_end     — borne droite de la fenêtre temporelle
        scale, domain_map — non utilisés ici, gardés pour cohérence
    """
    # Label : nom du groupe en violet
    add_text(slide, group.name,
             ML + 0.08, y + 0.01, LABEL_W - 0.1, h - 0.02,
             font_size=7, bold=True, color_hex=VIOLET)
    add_line(slide, ML, y + h, 13.33 - MR, y + h, "EEEEEE", line_w=0.3)

    # Un ◆ violet par occurrence dans la fenêtre
    for occ in group.occurrences:
        occ_d = occ.replace(hour=0, minute=0, second=0, microsecond=0)
        if occ_d < range_start or occ_d > range_end:
            continue
        cx = date_to_x(occ_d, range_start, range_end)
        add_text(slide, "◆",
                 cx - 0.08, y + (h - 0.16) / 2, 0.17, 0.16,
                 font_size=7, bold=True, color_hex=VIOLET, align=PP_ALIGN.CENTER)


# ══════════════════════════════════════════════════════════════════
# EN-TÊTES TEMPORELS
# ══════════════════════════════════════════════════════════════════

def draw_time_headers(slide, cols, scale, range_start, range_end):
    """
    Dessine les deux niveaux d'en-têtes du calendrier.

    Les en-têtes occupent la zone entre y = MT - 2×HDR_H et y = MT.
      Ligne 1 (y1, fond bleu foncé) : période longue
        - mode jour    → mois
        - mode semaine → trimestre
        - mode mois    → année
      Ligne 2 (y2, fond gris clair) : période courte
        - mode jour    → semaine (numéros S1, S2...)
        - mode semaine → mois (Jan, Fév...)
        - mode mois    → trimestre (T1 2026, T2 2026...)

    Dispatche vers _draw_month_headers / _draw_week_headers (mode jour)
    ou _draw_fused (modes semaine et mois).

    Paramètres :
        slide         — objet Slide
        cols          — liste de colonnes
        scale         — "jour", "semaine" ou "mois"
        range_start   — borne gauche (pour date_to_x)
        range_end     — borne droite
    """
    y1 = MT - HDR_H * 2   # position Y de la ligne 1 (haute)
    y2 = MT - HDR_H        # position Y de la ligne 2 (basse)

    if scale == "jour":
        _draw_month_headers(slide, cols, range_start, range_end, y1)
        _draw_week_headers(slide, cols, range_start, range_end, y2)
    elif scale == "semaine":
        _draw_fused(slide, cols, range_start, range_end, y1, y2,
                    "new_quarter", "quarter_label", "new_month", "month_label")
    else:  # mois
        _draw_fused(slide, cols, range_start, range_end, y1, y2,
                    "new_year", "year_label", "new_quarter", "quarter_label")


def _draw_month_headers(slide, cols, range_start, range_end, y):
    """
    Dessine la ligne d'en-têtes de mois pour le mode "jour".

    Parcourt les colonnes et fusionne visuellement les colonnes
    appartenant au même mois en un seul rectangle bleu avec le
    nom du mois centré dedans.

    Les segments trop étroits (< 0.05") sont ignorés pour éviter
    des rectangles quasi-invisibles avec du texte illisible.

    Paramètres :
        slide                — objet Slide
        cols                 — liste de colonnes (mode jour = 1 col/jour)
        range_start, range_end — bornes pour date_to_x
        y                    — position Y de cette ligne (inches)
    """
    cur_month = None
    seg_x     = None
    seg_start = None

    for col in cols:
        d = col["date"]
        if d.month != cur_month:
            # Clôture le segment du mois précédent
            if cur_month is not None:
                end_x = date_to_x(d, range_start, range_end)
                w     = end_x - seg_x
                if w > 0.05:
                    add_rect(slide, seg_x, y, w, HDR_H - 0.01, WHITE)
                    add_text(slide, f"{_mname(cur_month)} {seg_start.year}",
                             seg_x + 0.02, y, w - 0.04, HDR_H - 0.01,
                             font_size=6.5, bold=True, color_hex=TEXT_DARK,
                             align=PP_ALIGN.CENTER)
            # Ouvre le nouveau segment
            cur_month = d.month
            seg_start = d
            seg_x     = date_to_x(d, range_start, range_end)

    # Dernier segment (pas de colonne suivante pour le déclencher)
    if cur_month is not None:
        end_x = GANTT_X + GANTT_W
        w     = end_x - seg_x
        if w > 0.05:
            add_rect(slide, seg_x, y, w, HDR_H - 0.01, WHITE)
            add_text(slide, f"{_mname(cur_month)} {seg_start.year}",
                     seg_x + 0.02, y, w - 0.04, HDR_H - 0.01,
                     font_size=6.5, bold=True, color_hex=TEXT_DARK,
                     align=PP_ALIGN.CENTER)


def _draw_week_headers(slide, cols, range_start, range_end, y):
    """
    Dessine la ligne d'en-têtes de semaines pour le mode "jour".

    Chaque colonne = 1 jour. On colorie les weekends en gris clair,
    les lundis (début de semaine) en gris moyen avec le numéro de
    semaine (S1, S42...), et les autres jours en gris très clair.

    Paramètres :
        slide                — objet Slide
        cols                 — liste de colonnes (mode jour)
        range_start, range_end — bornes pour date_to_x
        y                    — position Y de cette ligne (inches)
    """
    for col in cols:
        d  = col["date"]
        x  = date_to_x(d, range_start, range_end)
        nd = d + timedelta(days=1)
        w  = date_to_x(nd, range_start, range_end) - x

        # Couleur de fond selon le type de jour
        if col.get("is_weekend"):
            bg = "E8ECF0"   # weekend : gris clair
        elif col.get("new_week"):
            bg = "D6DCE4"   # lundi : gris moyen (marque le début de semaine)
        else:
            bg = "EEF1F4"   # autre jour de semaine : gris très clair

        add_rect(slide, x, y, max(w, 0.01), HDR_H - 0.01, bg)

        # Numéro de semaine sur les lundis uniquement
        if col.get("new_week") and col.get("week_num"):
            add_text(slide, f"S{col['week_num']}",
                     x, y, w, HDR_H - 0.01,
                     font_size=5.5, color_hex=BLUE, align=PP_ALIGN.CENTER)


def _draw_fused(slide, cols, range_start, range_end, y1, y2,
                l1_bound, l1_label, l2_bound, l2_label):
    """
    Dessine deux niveaux d'en-têtes fusionnés (modes semaine et mois).

    Chaque niveau est une suite de segments fond blanc/texte foncé, chaque
    segment couvrant une période complète (trimestre, mois, etc.). Les
    segments sont construits en parcourant les colonnes et en "clôturant"
    le segment courant dès qu'une frontière est détectée. La séparation
    entre segments se lit aux filets verticaux (voir `draw_vertical_lines()`,
    étendus jusqu'au sommet de l'en-tête par `draw_slide()`), pas à un fond
    coloré (retour du 22/09 — avant : bleu foncé niveau 1, gris clair niveau 2).

    Niveau 1 (y1) : période longue
    Niveau 2 (y2) : période courte

    Paramètres :
        slide                — objet Slide
        cols                 — liste de colonnes
        range_start, range_end — bornes pour date_to_x
        y1, y2               — positions Y des deux niveaux (inches)
        l1_bound             — clé booléenne de frontière niveau 1 (ex: "new_quarter")
        l1_label             — clé de libellé niveau 1 (ex: "quarter_label")
        l2_bound             — clé booléenne de frontière niveau 2 (ex: "new_month")
        l2_label             — clé de libellé niveau 2 (ex: "month_label")

    Note : les segments trop étroits (< 0.05") sont omis pour éviter
    des rectangles illisibles au bord de la zone Gantt.
    """
    for level, (bound_key, label_key, y, bg, fg) in enumerate([
        (l1_bound, l1_label, y1, WHITE, TEXT_DARK),   # niveau 1 (retour du 22/09 : fond blanc, plus bleu)
        (l2_bound, l2_label, y2, WHITE, TEXT_DARK),   # niveau 2, idem — les filets verticaux séparent désormais
    ]):
        seg_label = cols[0].get(label_key, "") if cols else ""
        seg_x     = GANTT_X

        for i, col in enumerate(cols):
            x = date_to_x(col["date"], range_start, range_end)
            if i > 0 and col.get(bound_key):
                # Clôture le segment courant
                w = x - seg_x
                if w > 0.05:
                    add_rect(slide, seg_x, y, w, HDR_H - 0.01, bg)
                    add_text(slide, seg_label,
                             seg_x + 0.02, y, w - 0.04, HDR_H - 0.01,
                             font_size=7 if level == 0 else 6.5,
                             bold=(level == 0), color_hex=fg,
                             align=PP_ALIGN.CENTER)
                seg_label = col.get(label_key, "")
                seg_x     = x

        # Dernier segment
        w = (GANTT_X + GANTT_W) - seg_x
        if w > 0.05:
            add_rect(slide, seg_x, y, w, HDR_H - 0.01, bg)
            add_text(slide, seg_label,
                     seg_x + 0.02, y, w - 0.04, HDR_H - 0.01,
                     font_size=7 if level == 0 else 6.5,
                     bold=(level == 0), color_hex=fg,
                     align=PP_ALIGN.CENTER)


# ══════════════════════════════════════════════════════════════════
# LIGNES DE GRILLE ET MARQUEUR AUJOURD'HUI
# ══════════════════════════════════════════════════════════════════

def draw_vertical_lines(slide, cols, scale, range_start, range_end, y_top, y_bot):
    """
    Trace les traits verticaux de grille sur toute la hauteur du planning.

    Un trait est tracé à chaque frontière de niveau 2 (début de semaine,
    de mois ou de trimestre selon l'échelle). Les traits sont en pointillés
    gris clair pour ne pas surcharger le visuel.

    Les traits en dehors de la zone Gantt [GANTT_X, GANTT_X+GANTT_W]
    sont ignorés.

    Paramètres :
        slide                — objet Slide
        cols                 — liste de colonnes
        scale                — "jour", "semaine" ou "mois"
        range_start, range_end — bornes pour date_to_x
        y_top                — position Y du haut de la zone de données
        y_bot                — position Y du bas de la zone de données
    """
    for col in cols:
        if not is_l2_boundary(col, scale):
            continue
        x = date_to_x(col["date"], range_start, range_end)
        if x < GANTT_X or x > GANTT_X + GANTT_W:
            continue
        add_line(slide, x, y_top, x, y_bot, "AAAAAA", line_w=0.5, dash=True)


def draw_today_line(slide, today, range_start, range_end, y_top, y_bot):
    """
    Trace le trait vertical rouge "aujourd'hui" avec un indicateur ▼ en haut.

    Si aujourd'hui est hors de la fenêtre temporelle, rien n'est tracé.

    Le trait est plein (non pointillé) et plus épais (1.2pt) que les
    traits de grille pour être clairement visible.
    Le ▼ est positionné juste au-dessus de y_top pour pointer vers le trait.

    Paramètres :
        slide                — objet Slide
        today                — datetime de ce jour à minuit
        range_start, range_end — bornes de la fenêtre temporelle
        y_top                — position Y du haut de la zone de données
        y_bot                — position Y du bas de la zone de données
    """
    if today < range_start or today > range_end:
        return
    x = date_to_x(today, range_start, range_end)
    add_line(slide, x, y_top, x, y_bot, RED_TODAY, line_w=1.2)
    # Triangle indicateur positionné juste au-dessus du trait
    add_text(slide, "▼",
             x - 0.09, y_top - 0.17, 0.18, 0.17,
             font_size=7, bold=True, color_hex=RED_TODAY, align=PP_ALIGN.CENTER)


# ══════════════════════════════════════════════════════════════════
# POINT D'ENTRÉE PUBLIC
# ══════════════════════════════════════════════════════════════════

def generate_pptx(
    tasks:       list,
    output_path: str,
    scale:       str  = "semaine",
    page_mode:   str  = "domain",
    show_tasks:  bool = True,
    title:       str  = "Planning",
    project_name: str = None,
    prs=None,
    save:        bool = True,
    range_start=None,
    range_end=None,
) -> str:
    """
    Génère un fichier PowerPoint de planning Gantt et le sauvegarde.

    C'est le seul point d'entrée public de ce module.
    Appelé depuis app.py (interface web) ou main.py (CLI).

    Paramètres :
        tasks       — liste de Task (issue de core.data_model.parse_tasks)
        output_path — chemin complet du fichier .pptx à créer
        scale       — échelle temporelle :
                        "jour"    → 1 colonne/jour,    en-têtes mois+semaine
                        "semaine" → 1 colonne/semaine, en-têtes trimestre+mois
                        "mois"    → 1 colonne/mois,    en-têtes année+trimestre
        page_mode   — mode de découpage en slides :
                        "domain" → 1 slide par domaine ClickUp (défaut)
                        "fill"   → remplissage maximal (MAX_ROWS lignes/slide)
                        "auto"   → identique à "fill"
        show_tasks  — si True, les tâches ordinaires sont affichées.
                      Si False, seuls chantiers et jalons apparaissent
                      (vue synthétique pour COPIL).
        title       — titre affiché en haut de chaque slide

    Retourne : output_path (le chemin du fichier généré).

    Effet de bord : crée les dossiers intermédiaires si nécessaire.
    """
    # 1. Construction de la liste ordonnée des lignes
    rows = build_ordered_rows(tasks)

    # 2. Calcul de la fenêtre temporelle commune à tous les slides
    range_start, range_end = compute_date_range(tasks, scale, range_start, range_end)

    # 3. Génération des colonnes du calendrier
    cols = iter_columns(range_start, range_end, scale)

    # 4. Attribution des couleurs par domaine
    domain_map = build_domain_map(rows)

    # 5. Découpage en groupes de slides
    slide_groups = split_into_slides(rows, page_mode)
    total        = len(slide_groups)

    # 6. Création de la présentation
    #
    # `prs` peut être fourni par l'appelant (Joseph, chantier JOS-69) : un
    # rapport est une composition de blocs, et le planning n'est que l'un
    # d'eux. Sans ce paramètre, chaque bloc produirait son propre fichier.
    if prs is None:
        prs = new_presentation()   # masque Oncopole — voir core/design_kit.py

    # 7. Dessin de chaque slide
    for i, group_rows in enumerate(slide_groups):
        draw_slide(
            prs, group_rows,
            range_start, range_end, cols,
            domain_map, scale,
            slide_num=i + 1, total_slides=total,
            title=title, show_tasks=show_tasks, project_name=project_name,
        )

    # 8. Sauvegarde — sautée quand l'appelant compose plusieurs blocs dans la
    #    même présentation et sauvegardera lui-même à la fin.
    if save:
        os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
        prs.save(output_path)
        print(f"✓ PowerPoint généré → {output_path}")
    return output_path