"""
Tableaux de bord de direction — blocs « Focus » et « Portefeuille » (PowerPoint).

Repris des maquettes validées le 23/09/2026. Trois tableaux de bord :

- **Projet** (7 diapositives) : vue d'ensemble, points d'attention (une
  diapositive par catégorie — Bloqués, En attente, En retard, En cours,
  retours du 25/09/2026), risques, analyse SWOT ;
- **Chantier** (2 diapositives) : vue d'ensemble, sous-éléments ;
- **Portefeuille** (2 diapositives) : état d'avancement des projets actifs,
  répartition et jalons à venir.

Trois règles, qui valent pour tout ce fichier :

1. **Rien n'est rédigé ici.** Tout ce qui s'affiche est une donnée brute ou le
   résultat d'une règle mécanique écrite ci-dessous (`short_label`,
   `attention`…). Une phrase d'analyse — « la date d'ouverture n'est toujours
   pas rendue » — est le travail du chef de projet dans le cadre « Synthèse »,
   ou, plus tard, du LLM Scarlett (JOS-191).
2. **Tout ce qui est tabulaire est un vrai tableau PowerPoint**, jamais des
   zones de texte alignées : c'est ce qui permet au chef de projet de retoucher
   le document. Les états sont des cellules teintées.
3. **On ne coupe pas.** Un tableau trop long déborde de la diapositive plutôt
   que de n'en montrer que le début : le chef de projet ajuste (décision du
   22/09/2026).

La **météo** n'est pas calculée : les trois pastilles sont posées, le chef de
projet supprime les deux qui ne s'appliquent pas.

Les règles de comptage (avancement, retard) sont celles de l'écran projet et de
`App\\Services\\Reporting\\PortfolioSnapshot` — voir `_metrics()`.
"""

from __future__ import annotations  # PEP 585 sans Python 3.9 : l'hébergement est en 3.7

import html
import math
import re
from datetime import date, datetime

from lxml import etree
from pptx.chart.data import CategoryChartData
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.oxml.ns import qn
from pptx.util import Inches, Pt

import core.design_kit as dk
from core.data_model import TYPE_CHANTIER, TYPE_JALON


# ══════════════════════════════════════════════════════════════════
# Palette et typographie
# ══════════════════════════════════════════════════════════════════

BLUE, ORANGE = dk.BLUE_DARK, dk.ORANGE
INK, MUTED, LINE, SOFT, WHITE = "1A2733", "6B7A8D", "D9DEE3", "F3F5F7", "FFFFFF"
GREEN, GREEN_SOFT = "2E9E5B", "E2F3E9"
AMBER, AMBER_SOFT = "E8A317", "FCF0D3"
RED, RED_SOFT = "D64541", "FAE2E0"
BLUE_SOFT, GRAY_SOFT = "E3EBF2", "EDEFF2"
# Seul accent sans variante douce jusqu'ici (retours du 25/09/2026, liste
# d'éléments) — même teinte que `ORANGE`, éclaircie comme les quatre autres.
ORANGE_SOFT = "FCE7D1"
DIN, BODY = dk.FONT_TITLE, dk.FONT_BODY

# Tailles minimales demandées le 23/09 : 11 pt dans les tableaux, 10 pt en en-tête.
T_BODY, T_HEAD = 11, 10

MARGIN_L, RIGHT = 0.45, 12.88
WIDTH = RIGHT - MARGIN_L

RESPONSIBLE = "responsible"


def rgb(h):
    return dk.rgb(h)


# ══════════════════════════════════════════════════════════════════
# Règles mécaniques
# ══════════════════════════════════════════════════════════════════

def short_label(s, limit=115, min_len=15):
    """
    Titre court, par règle : le texte avant le premier « : » ou « — » s'il
    tombe entre `min_len` et `limit` caractères, sinon la première phrase, sinon
    une coupe au dernier mot avant `limit` suivie de « … ».

    Sert au SWOT (le texte intégral part dans les notes) et aux libellés de la
    frise (« Bêta ouverte — 5 à 10 patients » → « Bêta ouverte »).
    """
    for sep in (" : ", " — "):
        i = s.find(sep)
        if min_len <= i <= limit:
            return s[:i]
    first = s.split(". ")[0].rstrip(".")
    if len(first) <= limit:
        return first
    return s[:limit].rsplit(" ", 1)[0].rstrip(",;") + "…"


def initials(name):
    """« Pierre-Yves Guido » → « P.-Y. Guido »."""
    parts = (name or "").split(" ", 1)
    if len(parts) < 2:
        return name or ""
    first, last = parts
    return "-".join(p[0] + "." for p in first.split("-") if p) + " " + last


def attention(project):
    """
    Le point d'attention d'un projet du portefeuille — règle retenue le 23/09 :
    risques critiques d'abord, sinon le premier élément bloqué, sinon le
    premier en attente. La reformulation viendra de Scarlett (JOS-191).
    """
    critical = project.get("risks_critical") or 0
    if critical:
        noun = "risque critique" if critical == 1 else "risques critiques"
        return "{} {} · le plus élevé : {}".format(critical, noun, project.get("top_risk") or "")
    if project.get("blocked_items"):
        return "Bloqué : " + project["blocked_items"][0]
    if project.get("waiting_items"):
        return "En attente : " + project["waiting_items"][0]
    return "—"


def clean_text(value):
    """Une description saisie en texte riche arrive parfois balisée : on n'en garde que le texte."""
    if not value:
        return ""
    text = re.sub(r"<\s*(br|/p|/li|/div)\s*/?>", " ", str(value), flags=re.I)
    text = re.sub(r"<[^>]+>", "", text)
    return re.sub(r"\s+", " ", html.unescape(text)).strip()


def pct(done, total):
    return int(round(done * 100.0 / total)) if total else 0


def as_date(value):
    if value is None:
        return None
    if isinstance(value, datetime):
        return value.date()
    if isinstance(value, date):
        return value
    try:
        return datetime.strptime(str(value)[:10], "%Y-%m-%d").date()
    except ValueError:
        return None


def fr(value, year=True):
    d = as_date(value)
    if d is None:
        return "—"
    return d.strftime("%d/%m/%Y" if year else "%d/%m")


def today():
    return date.today()


# ══════════════════════════════════════════════════════════════════
# Lecture des données
# ══════════════════════════════════════════════════════════════════

def is_done(task):
    return bool(task.is_done) or task.status_category == "done"


def is_late(task, now):
    due = as_date(task.due_date)
    return due is not None and due < now and not is_done(task)


