"""
Génération d'un planning Gantt en Excel à partir des tâches ClickUp.
Feuilles : Planning (Gantt) + Données (tableau brut filtrable)
"""

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 openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
from dataclasses import dataclass

from core.data_model import Task, ReunionGroup, TYPE_CHANTIER, TYPE_JALON, TYPE_TACHE, TYPE_REUNION, group_reunions


@dataclass
class DomainHeader:
    """Ligne de séparation de domaine insérée dans le planning."""
    name: str
    folder_name: str
    list_name: str = ""
    list_id: str = ""
    parent_id: str = None
    orderindex: float = -1.0


# ------------------------------------------------------------------
# Palette domaines
# ------------------------------------------------------------------

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
]

COLOR_JALON        = "F29700"
COLOR_JALON_TEXT   = "FFFFFF"
COLOR_REUNION      = "6C3483"
COLOR_REUNION_TEXT = "FFFFFF"

COLOR_HEADER_BG   = "1F3864"
COLOR_HEADER_TEXT = "FFFFFF"
COLOR_SUBHDR_BG   = "D6DCE4"
COLOR_WEEKEND     = "F5F5F5"
COLOR_TODAY_BG    = "FF6B6B"
COLOR_TODAY_MARK  = "FFE5E5"
COLOR_BORDER      = "BFBFBF"


# ------------------------------------------------------------------
# Colonnes fixes — modifie ici pour afficher/masquer des colonnes
# ------------------------------------------------------------------

FIXED_COLS = [
    ("domaine",  14, "Domaine"),
    ("nom",      38, "Nom"),
    ("etat",     14, "État"),
    ("debut",    11, "Début"),
    ("echeance", 11, "Échéance"),
]
N_FIXED     = len(FIXED_COLS)
GANTT_START = N_FIXED + 1

# Index des colonnes fixes par clé (1-based)
COL = {key: i + 1 for i, (key, _, _) in enumerate(FIXED_COLS)}


# ------------------------------------------------------------------
# Modes d'échelle temporelle
# ------------------------------------------------------------------

SCALE_MODES = {
    "jour":    {"col_width": 2.2,  "label": "Jour    (Mois › Semaine › Jour)"},
    "semaine": {"col_width": 7.0,  "label": "Semaine (Trimestre › Mois › Semaine)"},
    "mois":    {"col_width": 16.0, "label": "Mois    (Année › Trimestre › Mois)"},
}


def ask_scale_mode() -> str:
    print("\n──────────────────────────────────────────────")
    print("  Échelle temporelle du planning :")
    print("  [1] Jour     — Mois / Semaine / Jour   (~3 mois)")
    print("  [2] Semaine  — Trimestre / Mois / Semaine (~12 mois)")
    print("  [3] Mois     — Année / Trimestre / Mois  (~2 ans)")
    print("──────────────────────────────────────────────")
    while True:
        raw = input("  Choix [1/2/3] : ").strip()
        if raw == "1": return "jour"
        if raw == "2": return "semaine"
        if raw == "3": return "mois"
        print("  Saisie invalide.")


# ------------------------------------------------------------------
# Helpers styles
# ------------------------------------------------------------------

def _fill(hex_color: str) -> PatternFill:
    return PatternFill("solid", fgColor=hex_color)

def _font(bold=False, color="000000", size=10, italic=False) -> Font:
    return Font(bold=bold, color=color, size=size, italic=italic, name="Arial")

def _border_thin(color=COLOR_BORDER) -> Border:
    s = Side(style="thin", color=color)
    return Border(left=s, right=s, top=s, bottom=s)

def _align(h="left", v="center", wrap=False) -> Alignment:
    return Alignment(horizontal=h, vertical=v, wrap_text=wrap)

def _merge(ws, r1, c1, r2, c2, value, bg, fg, bold=False, size=10):
    if r1 == r2 and c1 == c2:
        cell = ws.cell(row=r1, column=c1, value=value)
    else:
        ws.merge_cells(start_row=r1, start_column=c1, end_row=r2, end_column=c2)
        cell = ws.cell(row=r1, column=c1, value=value)
    cell.fill      = _fill(bg)
    cell.font      = _font(bold=bold, color=fg, size=size)
    cell.alignment = _align("center")
    return cell