def category(task):
    """La catégorie de statut, avec « terminé » tranché par `is_done` (un élément complété garde parfois son statut)."""
    return "done" if is_done(task) else (task.status_category or "todo")


def children_index(tasks):
    index = {}
    for task in tasks:
        index.setdefault(task.parent_id, []).append(task)
    return index


def descendants(root_id, index):
    """Toute la descendance, dans l'ordre de l'arbre (les tâches arrivent déjà ordonnées)."""
    out = []
    stack = list(reversed(index.get(root_id, [])))
    while stack:
        task = stack.pop()
        out.append(task)
        stack.extend(reversed(index.get(task.id, [])))
    return out


def responsables(task):
    """Les porteurs d'un élément : ses assignés « responsable », à défaut tous ses assignés."""
    people = [a for a in (task.assignees or []) if a.get("role") == RESPONSIBLE] or list(task.assignees or [])
    names = [initials(a.get("name") or "") for a in people if a.get("name")]
    return " · ".join(names) if names else "—"


def _metrics(tasks, now):
    """
    Les indicateurs d'un ensemble d'éléments.

    Mêmes règles que l'écran projet (`ViewProject::progressPercent()`,
    `overdueCount()`) et que `PortfolioSnapshot` côté Joseph : avancement =
    terminés / éléments, tous types confondus ; retard = échéance passée et pas
    terminé. Les annulés et les éléments masqués du reporting sont déjà écartés
    par `joseph_source.parse_payload()`.
    """
    return {
        "total": len(tasks),
        "done": sum(1 for t in tasks if is_done(t)),
        "late": [t for t in tasks if is_late(t, now)],
        "blocked": [t for t in tasks if category(t) == "blocked"],
        "waiting": [t for t in tasks if category(t) == "waiting"],
        "in_progress": [t for t in tasks if category(t) == "in_progress" and not is_late(t, now)],
    }


def open_milestones(tasks, now):
    """Les jalons à venir — ni terminés, ni déjà passés (ceux-là sont « en retard »)."""
    items = [t for t in tasks if t.item_type == TYPE_JALON and not is_done(t)
             and as_date(t.due_date) is not None and as_date(t.due_date) >= now]
    return sorted(items, key=lambda t: (as_date(t.due_date), t.orderindex))


def by_due(tasks):
    return sorted(tasks, key=lambda t: (as_date(t.due_date) or date.max, t.orderindex))


# ══════════════════════════════════════════════════════════════════
# Primitives de dessin
# ══════════════════════════════════════════════════════════════════

def text(slide, s, x, y, w, h, size=12, bold=False, color=INK, font=BODY,
         align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP, italic=False, spacing=None, wrap=True):
    box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
    tf = box.text_frame
    tf.word_wrap = wrap
    tf.vertical_anchor = anchor
    tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
    p = tf.paragraphs[0]
    p.alignment = align
    r = p.add_run()
    r.text = s
    r.font.size, r.font.bold, r.font.italic, r.font.name = Pt(size), bold, italic, font
    r.font.color.rgb = rgb(color)
    if spacing is not None:
        r._r.get_or_add_rPr().set("spc", str(spacing))
    return box


def rect(slide, x, y, w, h, fill, radius=None, line=None, line_w=0.75):
    kind = MSO_SHAPE.ROUNDED_RECTANGLE if radius is not None else MSO_SHAPE.RECTANGLE
    s = slide.shapes.add_shape(kind, Inches(x), Inches(y), Inches(w), Inches(h))
    if radius is not None:
        s.adjustments[0] = radius
    if fill is None:
        s.fill.background()
    else:
        s.fill.solid()
        s.fill.fore_color.rgb = rgb(fill)
    if line is None:
        s.line.fill.background()
    else:
        s.line.color.rgb = rgb(line)
        s.line.width = Pt(line_w)
    s.shadow.inherit = False
    return s


def hline(slide, x1, x2, y, color=LINE, width=0.75):
    c = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, Inches(x1), Inches(y), Inches(x2), Inches(y))
    c.line.color.rgb = rgb(color)
    c.line.width = Pt(width)


def dot(slide, cx, cy, diam, fill, line=None, line_w=2):
    s = slide.shapes.add_shape(MSO_SHAPE.OVAL, Inches(cx - diam / 2), Inches(cy - diam / 2), Inches(diam), Inches(diam))
    s.fill.solid()
    s.fill.fore_color.rgb = rgb(fill)
    if line:
        s.line.color.rgb = rgb(line)
        s.line.width = Pt(line_w)
    else:
        s.line.fill.background()
    s.shadow.inherit = False


def notes(slide, s):
    slide.notes_slide.notes_text_frame.text = s


def section(slide, label, x, y, w=6.0):
    text(slide, label, x, y, w, 0.32, size=14, bold=True, color=BLUE)


def n_lines(s, width_in, size):
    """Estimation du nombre de lignes d'un texte dans une largeur — pour empiler les blocs sous un tableau."""
    per_line = max(1, int((width_in - 0.16) * 72 / (size * 0.5)))
    return max(1, int(math.ceil(len(s or "") / float(per_line))))


def new_slide(prs):
    # Layout « Vide » : le tableau de bord pose son propre en-tête (surtitre,
    # titre, météo) — le grand titre du layout « Simple » ne laisse pas la
    # place à la météo.
    return prs.slides.add_slide(prs.slide_layouts[dk.BLANK_LAYOUT_IDX])


# ══════════════════════════════════════════════════════════════════
# En-tête (surtitre bleu, titre orange gras) et météo
# ══════════════════════════════════════════════════════════════════

METEO = [("Bonne", GREEN, WHITE), ("À surveiller", AMBER, INK), ("Critique", RED, WHITE)]
METEO_NOTE = ("Météo : les trois mentions sont posées volontairement. Conserver celle qui "
              "s'applique, sélectionner les deux autres et les supprimer (Suppr).")


def meteo(slide, right=12.3, y=0.34):
    # `right` à 12.3 et non au bord : l'arc orange du masque occupe le coin
    # supérieur droit (x ≥ 12.49").
    w, gap = 1.15, 0.1
    x0 = right - (3 * w + 2 * gap)
    text(slide, "MÉTÉO", x0, y, 1.5, 0.22, size=10, bold=True, color=MUTED, spacing=100)
    for i, (label, fill, fg) in enumerate(METEO):
        pill = rect(slide, x0 + i * (w + gap), y + 0.28, w, 0.38, fill, radius=0.5)
        tf = pill.text_frame
        tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
        tf.vertical_anchor = MSO_ANCHOR.MIDDLE
        p = tf.paragraphs[0]
        p.alignment = PP_ALIGN.CENTER
        r = p.add_run()
        r.text = label
        r.font.size, r.font.bold, r.font.name = Pt(11), True, DIN
        r.font.color.rgb = rgb(fg)


def header(slide, kicker, title, subline, description=None, with_meteo=False):
    """Pose l'en-tête et renvoie le y où le contenu peut commencer."""
    text(slide, (kicker or "").upper(), MARGIN_L, 0.34, 7.6, 0.26, size=12, bold=True, color=BLUE, font=DIN, spacing=100)
    text(slide, title or "", MARGIN_L, 0.62, 7.8 if with_meteo else WIDTH, 0.58, size=30, bold=True, color=ORANGE, font=DIN)
    text(slide, subline, MARGIN_L, 1.24, WIDTH, 0.26, size=12, color=MUTED)
    y = 1.56
    if description:
        h = n_lines(description, WIDTH, 12) * 0.21
        text(slide, description, MARGIN_L, y, WIDTH, h, size=12, color=INK)
        y += h + 0.06
    if with_meteo:
        meteo(slide)
    hline(slide, MARGIN_L, RIGHT, y + 0.04)
    return y + 0.18


def date_span(start, end, now):
    parts = []
    if start or end:
        parts.append("{}  →  {}".format(fr(start), fr(end)))
    parts.append("Point au {}".format(fr(now)))
    return "   ·   ".join(parts)


# ══════════════════════════════════════════════════════════════════
# Cartes d'indicateurs
# ══════════════════════════════════════════════════════════════════

def kpi_card(slide, x, y, w, h, label, value, context=None, value_color=BLUE, progress=None):
    rect(slide, x, y, w, h, SOFT, radius=0.08)
    text(slide, label.upper(), x + 0.16, y + 0.1, w - 0.3, 0.22, size=10, bold=True, color=MUTED, spacing=80)
    text(slide, value, x + 0.16, y + 0.3, w - 0.3, 0.5, size=28, color=value_color, font=DIN)
    cy = y + 0.8
    if progress is not None:
        track = w - 0.32
        rect(slide, x + 0.16, y + 0.8, track, 0.06, LINE, radius=0.5)
        if progress > 0:
            rect(slide, x + 0.16, y + 0.8, max(track * progress, 0.06), 0.06, ORANGE, radius=0.5)
        cy = y + 0.9
    if context:
        text(slide, context, x + 0.16, cy, w - 0.3, h - (cy - y) - 0.04, size=11, color=MUTED)


def kpi_row(slide, y, cards, h=1.14):
    gap = 0.18
    w = (WIDTH - gap * (len(cards) - 1)) / len(cards)
    # Les cartes grandissent ensemble quand un contexte est long (titre complet
    # du prochain jalon) : une carte qui déborde de son fond se lit mal.
    lines = max([n_lines(c.get("context") or "", w - 0.3, 11) for c in cards] + [1])
    h = max(h, 0.9 + lines * 0.19 + 0.08)
    for i, card in enumerate(cards):
        kpi_card(slide, MARGIN_L + i * (w + gap), y, w, h, **card)
    return y + h


def next_milestone_card(milestones, now):
    if not milestones:
        return dict(label="Prochain jalon", value="—", value_color=MUTED, context="aucun jalon à venir")
    first = milestones[0]
    due = as_date(first.due_date)
    return dict(label="Prochain jalon", value=fr(due, year=False), value_color=BLUE,
                context="{} · J-{}".format(first.name, (due - now).days))


def blocked_card(count):
    return dict(label="Bloqués / en attente", value=str(count), value_color=RED if count else GREEN,
                context="à débloquer" if count else "aucun élément signalé")


# ══════════════════════════════════════════════════════════════════
# Tableaux natifs : filets horizontaux seuls, états en cellules teintées
# ══════════════════════════════════════════════════════════════════

def _ln(tag, color=None, width_pt=0.75):
    el = etree.Element(qn("a:" + tag))
    if color is None:
        el.set("w", "12700")
        etree.SubElement(el, qn("a:noFill"))
    else:
        el.set("w", str(int(width_pt * 12700)))
        fill = etree.SubElement(el, qn("a:solidFill"))
        etree.SubElement(fill, qn("a:srgbClr")).set("val", color)
    return el


def _borders(cell, bottom=LINE, bottom_w=0.75):
    """Seul le filet du bas est tracé. L'ordre lnL/lnR/lnT/lnB est imposé par le schéma."""
    tcPr = cell._tc.get_or_add_tcPr()
    for tag in ("lnL", "lnR", "lnT", "lnB"):
        old = tcPr.find(qn("a:" + tag))
        if old is not None:
            tcPr.remove(old)
    for i, el in enumerate([_ln("lnL"), _ln("lnR"), _ln("lnT"),
                            _ln("lnB", bottom, bottom_w) if bottom else _ln("lnB")]):
        tcPr.insert(i, el)


def _fill(cell, color):
    if color is None:
        cell.fill.background()
    else:
        cell.fill.solid()
        cell.fill.fore_color.rgb = rgb(color)


def _write(cell, value, size=T_BODY, bold=False, color=INK, align=PP_ALIGN.LEFT, italic=False, font=BODY):
    cell.margin_left = cell.margin_right = Inches(0.08)
    cell.margin_top = cell.margin_bottom = Inches(0.04)
    cell.vertical_anchor = MSO_ANCHOR.MIDDLE
    tf = cell.text_frame
    tf.word_wrap = True
    parts = value if isinstance(value, list) else [(value, {})]
    for i, (s, fmt) in enumerate(parts):
        p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
        p.alignment = align
        r = p.add_run()
        r.text = s
        r.font.size = Pt(fmt.get("size", size))
        r.font.bold = fmt.get("bold", bold)
        r.font.italic = fmt.get("italic", italic)
        r.font.name = fmt.get("font", font)
        r.font.color.rgb = rgb(fmt.get("color", color))


class Badge:
    """Une cellule d'état : texte coloré sur fond teinté."""

    def __init__(self, label, fg, bg):
        self.label, self.fg, self.bg = label, fg, bg


def days_badge(due, now):
    delta = (as_date(due) - now).days
    label = "Aujourd'hui" if delta == 0 else ("J-{}".format(delta) if delta > 0 else "J+{}".format(-delta))
    if delta < 0:
        return Badge(label, RED, RED_SOFT)
    if delta <= 7:
        return Badge(label, AMBER, AMBER_SOFT)
    return Badge(label, BLUE, BLUE_SOFT)