def _month_name(m: int) -> str:
    return ["Jan","Fév","Mar","Avr","Mai","Jun",
            "Jul","Aoû","Sep","Oct","Nov","Déc"][m - 1]

def _quarter(m: int) -> str:
    return f"T{(m - 1) // 3 + 1}"


# ------------------------------------------------------------------
# Couleurs par domaine
# ------------------------------------------------------------------

def build_domain_color_map(tasks: list, domain_colors: dict = None) -> dict:
    domain_colors = domain_colors or {}
    seen = []
    for t in tasks:
        name = t.folder_name or t.list_name
        if name and name not in seen:
            seen.append(name)
    result = {}
    palette_idx = 0
    for name in seen:
        if name in domain_colors:
            idx = domain_colors[name] % len(DOMAIN_PALETTE)
        else:
            idx = palette_idx % len(DOMAIN_PALETTE)
            palette_idx += 1
        result[name] = DOMAIN_PALETTE[idx]
    return result


def _status_text(task) -> str:
    """L'état tel qu'il se lit dans la colonne dédiée — Joseph envoie déjà le
    libellé, il n'y a rien à deviner."""
    if getattr(task, "is_cancelled", False):
        return "Annulé"

    return (getattr(task, "status", "") or "—").strip().capitalize()


def get_task_colors(task, domain_map: dict) -> tuple:
    """
    Retourne (bg_cell, bar_color, text_color).

    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. C'est ce qui fait qu'un chantier
    garde la même couleur d'un rapport à l'autre.
    """
    domain = task.folder_name or task.list_name
    strong, light = domain_map.get(domain, DOMAIN_PALETTE[0])

    own = getattr(task, "color", None)
    if own:
        strong = own
        light = getattr(task, "color_light", None) or own

    if task.item_type == TYPE_JALON:
        return (COLOR_JALON, COLOR_JALON, COLOR_JALON_TEXT)
    if task.item_type == TYPE_REUNION:
        return ("F5EEF8", COLOR_REUNION, COLOR_REUNION_TEXT)
    if task.item_type == TYPE_CHANTIER:
        return (strong, strong, "FFFFFF")
    # Tâche
    return (light, light, strong)


# ------------------------------------------------------------------
# Tri hiérarchique avec DomainHeader
# ------------------------------------------------------------------

def sort_tasks_hierarchical(tasks: list) -> list:
    """
    Trie en respectant la hiérarchie ClickUp.
    Insère un DomainHeader au début de chaque domaine.
    Accepte un mix de Task, ReunionGroup.
    """
    # Rang d'arrivée : **l'ordre du projet**, tel que Joseph l'envoie. Le tri
    # d'origine `(domaine, orderindex)` était alphabétique par nom de domaine —
    # et le domaine d'un chantier racine étant son propre titre, les chantiers
    # sortaient dans l'ordre de l'alphabet. Voir le jumeau dans `planning_pptx`.
    rank = {id(t): i for i, t in enumerate(tasks)}

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

    # Enfants par parent_id
    children: dict = {}
    for t in tasks:
        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 : Task sans parent_id (non chantier) + ReunionGroup sans parent_id
    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 if is_orphan(t)],
        key=lambda t: rank[id(t)]
    )

    # Ordre des domaines
    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()

    for domain in domains:
        result.append(DomainHeader(name=domain, folder_name=domain))

        # Chantiers du domaine + toute leur descendance — récursif, voir le
        # jumeau dans `planning_pptx` pour le raisonnement (profondeur "Tout"
        # ne doit pas s'arrêter aux enfants directs).
        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
                result.append(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(chantier)
            visited.add(chantier.id)
            append_descendants(chantier.id)

        # Orphelins du domaine
        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:
                result.append(orphan)
                visited.add(oid)

    # Sécurité : éléments non visités
    visited_ids = {getattr(t, 'id', id(t)) for t in result if not isinstance(t, DomainHeader)}
    for t in tasks:
        if getattr(t, 'id', id(t)) not in visited_ids:
            result.append(t)

    return result


# ------------------------------------------------------------------
# Fenêtre temporelle
# ------------------------------------------------------------------

def compute_date_range(tasks: list, scale: str, days_past=30, days_future=120,
                      range_start=None, range_end=None):
    """
    `range_start`/`range_end` : la période choisie dans le bloc l'emporte sur
    tout le reste — ni marge, ni étirement jusqu'aux tâches, ni « aujourd'hui
    toujours visible ». Voir le docblock jumeau dans `planning_pptx.py`.
    """
    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):
                dates.extend(t.occurrences)
                continue
            ref_start = t.start_date or t.due_date
            if ref_start:
                dates.append(ref_start.replace(hour=0, minute=0, second=0, microsecond=0))
            if 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) - timedelta(days=7)
            max_d = max(max(dates), today) + timedelta(days=14)
        else:
            min_d = today - timedelta(days=days_past)
            max_d = today + timedelta(days=days_future)

    if scale in ("jour", "semaine"):
        min_d -= timedelta(days=min_d.weekday())
        max_d += timedelta(days=6 - max_d.weekday())
    elif scale == "mois":
        import calendar
        min_d = min_d.replace(day=1)
        last_day = calendar.monthrange(max_d.year, max_d.month)[1]
        max_d = max_d.replace(day=last_day)

    return min_d, max_d