def level_badge(score, level):
    """Couleur par niveau — le seuil est celui de Joseph (`RiskLevel`), reçu dans le payload."""
    fg, bg = {"critical": (RED, RED_SOFT), "high": (AMBER, AMBER_SOFT)}.get(level, (BLUE, BLUE_SOFT))
    return Badge(str(score), fg, bg)


def count_badge(n, fg=RED, bg=RED_SOFT):
    return Badge(str(n), fg, bg) if n else "—"


def group_row(label, count, fg, bg, empty_note=None):
    """Une ligne de groupe fusionnée ; vide, elle porte sa propre mention au lieu d'une ligne de plus."""
    return {"group": "{} · {}".format(label, count) + ("   —   " + empty_note if not count and empty_note else ""),
            "fg": fg, "bg": bg}


def table(slide, x, y, headers, rows, col_w, aligns=None, min_row=0.3, header_h=0.34, indents=None, row_style=None):
    """
    Tableau natif. Renvoie le y estimé de son bas, pour empiler la suite.

    `indents` (retours du 25/09/2026, liste d'éléments) : un pouce
    d'indentation par ligne, ou `None`. Posé sur la marge gauche de la
    première colonne seulement — c'est ce qui matérialise un niveau de
    hiérarchie sans dupliquer la mise en page pour un seul appelant.

    `row_style` (retours du 25/09/2026, liste d'éléments) : `None` par
    ligne, ou `{"bg": ..., "fg": ..., "bold": ...}` pour teinter **toutes**
    les cellules d'une ligne normale (pas fusionnées, contrairement au
    groupe) du même fond — un chantier qui se distingue sur toute sa
    largeur plutôt qu'une seule cellule d'état.
    """
    aligns = aligns or [PP_ALIGN.LEFT] + [PP_ALIGN.CENTER] * (len(headers) - 1)
    n = len(headers)
    shape = slide.shapes.add_table(len(rows) + 1, n, Inches(x), Inches(y), Inches(sum(col_w)),
                                   Inches(header_h + min_row * max(len(rows), 1)))
    tbl = shape.table
    tbl.first_row, tbl.horz_banding = True, False
    for cw, col in zip(col_w, tbl.columns):
        col.width = Inches(cw)
    tbl.rows[0].height = Inches(header_h)
    for ci, h in enumerate(headers):
        cell = tbl.cell(0, ci)
        _fill(cell, None)
        _write(cell, h.upper(), size=T_HEAD, bold=True, color=MUTED, align=aligns[ci])
        _borders(cell, bottom=BLUE, bottom_w=1.25)

    total = header_h
    for ri, row in enumerate(rows, start=1):
        if isinstance(row, dict) and "empty" in row:
            first = tbl.cell(ri, 0)
            if n > 1:
                first.merge(tbl.cell(ri, n - 1))
            _fill(first, None)
            _write(first, row["empty"], italic=True, color=MUTED)
            for ci in range(n):
                _borders(tbl.cell(ri, ci))
            h = min_row
        elif isinstance(row, dict):
            first = tbl.cell(ri, 0)
            if n > 1:
                first.merge(tbl.cell(ri, n - 1))
            _fill(first, row["bg"])
            _write(first, row["group"], size=T_HEAD + 0.5, bold=True, color=row["fg"])
            for ci in range(n):
                _borders(tbl.cell(ri, ci), bottom=None)
            h = 0.32
        else:
            style = row_style[ri - 1] if row_style else None
            lines = 1
            for ci, value in enumerate(row):
                cell = tbl.cell(ri, ci)
                if style:
                    # Le style de ligne est prioritaire sur toute couleur posée
                    # cellule par cellule (`Badge`, texte coloré) : une ligne
                    # chantier reste uniforme, jamais bigarrée par les couleurs
                    # que ces valeurs porteraient sur une ligne normale.
                    if isinstance(value, Badge):
                        label = value.label
                    elif isinstance(value, list):
                        label = "".join(s for s, _ in value)
                    else:
                        label = value
                    _fill(cell, style.get("bg"))
                    _write(cell, label, bold=style.get("bold", True), color=style.get("fg", INK), align=aligns[ci])
                    lines = max(lines, n_lines(label, col_w[ci], T_BODY))
                elif isinstance(value, Badge):
                    _fill(cell, value.bg)
                    _write(cell, value.label, bold=True, color=value.fg, align=PP_ALIGN.CENTER)
                elif isinstance(value, list):
                    _fill(cell, None)
                    _write(cell, value, align=aligns[ci])
                    lines = max(lines, sum(n_lines(s, col_w[ci], f.get("size", T_BODY)) for s, f in value))
                else:
                    _fill(cell, None)
                    _write(cell, value, align=aligns[ci], color=MUTED if value in ("—", "") else INK)
                    lines = max(lines, n_lines(value, col_w[ci], T_BODY))
                if ci == 0 and indents and indents[ri - 1]:
                    cell.margin_left = Inches(0.08 + indents[ri - 1])
                _borders(cell)
            h = max(min_row, lines * T_BODY * 1.2 / 72 + 0.1)
        tbl.rows[ri].height = Inches(h)
        total += h
    return y + total


def empty_row(message, n=None):
    """Une ligne « rien à signaler », fusionnée sur toute la largeur (sinon elle s'écrase dans la première colonne)."""
    return {"empty": message}


def comment_box(slide, x, y, w, h, label="SYNTHÈSE ET DÉCISIONS ATTENDUES"):
    rect(slide, x, y, w, h, WHITE, radius=0.06, line=ORANGE, line_w=1)
    text(slide, label, x + 0.2, y + 0.14, w - 0.4, 0.24, size=10, bold=True, color=ORANGE, spacing=80)
    text(slide, "À compléter par le chef de projet.", x + 0.2, y + 0.44, w - 0.4, max(h - 0.54, 0.3),
         size=12, italic=True, color=MUTED)


# ══════════════════════════════════════════════════════════════════
# Frise des jalons majeurs
# ══════════════════════════════════════════════════════════════════

def trajectory(slide, y, start, end, milestones, now):
    """
    Frise des jalons **majeurs** (JOS-192). Libellé = règle de coupe (le texte
    avant « — »), alternés haut/bas pour que deux jalons rapprochés ne se
    chevauchent pas. Renvoie le y sous la frise.
    """
    dates = [as_date(m.due_date) for m in milestones]
    s = as_date(start) or min(dates)
    e = as_date(end) or max(dates)
    s, e = min([s] + dates), max([e] + dates)
    span = max((e - s).days, 1)
    x0, x1 = MARGIN_L + 0.3, RIGHT - 0.3

    def xof(d):
        return x0 + (x1 - x0) * (d - s).days / float(span)

    # Deux lignes de libellé au plus : au-dessus de la ligne, le libellé est
    # calé en bas pour rester collé à sa date ; en dessous, calé en haut.
    label_h, date_h = 0.4, 0.22
    line_y = y + label_h + date_h + 0.08
    hline(slide, x0, x1, line_y, LINE, 2.5)
    if s <= now <= e:
        hline(slide, x0, xof(now), line_y, ORANGE, 2.5)
    for i, (milestone, when) in enumerate(zip(milestones, dates)):
        cx = xof(when)
        done = is_done(milestone)
        dot(slide, cx, line_y, 0.2, BLUE if done else WHITE, line=BLUE)
        label = short_label(milestone.name, limit=50, min_len=3)
        if i % 2 == 0:
            text(slide, label, cx - 1.1, y, 2.2, label_h, size=11, bold=True, color=BLUE, font=DIN,
                 align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.BOTTOM)
            text(slide, fr(when), cx - 1.1, y + label_h, 2.2, date_h, size=10, color=MUTED, align=PP_ALIGN.CENTER)
        else:
            text(slide, fr(when), cx - 1.1, line_y + 0.14, 2.2, date_h, size=10, color=MUTED, align=PP_ALIGN.CENTER)
            text(slide, label, cx - 1.1, line_y + 0.14 + date_h, 2.2, label_h, size=11, bold=True, color=BLUE,
                 font=DIN, align=PP_ALIGN.CENTER)
    if s <= now <= e:
        tx = xof(now)
        dot(slide, tx, line_y, 0.16, ORANGE)
        text(slide, "Aujourd'hui", tx - 0.8, line_y - 0.36, 1.6, 0.22, size=10, bold=True, color=ORANGE,
             align=PP_ALIGN.CENTER)
    return line_y + 0.14 + date_h + label_h + 0.04


# ══════════════════════════════════════════════════════════════════
# Focus — tableau de bord projet (4 diapositives)
# ══════════════════════════════════════════════════════════════════

def element_rows(items, with_chantier=None):
    """Lignes « Élément | (Chantier) | Échéance | Responsable »."""
    rows = []
    for t in items:
        row = [t.name]
        if with_chantier is not None:
            row.append(with_chantier.get(t.id, "—"))
        row += [fr(t.due_date, year=False), responsables(t)]
        rows.append(row)
    return rows


PROJECT_PAGES = ("overview", "attention", "risks", "swot")
ELEMENT_PAGES = ("overview", "children")


def selected_pages(block, applicable):
    """
    Les pages retenues par le bloc (`options.pages`) — même règle que
    `App\\Services\\Reporting\\FocusPage::selected()` : vide vaut « toutes », et
    une liste qui ne contient aucune page applicable (recette réglée sur un
    projet, rejouée sur un chantier) retombe aussi sur toutes.
    """
    stored = (block.get("options") or {}).get("pages") or []
    kept = [page for page in applicable if page in stored]
    return set(kept or applicable)


def render_project(prs, payload, tasks, now, pages=PROJECT_PAGES):
    project = payload.get("project") or {}
    title = project.get("name") or "Projet"
    index = children_index(tasks)
    ids = set(t.id for t in tasks)
    m = _metrics(tasks, now)
    milestones = open_milestones(tasks, now)
    risks = payload.get("risks") or []
    critical = [r for r in risks if r.get("level") == "critical"]
    majors = sorted([t for t in tasks if t.item_type == TYPE_JALON and t.is_major and t.due_date],
                    key=lambda t: as_date(t.due_date))
    chantiers = [t for t in tasks if t.item_type == TYPE_CHANTIER and (t.parent_id is None or t.parent_id not in ids)]

    # Le chantier racine de chaque élément, pour la colonne « Chantier ».
    root_of = {}
    for chantier in chantiers:
        for d in descendants(chantier.id, index):
            root_of[d.id] = chantier.name

    if "overview" in pages:
        _project_overview(prs, project, title, tasks, index, m, milestones, risks, critical, majors, chantiers, now)
    if "attention" in pages:
        _project_attention(prs, title, m, milestones, root_of, now)
    if "risks" in pages:
        _project_risks(prs, title, risks, now)
    if "swot" in pages:
        render_swot(prs, title, payload.get("swot") or [], now)


def _project_overview(prs, project, title, tasks, index, m, milestones, risks, critical, majors, chantiers, now):
    s = new_slide(prs)
    y = header(s, "Vue d'ensemble", title, date_span(project.get("start_date"), project.get("due_date"), now),
               clean_text(project.get("description")), with_meteo=True)
    y = kpi_row(s, y, [
        dict(label="Avancement", value="{} %".format(pct(m["done"], m["total"])), value_color=ORANGE,
             context="{} éléments terminés sur {}".format(m["done"], m["total"]),
             progress=(m["done"] / float(m["total"])) if m["total"] else 0),
        dict(label="En retard", value=str(len(m["late"])), value_color=RED if m["late"] else GREEN,
             context="éléments à échéance dépassée"),
        blocked_card(len(m["blocked"]) + len(m["waiting"])),
        next_milestone_card(milestones, now),
        dict(label="Risques critiques", value=str(len(critical)), value_color=RED if critical else GREEN,
             context="sur {} risques ouverts".format(len(risks))),
    ])
    if majors:
        y = trajectory(s, y + 0.08, project.get("start_date"), project.get("due_date"), majors, now)
    else:
        y += 0.18

    rows = []
    for chantier in chantiers:
        sub = descendants(chantier.id, index)
        done = sum(1 for t in sub if is_done(t))
        rows.append([chantier.name, fr(chantier.due_date), "{} / {}".format(done, len(sub)),
                     "{} %".format(pct(done, len(sub))), count_badge(sum(1 for t in sub if is_late(t, now)))])
    if not rows:
        rows = [empty_row("Aucun chantier", 5)]
    bottom = table(s, MARGIN_L, y + 0.02, ["Chantier", "Fin", "Terminés", "Avancement", "En retard"], rows,
                   [3.25, 1.15, 1.05, 1.2, 1.05], min_row=0.31, header_h=0.32)
    comment_box(s, 8.4, y + 0.02, RIGHT - 8.4, max(bottom - y - 0.02, 1.2))
    notes(s, METEO_NOTE + "\n\nAvancement = éléments terminés / éléments, tous types confondus (annulés et "
             "éléments masqués du reporting exclus). Frise : jalons marqués « majeurs » dans Joseph ; "
             "libellé = texte avant le « — » du titre.")