# ------------------------------------------------------------------
# Colonnes Gantt
# ------------------------------------------------------------------

def iter_columns(start: datetime, end: datetime, scale: str):
    cols = []
    d    = start

    if scale == "jour":
        while d <= end:
            cols.append({
                "date":        d,
                "is_weekend":  d.weekday() >= 5,
                "new_month":   d.day == 1,
                "new_week":    d.weekday() == 0,
                "week_num":    d.isocalendar()[1],
                "month_label": f"{_month_name(d.month)} {d.year}",
            })
            d += timedelta(days=1)

    elif scale == "semaine":
        while d <= end:
            cols.append({
                "date":          d,
                "is_weekend":    False,
                "new_month":     d.day <= 7 and d.weekday() == 0,
                "new_quarter":   d.month in (1,4,7,10) and d.day <= 7 and d.weekday() == 0,
                "week_num":      d.isocalendar()[1],
                "month_label":   _month_name(d.month),
                "quarter_label": f"{_quarter(d.month)} {d.year}",
            })
            d += timedelta(weeks=1)

    elif scale == "mois":
        import calendar
        while d <= end:
            cols.append({
                "date":          d,
                "is_weekend":    False,
                "new_quarter":   d.month in (1,4,7,10),
                "new_year":      d.month == 1,
                "month_label":   _month_name(d.month),
                "quarter_label": f"{_quarter(d.month)} {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 is_l2_boundary(col: dict, scale: str) -> bool:
    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_col_idx(date: datetime, cols: list, col_offset: int):
    """
    La colonne contenant cette date, ou `None` si elle tombe **hors fenêtre**.

    Le repli d'origine — rabattre sur la dernière colonne — était sans effet tant
    que la fenêtre couvrait toutes les dates. Depuis qu'une période peut être
    imposée (chantier JOS-69), il empilerait tout ce qui déborde sur la colonne
    de droite, en donnant la fausse impression d'échéances groupées. Les
    appelants sautent désormais ce qu'ils ne peuvent pas placer.
    """
    d = date.replace(hour=0, minute=0, second=0, microsecond=0)

    if not cols or d < cols[0]["date"]:
        return None

    for i, c in enumerate(cols):
        if i + 1 < len(cols):
            if c["date"] <= d < cols[i + 1]["date"]:
                return col_offset + i
        else:
            # Dernière colonne : elle couvre sa propre période (un jour, une
            # semaine ou un mois), pas tout ce qui suit.
            if c["date"] <= d < _col_end(c, cols):
                return col_offset + i

    return None


def _col_end(col: dict, cols: list) -> datetime:
    """La borne droite (exclue) de la dernière colonne, déduite du pas des colonnes."""
    if len(cols) >= 2:
        return col["date"] + (cols[-1]["date"] - cols[-2]["date"])

    # Une seule colonne : on ne connaît pas le pas, on prend le mois — la plus
    # large des trois échelles, donc le repli le moins mutilant.
    return col["date"] + timedelta(days=31)


# ------------------------------------------------------------------
# Feuille Planning
# ------------------------------------------------------------------

def build_planning_sheet(ws, tasks: list, scale: str,
                         domain_map: dict, days_past=30, days_future=120,
                         range_start=None, range_end=None):
    today   = datetime.today().replace(hour=0, minute=0, second=0, microsecond=0)

    # Groupement réunions + tri hiérarchique
    tasks_with_groups = group_reunions(tasks)
    ordered           = sort_tasks_hierarchical(tasks_with_groups)

    start_d, end_d = compute_date_range(tasks_with_groups, scale, days_past, days_future,
                                        range_start, range_end)
    cols   = iter_columns(start_d, end_d, scale)
    col_w  = SCALE_MODES[scale]["col_width"]

    # Largeurs colonnes fixes
    for i, (_, w, _) in enumerate(FIXED_COLS):
        ws.column_dimensions[get_column_letter(i + 1)].width = w

    # Largeurs colonnes Gantt
    for j in range(len(cols)):
        ws.column_dimensions[get_column_letter(GANTT_START + j)].width = col_w

    # Hauteurs en-têtes
    ws.row_dimensions[1].height = 16
    ws.row_dimensions[2].height = 14
    ws.row_dimensions[3].height = 20

    # En-têtes colonnes fixes
    for i, (_, _, label) in enumerate(FIXED_COLS):
        c = ws.cell(row=3, column=i + 1, value=label)
        c.fill      = _fill(COLOR_HEADER_BG)
        c.font      = _font(bold=True, color=COLOR_HEADER_TEXT, size=10)
        c.alignment = _align("center")
        c.border    = _border_thin()

    # En-têtes Gantt
    _write_gantt_headers(ws, cols, scale, today)

    # Lignes de données
    for row_i, task in enumerate(ordered, start=4):
        ws.row_dimensions[row_i].height = 22 if isinstance(task, DomainHeader) else 17
        _write_task_row(ws, task, row_i, cols, today, domain_map, scale)

    # Colonne aujourd'hui
    _draw_today_col(ws, today, cols, len(ordered) + 3)

    ws.freeze_panes = ws.cell(row=4, column=GANTT_START)


def _write_gantt_headers(ws, cols, scale, today):
    if scale == "jour":
        cur_month = None; month_start = None; month_col = None
        for i, col in enumerate(cols):
            excel_col = GANTT_START + i
            d = col["date"]
            if d.month != cur_month:
                if cur_month is not None:
                    _merge(ws, 1, month_col, 1, excel_col - 1,
                           f"{_month_name(cur_month)} {month_start.year}",
                           COLOR_HEADER_BG, COLOR_HEADER_TEXT, bold=True, size=9)
                cur_month = d.month; month_start = d; month_col = excel_col
        if cur_month is not None:
            _merge(ws, 1, month_col, 1, GANTT_START + len(cols) - 1,
                   f"{_month_name(cur_month)} {month_start.year}",
                   COLOR_HEADER_BG, COLOR_HEADER_TEXT, bold=True, size=9)

        for i, col in enumerate(cols):
            excel_col = GANTT_START + i
            d  = col["date"]
            c  = ws.cell(row=2, column=excel_col)
            if d.date() == today.date():
                c.value = "▼"; c.fill = _fill(COLOR_TODAY_BG)
                c.font  = _font(bold=True, color="FFFFFF", size=8)
            elif col["new_week"]:
                c.value  = f"S{col['week_num']}"
                c.fill   = _fill(COLOR_SUBHDR_BG)
                c.font   = _font(size=8, color="1F3864")
                c.border = Border(left=Side(style="medium", color="AAAAAA"))
            elif col["is_weekend"]:
                c.fill = _fill(COLOR_WEEKEND)
            else:
                c.fill = _fill("FFFFFF")
            c.alignment = _align("center")

    elif scale == "semaine":
        _write_fused_headers(ws, cols,
                             l1_key="new_quarter", l1_label_key="quarter_label",
                             l2_key="new_month",   l2_label_key="month_label",
                             today=today)
    elif scale == "mois":
        _write_fused_headers(ws, cols,
                             l1_key="new_year",    l1_label_key="year_label",
                             l2_key="new_quarter", l2_label_key="quarter_label",
                             today=today)


def _write_fused_headers(ws, cols, l1_key, l1_label_key, l2_key, l2_label_key, today):
    for level, (boundary_key, label_key, bg, fg) in enumerate([
        (l1_key, l1_label_key, COLOR_HEADER_BG,  COLOR_HEADER_TEXT),
        (l2_key, l2_label_key, COLOR_SUBHDR_BG, "1F3864"),
    ], start=1):
        seg_start_col = GANTT_START
        seg_label     = cols[0].get(label_key, "")
        for i, col in enumerate(cols):
            excel_col = GANTT_START + i
            if i > 0 and col.get(boundary_key):
                _merge(ws, level, seg_start_col, level, excel_col - 1,
                       seg_label, bg, fg, bold=(level == 1), size=9)
                seg_start_col = excel_col
                seg_label     = col.get(label_key, "")
        _merge(ws, level, seg_start_col, level, GANTT_START + len(cols) - 1,
               seg_label, bg, fg, bold=(level == 1), size=9)


# ------------------------------------------------------------------
# Renderers de lignes
# ------------------------------------------------------------------

def _write_task_row(ws, task, row: int, cols: list,
                    today: datetime, domain_map: dict, scale: str = "semaine"):
    if isinstance(task, DomainHeader):
        _write_domain_header_row(ws, task, row, cols, scale)
        return
    if isinstance(task, ReunionGroup):
        _write_reunion_row(ws, task, row, cols, today, scale)
        return
    _write_regular_row(ws, task, row, cols, today, domain_map, scale)


def _write_domain_header_row(ws, header: DomainHeader, row: int, cols: list, scale: str):
    """Fond blanc, texte noir gras, bordures haut+bas medium."""
    for col_i in range(1, N_FIXED + 1):
        c = ws.cell(row=row, column=col_i)
        c.fill   = _fill("FFFFFF")
        c.font   = _font(bold=True, color="000000", size=12)
        c.border = Border(
            bottom=Side(style="medium", color="000000"),
            top   =Side(style="medium", color="000000"),
        )
    # Nom dans la première colonne
    ws.cell(row=row, column=1).value = header.name

    for j, col in enumerate(cols):
        c = ws.cell(row=row, column=GANTT_START + j)
        c.fill   = _fill("FFFFFF")
        left_s   = Side(style="medium", color="AAAAAA") if is_l2_boundary(col, scale) else Side(style="thin", color="DDDDDD")
        c.border = Border(
            left  =left_s,
            bottom=Side(style="medium", color="000000"),
            top   =Side(style="medium", color="000000"),
        )


def _write_reunion_row(ws, group: ReunionGroup, row: int, cols: list,
                       today: datetime, scale: str):
    """Une ligne par groupe de réunions récurrentes, ◆ violet par occurrence."""
    bg = "F5EEF8"
    fg = COLOR_REUNION

    def wc(key, value, h="left", bold=False, italic=False, fmt=None):
        if key not in COL:
            return
        c = ws.cell(row=row, column=COL[key], value=value)
        c.fill      = _fill(bg)
        c.font      = _font(bold=bold, color=fg, size=9, italic=italic)
        c.alignment = _align(h)
        c.border    = _border_thin()
        if fmt: c.number_format = fmt

    wc("domaine",  group.folder_name or "")
    wc("nom",      group.name, bold=True)
    wc("debut",    min(group.occurrences) if group.occurrences else None, "center", fmt="DD/MM")
    wc("echeance", max(group.occurrences) if group.occurrences else None, "center", fmt="DD/MM")

    # Fond zone Gantt
    for j, col in enumerate(cols):
        c      = ws.cell(row=row, column=GANTT_START + j)
        c.fill = _fill(COLOR_WEEKEND if col["is_weekend"] else "FAFAFA")
        left_s = Side(style="medium", color="AAAAAA") if is_l2_boundary(col, scale) else Side(style="thin", color="EEEEEE")
        c.border = Border(left=left_s,
                          top=Side(style="thin", color="EEEEEE"),
                          bottom=Side(style="thin", color="EEEEEE"))

    # ◆ par occurrence
    for occ in group.occurrences:
        occ_d  = occ.replace(hour=0, minute=0, second=0, microsecond=0)
        col_i  = date_to_col_idx(occ_d, cols, GANTT_START)
        if col_i is None:
            continue
        c      = ws.cell(row=row, column=col_i)
        existing = c.value or ""
        c.value     = "◆" if not existing else existing + "◆"
        c.fill      = _fill(COLOR_REUNION)
        c.font      = _font(bold=True, color=COLOR_REUNION_TEXT, size=10)
        c.alignment = _align("center")


def _write_regular_row(ws, task: Task, row: int, cols: list,
                       today: datetime, domain_map: dict, scale: str):
    bg_cell, bar_color, text_color = get_task_colors(task, domain_map)
    is_chantier = task.item_type == TYPE_CHANTIER

    def wc(key, value, h="left", bold=False, italic=False, fmt=None):
        if key not in COL:
            return
        c = ws.cell(row=row, column=COL[key], value=value)
        c.fill      = _fill(bg_cell)
        c.font      = _font(bold=bold, color=text_color, size=9, italic=italic)
        c.alignment = _align(h)
        c.border    = _border_thin()
        if fmt: c.number_format = fmt
        return c

    wc("domaine",  task.folder_name or "")
    wc("nom",      task.name, bold=is_chantier)

    wc("etat", _status_text(task), "center")

    if task.start_date:
        wc("debut", task.start_date, "center", fmt="DD/MM")
    else:
        wc("debut", "—", "center", italic=True)

    if task.due_date:
        c = wc("echeance", task.due_date, "center", fmt="DD/MM")
        if c and task.due_date < today and not task.is_done:
            c.font = _font(bold=True, color="C00000", size=9)
    else:
        wc("echeance", "—", "center", italic=True)

    # Fond zone Gantt
    for j, col in enumerate(cols):
        c      = ws.cell(row=row, column=GANTT_START + j)
        c.fill = _fill(COLOR_WEEKEND if col["is_weekend"] else "FAFAFA")
        left_s = Side(style="medium", color="AAAAAA") if is_l2_boundary(col, scale) else Side(style="thin", color="EEEEEE")
        c.border = Border(left=left_s,
                          top=Side(style="thin", color="EEEEEE"),
                          bottom=Side(style="thin", color="EEEEEE"))

    # Barre Gantt
    if task.item_type == TYPE_JALON:
        _draw_milestone(ws, task, row, cols)
    # `or` et non `and` : 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 — n'a qu'une seule
    # date, et la condition d'origine le faisait disparaître du planning sans
    # rien dire. Il remplit désormais au moins la cellule de sa date.
    elif task.start_date or task.due_date:
        barre = bar_color if is_chantier else bg_cell
        _draw_bar(ws, task, row, cols, barre, scale)


def _draw_bar(ws, task: Task, row: int, cols: list, color: str, scale: str):
    # Une seule date renseignée vaut début **et** fin : la barre se réduit à la
    # cellule qui englobe ce jour.
    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
    c1 = date_to_col_idx(sd, cols, GANTT_START)
    c2 = date_to_col_idx(ed, cols, GANTT_START)

    # Rognage aux bornes : une barre qui commence avant la fenêtre — ou finit
    # après — reste visible sur la partie qui s'y trouve. Elle ne disparaît que
    # si elle est entièrement au-dehors.
    if c1 is None and c2 is None:
        return
    if c1 is None:
        c1 = GANTT_START
    if c2 is None:
        c2 = GANTT_START + len(cols) - 1

    for col_i in range(c1, c2 + 1):
        c        = ws.cell(row=row, column=col_i)
        c.fill   = _fill(color)
        col_meta = cols[col_i - GANTT_START] if 0 <= col_i - GANTT_START < len(cols) else {}
        if col_i == c1:
            left = Side(style="medium", color="404040")
        elif is_l2_boundary(col_meta, scale):
            left = Side(style="medium", color="AAAAAA")
        else:
            left = Side(style="thin", color=color)
        right = Side(style="medium" if col_i == c2 else "thin",
                     color="404040" if col_i == c2 else color)
        c.border = Border(left=left, right=right,
                          top=Side(style="thin", color="404040"),
                          bottom=Side(style="thin", color="404040"))


def _draw_milestone(ws, task: Task, row: int, cols: list):
    if not task.due_date:
        return
    col_i       = date_to_col_idx(
        task.due_date.replace(hour=0, minute=0, second=0, microsecond=0),
        cols, GANTT_START
    )
    if col_i is None:
        return
    c           = ws.cell(row=row, column=col_i)
    c.value     = "◆"
    c.fill      = _fill(COLOR_JALON)
    c.font      = _font(bold=True, color=COLOR_JALON_TEXT, size=11)
    c.alignment = _align("center")


def _draw_today_col(ws, today: datetime, cols: list, last_row: int):
    col_i = date_to_col_idx(today, cols, GANTT_START)

    # Une période passée ou à venir ne contient pas aujourd'hui : pas de repère.
    if col_i is None:
        return

    for row in range(4, last_row + 1):
        c = ws.cell(row=row, column=col_i)
        if not c.value:
            curr = c.fill.fgColor.rgb if c.fill and c.fill.fgColor else "FAFAFA"
            if curr in ("FAFAFA", "F5F5F5", "FFFFFF", "00000000"):
                c.fill = _fill(COLOR_TODAY_MARK)


# ------------------------------------------------------------------
# Feuille Données
# ------------------------------------------------------------------

def build_data_sheet(ws, tasks: list):
    headers = ["ID","Type","Domaine","Nom","Statut","Début","Échéance",
               "Terminé le","Visible COPIL","Visible rapport","À arbitrer",
               "Note de contexte","Description Gantt","Priorité","URL"]
    widths  = [12,10,16,42,12,11,11,11,14,15,12,30,30,10,40]

    for i, (h, w) in enumerate(zip(headers, widths), 1):
        c = ws.cell(row=1, column=i, value=h)
        c.fill      = _fill(COLOR_HEADER_BG)
        c.font      = _font(bold=True, color=COLOR_HEADER_TEXT, size=10)
        c.alignment = _align("center")
        c.border    = _border_thin()
        ws.column_dimensions[get_column_letter(i)].width = w

    ws.row_dimensions[1].height = 20

    for row, t in enumerate(tasks, start=2):
        if isinstance(t, (ReunionGroup, DomainHeader)):
            continue

        def w(col, val, fmt=None):
            c = ws.cell(row=row, column=col, value=val)
            c.font      = _font(size=9)
            c.border    = _border_thin()
            c.alignment = _align()
            if fmt: c.number_format = fmt
            return c

        w(1,  t.id)
        w(2,  t.item_type_label)
        w(3,  t.folder_name or "")
        w(4,  t.name)
        w(5,  t.status.title())
        w(6,  t.start_date, "DD/MM/YYYY")
        w(7,  t.due_date,   "DD/MM/YYYY")
        w(8,  t.date_done,  "DD/MM/YYYY")
        w(9,  "✓" if t.visible_copil   else "")
        w(10, "✓" if t.visible_rapport else "")
        w(11, "✓" if t.a_arbitrer      else "")
        w(12, t.note_contexte     or "")
        w(13, t.description_gantt or "")
        w(14, t.priority_label)
        c = w(15, t.url)
        if t.url:
            c.hyperlink = t.url
            c.font = Font(color="0563C1", underline="single", size=9, name="Arial")

    ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}1"
    ws.freeze_panes    = "A2"


# ------------------------------------------------------------------
# Point d'entrée
# ------------------------------------------------------------------

def generate_planning(tasks: list, output_path: str, scale: str = "semaine",
                      domain_colors: dict = None, days_past=30, days_future=120,
                      wb=None, save: bool = True, sheet_title: str = "Planning",
                      range_start=None, range_end=None):
    """
    `wb` / `save` : un classeur fourni par l'appelant est complété plutôt que
    remplacé (Joseph, chantier JOS-69 — un rapport est une composition de blocs,
    et le planning n'est que l'un d'eux). Sans ces paramètres, chaque bloc
    produirait son propre fichier.
    """
    domain_map = build_domain_color_map(tasks, domain_colors)

    if wb is None:
        wb = Workbook()
        # La feuille par défaut d'un classeur neuf sert de feuille Planning ;
        # dans un classeur fourni, elle appartient déjà à un autre bloc.
        ws_planning       = wb.active
        ws_planning.title = sheet_title
    else:
        ws_planning = wb.create_sheet(sheet_title)

    build_planning_sheet(ws_planning, tasks, scale, domain_map, days_past, days_future,
                         range_start, range_end)

    ws_data = wb.create_sheet("Données")
    build_data_sheet(ws_data, tasks)

    if save:
        os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
        wb.save(output_path)
        print(f"✓ Planning généré → {output_path}")
    return output_path