def _project_attention(prs, title, m, milestones, root_of, now):
    """
    Quatre diapositives (retours du 25/09/2026), une par catégorie, plutôt
    qu'une seule table fourre-tout : « Bloqués » et « En attente » étaient
    fusionnés sous un même groupe, ce qui les rendait impossibles à
    distinguer d'un coup d'œil. « Prochains jalons » reste sur la première
    (Bloqués) — la plus consultée — les trois suivantes utilisent toute la
    largeur pour leur tableau plutôt que de le répéter.
    """
    late = [t for t in m["late"] if category(t) not in ("blocked", "waiting")]
    _attention_slide(prs, title, "Bloqués", RED, RED_SOFT, m["blocked"], root_of, now, milestones=milestones)
    _attention_slide(prs, title, "En attente", AMBER, AMBER_SOFT, m["waiting"], root_of, now)
    _attention_slide(prs, title, "En retard", RED, RED_SOFT, late, root_of, now)
    _attention_slide(prs, title, "En cours", BLUE, BLUE_SOFT, m["in_progress"], root_of, now)


ATTENTION_COLS = [4.5, 2.1, 1.0, 1.25]
ATTENTION_COLS_FULL = [6.3, 2.95, 1.4, 1.78]


def _attention_slide(prs, title, label, fg, bg, items, root_of, now, milestones=None):
    s = new_slide(prs)
    y = header(s, "Points d'attention · {}".format(label), title, "Point au {}".format(fr(now)))
    items = by_due(items)
    rows = [group_row(label.upper(), len(items), fg if items else GREEN, bg if items else GREEN_SOFT,
                      "aucun élément signalé")]
    rows += element_rows(items, root_of)
    col_w = ATTENTION_COLS if milestones is not None else ATTENTION_COLS_FULL
    table(s, MARGIN_L, y + 0.1, ["Élément", "Chantier", "Échéance", "Responsable"], rows, col_w,
          min_row=0.3, aligns=[PP_ALIGN.LEFT, PP_ALIGN.LEFT, PP_ALIGN.CENTER, PP_ALIGN.CENTER])
    if milestones is not None:
        rx = 9.5
        section(s, "Prochains jalons", rx, y + 0.02)
        jrows = [[fr(t.due_date, year=False), t.name, days_badge(t.due_date, now)] for t in milestones[:5]]
        if not jrows:
            jrows = [empty_row("Aucun jalon à venir", 3)]
        table(s, rx, y + 0.42, ["Date", "Jalon", "Dans"], jrows, [0.6, 2.13, 0.65],
              aligns=[PP_ALIGN.LEFT, PP_ALIGN.LEFT, PP_ALIGN.CENTER])
    notes(s, "Titres complets, non raccourcis : le tableau peut déborder, à ajuster si besoin.")


def _project_risks(prs, title, risks, now):
    s = new_slide(prs)
    y = header(s, "Risques", title, "Point au {}".format(fr(now)))
    rows = [[r.get("title") or "", r.get("element_title") or "Projet", r.get("owner") or "—",
             str(r.get("probability") or ""), str(r.get("impact") or ""),
             level_badge(r.get("score") or 0, r.get("level"))] for r in risks]
    if not rows:
        rows = [empty_row("Aucun risque ouvert", 6)]
    table(s, MARGIN_L, y + 0.1, ["Risque", "Chantier", "Porteur", "P", "I", "Score"], rows,
          [6.2, 2.95, 1.63, 0.42, 0.42, 0.81],
          aligns=[PP_ALIGN.LEFT, PP_ALIGN.LEFT, PP_ALIGN.LEFT, PP_ALIGN.CENTER, PP_ALIGN.CENTER, PP_ALIGN.CENTER],
          min_row=0.31)
    notes(s, "Tous les risques ouverts, triés par score (probabilité × impact, de 1 à 25). "
             "Rouge : critique (≥ 15) ; ambre : élevé (10 à 14) ; bleu : en deçà — niveaux calculés par Joseph.")


SWOT_QUADRANTS = [
    ("strength", "Forces", GREEN, GREEN_SOFT), ("weakness", "Faiblesses", AMBER, AMBER_SOFT),
    ("opportunity", "Opportunités", BLUE, BLUE_SOFT), ("threat", "Menaces", RED, RED_SOFT),
]


def render_swot(prs, title, entries, now):
    by_quadrant = {}
    for entry in entries:
        by_quadrant.setdefault(entry.get("quadrant"), []).append(clean_text(entry.get("content")))

    s = new_slide(prs)
    y = header(s, "Analyse SWOT", title, "Point au {}".format(fr(now)))
    w = WIDTH / 2
    shape = s.shapes.add_table(4, 2, Inches(MARGIN_L), Inches(y + 0.1), Inches(WIDTH), Inches(4.5))
    tbl = shape.table
    tbl.first_row, tbl.horz_banding = False, False
    for col in tbl.columns:
        col.width = Inches(w)
    for block, pair in enumerate([SWOT_QUADRANTS[:2], SWOT_QUADRANTS[2:]]):
        head_row, body_row = block * 2, block * 2 + 1
        tbl.rows[head_row].height = Inches(0.38)
        lines, count = 1, 1
        for ci, (key, label, fg, bg) in enumerate(pair):
            cards = by_quadrant.get(key, [])
            head = tbl.cell(head_row, ci)
            _fill(head, bg)
            _write(head, "{}  ·  {}".format(label.upper(), len(cards)), size=11, bold=True, color=fg, font=DIN)
            _borders(head, bottom=None)
            body = tbl.cell(body_row, ci)
            _fill(body, None)
            items = ["•  " + short_label(t) for t in cards]
            if items:
                _write(body, [(t, {}) for t in items])
            else:
                _write(body, [("Aucune carte", {"italic": True, "color": MUTED})])
            for para in body.text_frame.paragraphs:
                para.space_after = Pt(3)
            body.vertical_anchor = MSO_ANCHOR.TOP
            body.margin_top = Inches(0.1)
            _borders(body, bottom=LINE)
            lines = max(lines, sum(n_lines(t, w, T_BODY) for t in items) if items else 1)
            count = max(count, len(items))
        tbl.rows[body_row].height = Inches(lines * T_BODY * 1.25 / 72 + count * 0.03 + 0.2)
    notes(s, "Titres courts par règle : texte avant le premier « : » ou « — », sinon première phrase, "
             "sinon coupe au dernier mot. Texte intégral des cartes :\n\n" + "\n\n".join(
                 "{}\n".format(label.upper()) + "\n".join("- " + t for t in by_quadrant.get(key, []))
                 for key, label, _, _ in SWOT_QUADRANTS))


# ══════════════════════════════════════════════════════════════════
# Focus — tableau de bord d'un chantier (2 diapositives)
# ══════════════════════════════════════════════════════════════════

def render_element(prs, payload, tasks, root, now, pages=ELEMENT_PAGES):
    project = payload.get("project") or {}
    index = children_index(tasks)
    sub = descendants(root.id, index)
    ids = set([root.id] + [t.id for t in sub])
    m = _metrics(sub, now)
    milestones = open_milestones(sub, now)
    risks = [r for r in (payload.get("risks") or []) if r.get("element_id") in ids]
    critical = sum(1 for r in risks if r.get("level") == "critical")
    kicker = project.get("name") or ""

    if "overview" in pages:
        _element_overview(prs, root, kicker, m, milestones, risks, critical, now)
    if "children" in pages:
        _element_children(prs, root, kicker, index, now)


def _element_overview(prs, root, kicker, m, milestones, risks, critical, now):
    s = new_slide(prs)
    y = header(s, kicker, root.name, date_span(root.start_date, root.due_date, now),
               clean_text(root.description), with_meteo=True)
    y = kpi_row(s, y, [
        dict(label="Avancement", value="{} %".format(pct(m["done"], m["total"])), value_color=ORANGE,
             context="{} élément(s) terminé(s) sur {}".format(m["done"], m["total"]),
             progress=(m["done"] / float(m["total"])) if m["total"] else 0),
        dict(label="En retard", value=str(len(m["late"])), value_color=RED if m["late"] else GREEN,
             context="élément(s) à échéance dépassée"),
        blocked_card(len(m["blocked"]) + len(m["waiting"])),
        next_milestone_card(milestones, now),
        dict(label="Risques", value=str(len(risks)), value_color=RED if critical else (AMBER if risks else GREEN),
             context="dont {} critique(s)".format(critical) if risks else "aucun risque ouvert"),
    ])
    left_w = 5.9
    section(s, "Jalons", MARGIN_L, y + 0.14)
    rows = [[fr(t.due_date, year=False), t.name, days_badge(t.due_date, now)] for t in milestones]
    if not rows:
        rows = [empty_row("Aucun jalon à venir", 3)]
    bottom = table(s, MARGIN_L, y + 0.52, ["Date", "Jalon", "Dans"], rows, [0.75, 4.35, 0.8],
                   aligns=[PP_ALIGN.LEFT, PP_ALIGN.LEFT, PP_ALIGN.CENTER])
    comment_box(s, MARGIN_L, bottom + 0.2, left_w, max(0.9, 6.5 - bottom - 0.2), label="SYNTHÈSE")
    rx = MARGIN_L + left_w + 0.4
    section(s, "Risques", rx, y + 0.14)
    rows = [[r.get("title") or "", str(r.get("probability") or ""), str(r.get("impact") or ""),
             level_badge(r.get("score") or 0, r.get("level"))] for r in risks]
    if not rows:
        rows = [empty_row("Aucun risque ouvert", 4)]
    table(s, rx, y + 0.52, ["Risque", "P", "I", "Score"], rows, [4.42, 0.45, 0.45, 0.8])
    notes(s, METEO_NOTE)


def _element_children(prs, root, kicker, index, now):
    """Les sous-éléments de premier niveau, groupés par état."""
    s = new_slide(prs)
    y = header(s, kicker, root.name, "Sous-éléments   ·   Point au {}".format(fr(now)))
    children = index.get(root.id, [])

    def fmt(t):
        effort = "—" if t.effort_hours in (None, "") else "{:g} h".format(float(t.effort_hours))
        return [t.name, fr(t.due_date, year=False), responsables(t), effort]

    groups = [
        ("BLOQUÉS / EN ATTENTE", [t for t in children if category(t) in ("blocked", "waiting")], RED, RED_SOFT),
        ("EN RETARD", [t for t in children if is_late(t, now) and category(t) not in ("blocked", "waiting")], RED, RED_SOFT),
        ("EN COURS", [t for t in children if category(t) == "in_progress" and not is_late(t, now)], BLUE, BLUE_SOFT),
        ("À VALIDER", [t for t in children if category(t) == "to_validate" and not is_late(t, now)], BLUE, BLUE_SOFT),
        ("À FAIRE", [t for t in children if category(t) == "todo" and not is_late(t, now)], MUTED, GRAY_SOFT),
        ("TERMINÉS", [t for t in children if category(t) == "done"], GREEN, GREEN_SOFT),
    ]
    rows = []
    for index_group, (label, items, fg, bg) in enumerate(groups):
        # Les bloqués sont toujours annoncés, même vides ; les autres groupes seulement s'ils ont du contenu.
        if items or index_group == 0:
            rows.append(group_row(label, len(items), fg if items else GREEN, bg if items else GREEN_SOFT,
                                  "aucun élément signalé"))
            rows += [fmt(t) for t in by_due(items)]
    table(s, MARGIN_L, y + 0.1, ["Élément", "Échéance", "Responsables", "Effort"], rows,
          [7.5, 1.2, 2.9, 0.83], aligns=[PP_ALIGN.LEFT, PP_ALIGN.CENTER, PP_ALIGN.LEFT, PP_ALIGN.CENTER],
          min_row=0.29)
    notes(s, "Aucun sous-élément n'est coupé : le tableau peut déborder, à ajuster si besoin. "
             "Groupés par état ; un élément en retard n'apparaît que dans « En retard ».")


# ══════════════════════════════════════════════════════════════════
# Point d'entrée du bloc Focus
# ══════════════════════════════════════════════════════════════════

def render_focus(prs, block, tasks, payload):
    """
    Le tableau de bord du bloc Focus.

    Sa cible : le chantier désigné par le bloc s'il y en a un, sinon la racine
    du périmètre (un rapport de chantier), sinon le projet entier.
    """
    now = today()
    by_id = dict((t.id, t) for t in tasks)
    target = by_id.get(str(block.get("target_element_id") or ""))

    if target is None:
        root_code = (payload.get("scope") or {}).get("root_code")
        target = next((t for t in tasks if root_code and t.code == root_code), None)

    if target is None:
        render_project(prs, payload, tasks, now, selected_pages(block, PROJECT_PAGES))
    else:
        render_element(prs, payload, tasks, target, now, selected_pages(block, ELEMENT_PAGES))


# ══════════════════════════════════════════════════════════════════
# Portefeuille (2 diapositives)
# ══════════════════════════════════════════════════════════════════

def render_portfolio(prs, block, payload):
    now = today()
    projects = payload.get("portfolio") or []
    late = sum(p.get("late") or 0 for p in projects)
    stuck = [p for p in projects if (p.get("blocked") or 0) + (p.get("waiting") or 0)]
    upcoming = sorted(
        [(m.get("date"), p.get("name"), m.get("title")) for p in projects for m in (p.get("upcoming_milestones") or [])],
        key=lambda row: row[0] or "",
    )

    # ── 1. État d'avancement ──
    s = new_slide(prs)
    y = header(s, "Portefeuille", "État d'avancement des projets", "Point au {}".format(fr(now)))
    y = kpi_row(s, y, [
        dict(label="Projets actifs", value=str(len(projects)), value_color=BLUE),
        dict(label="En retard", value=str(late), value_color=RED if late else GREEN,
             context="éléments, répartis sur {} projet(s)".format(sum(1 for p in projects if p.get("late")))),
        dict(label="Bloqués / en attente",
             value=str(sum((p.get("blocked") or 0) + (p.get("waiting") or 0) for p in projects)),
             value_color=RED if stuck else GREEN,
             context=" · ".join(p.get("name") or "" for p in stuck) or "aucun élément signalé"),
        dict(label="Jalons à 30 jours", value=str(len(upcoming)), value_color=BLUE),
    ], h=1.05)

    rows = []
    for p in projects:
        nxt = p.get("next_milestone")
        stuck_count = (p.get("blocked") or 0) + (p.get("waiting") or 0)
        rows.append([
            [(p.get("name") or "", {"bold": True}),
             ("fin " + fr(p.get("due_date")) if p.get("due_date") else "pas d'échéance", {"color": MUTED})],
            "{} %".format(pct(p.get("done") or 0, p.get("total") or 0)) if p.get("total") else "—",
            "",
            count_badge(p.get("late") or 0),
            count_badge(stuck_count, AMBER, AMBER_SOFT),
            [(fr(nxt.get("date"), year=False), {"bold": True, "color": BLUE}), (nxt.get("title") or "", {})] if nxt else "—",
            attention(p),
        ])
    if not rows:
        rows = [empty_row("Aucun projet actif", 7)]
    table(s, MARGIN_L, y + 0.18,
          ["Projet", "Avanc.", "Météo", "Retard", "Bloqués", "Prochain jalon", "Point d'attention"], rows,
          [2.55, 0.95, 1.0, 0.85, 0.95, 2.6, 3.53],
          aligns=[PP_ALIGN.LEFT, PP_ALIGN.CENTER, PP_ALIGN.CENTER, PP_ALIGN.CENTER, PP_ALIGN.CENTER,
                  PP_ALIGN.LEFT, PP_ALIGN.LEFT],
          min_row=0.5)

    # La météo de chaque ligne : trois pastilles dans une cellule, deux à effacer.
    if projects:
        tbl = [sh for sh in s.shapes if sh.has_table][-1].table
        for ri in range(1, len(projects) + 1):
            para = tbl.cell(ri, 2).text_frame.paragraphs[0]
            for r in list(para.runs):
                r._r.getparent().remove(r._r)
            para.alignment = PP_ALIGN.CENTER
            for i, (_, color, _) in enumerate(METEO):
                r = para.add_run()
                r.text = "●" + (" " if i < 2 else "")
                r.font.size = Pt(16)
                r.font.color.rgb = rgb(color)
    notes(s, "Météo : dans chaque ligne, garder la pastille qui s'applique et effacer les deux autres. "
             "Point d'attention (règle) : « N risques critiques · le plus élevé : … », sinon le premier "
             "élément bloqué, sinon le premier en attente, sinon « — ».")

    # ── 2. Répartition et jalons à venir ──
    s = new_slide(prs)
    y = header(s, "Portefeuille", "Répartition et jalons à venir", "Point au {}".format(fr(now)))
    shown = [p for p in projects if p.get("total")]
    section(s, "Répartition des éléments par état", MARGIN_L, y + 0.02)
    if shown:
        cd = CategoryChartData()
        cd.categories = [p.get("name") or "" for p in shown]
        stuck_of = lambda p: (p.get("blocked") or 0) + (p.get("waiting") or 0)  # noqa: E731
        cd.add_series("Terminé", [p.get("done") or 0 for p in shown])
        cd.add_series("En cours", [p.get("in_progress") or 0 for p in shown])
        cd.add_series("À faire", [max((p.get("total") or 0) - (p.get("done") or 0) - (p.get("in_progress") or 0)
                                      - stuck_of(p), 0) for p in shown])
        cd.add_series("Bloqué / en attente", [stuck_of(p) for p in shown])
        chart = s.shapes.add_chart(XL_CHART_TYPE.BAR_STACKED_100, Inches(MARGIN_L), Inches(y + 0.45),
                                   Inches(5.35), Inches(max(6.45 - y - 0.45, 2.0)), cd).chart
        chart.has_legend = True
        chart.legend.position = XL_LEGEND_POSITION.BOTTOM
        chart.legend.include_in_layout = False
        chart.legend.font.size = Pt(11)
        chart.legend.font.color.rgb = rgb(MUTED)
        plot = chart.plots[0]
        plot.gap_width, plot.overlap = 55, 100
        for series, color in zip(plot.series, [GREEN, BLUE, "C9D1D9", AMBER]):
            series.format.fill.solid()
            series.format.fill.fore_color.rgb = rgb(color)
        cat = chart.category_axis
        cat.reverse_order = True
        cat.tick_labels.font.size = Pt(11)
        cat.tick_labels.font.color.rgb = rgb(INK)
        cat.format.line.fill.background()
        chart.value_axis.visible = False
        chart.value_axis.has_major_gridlines = False

    rx = 6.2
    section(s, "Jalons des 30 prochains jours", rx, y + 0.02)
    rows = [[fr(when, year=False), name or "", title or "", days_badge(when, now)] for when, name, title in upcoming]
    if not rows:
        rows = [empty_row("Aucun jalon dans les 30 prochains jours", 4)]
    table(s, rx, y + 0.45, ["Date", "Projet", "Jalon", "Dans"], rows, [0.62, 2.35, 3.03, 0.68],
          aligns=[PP_ALIGN.LEFT, PP_ALIGN.LEFT, PP_ALIGN.LEFT, PP_ALIGN.CENTER])
    notes(s, "Graphique natif : clic droit > Modifier les données. Les projets sans élément n'y figurent pas.")
