"""
Les blocs qui ne sont pas un planning (chantier JOS-69, phase 4).

Un rapport Joseph est une **composition de blocs** : un planning, une ou
plusieurs listes d'éléments, un focus sur un élément précis. Le planning est
rendu par les trois générateurs existants, qui savent déjà le faire ; ce module
rend les deux autres, dans les trois formats.

**Pourquoi ici et pas dans les générateurs de planning ?** Parce qu'un
`planning_pptx.py` de 1 200 lignes dédiées au dessin d'un Gantt n'a aucune
raison d'apprendre à poser un tableau de tâches terminées. Les trois générateurs
restent ce qu'ils sont — la partie coûteuse et éprouvée — et ne gagnent que deux
paramètres optionnels (`prs=` / `doc=` / `wb=` et `save=`) qui leur permettent de
travailler dans un conteneur qu'ils n'ont pas créé.

**Le filtrage se fait ici, une fois pour les trois formats**, pour la même raison
que `parse_payload()` écarte les éléments annulés : c'est une décision sur les
données, pas sur la mise en page, et la reproduire trois fois les ferait diverger.
"""

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

from datetime import datetime

from core.data_model import TYPE_CHANTIER, TYPE_JALON
from core.design_kit import SIMPLE_LAYOUT_IDX
from generators import dashboards

# La palette des trois générateurs, en hexadécimal sans `#`. Recopiée plutôt
# qu'importée : chacun des trois l'exprime dans son propre type (RGBColor
# python-pptx, RGBColor python-docx, chaîne openpyxl). La centraliser est le
# sujet de la phase 5.
BLUE = "234964"
SLATE = "6B7A8D"
LIGHT = "EAF1F6"
WHITE = "FFFFFF"


# ══════════════════════════════════════════════════════════════════
# Sélection des éléments
# ══════════════════════════════════════════════════════════════════

def _in_range(moment, start, end) -> bool:
    """Bornes **incluses**, comme `DateRange` côté Joseph."""
    if moment is None:
        return False
    return start.date() <= moment.date() <= end.date()


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


def select(tasks: list, block: dict) -> list:
    """
    Les éléments que ce bloc doit montrer.

    Cinq filtres, qui répondent aux questions qu'on se pose en réunion de
    suivi : qu'est-ce qui a avancé, qu'est-ce qui coince, qu'est-ce qui
    attend une validation, qu'est-ce qui a glissé, qu'est-ce qui arrive. Plus
    « tous », qui n'écarte rien : filtrer n'est pas obligatoire, et un Kanban
    filtré sur un statut se réduirait à une seule colonne.

    La période s'applique **à la date qui fait sens pour le filtre** : celle
    d'achèvement pour « terminés », celle d'échéance pour « à venir ». Prendre
    partout la même reviendrait à ne rien montrer la moitié du temps. « Tous »
    ignore donc aussi la période : il n'y a pas de date qui fasse sens pour lui.
    """
    options = block.get("options") or {}
    kind = options.get("filter") or "completed"

    if kind == "all":
        return list(tasks)

    date_range = block.get("range") or {}
    start = _parse_day(date_range.get("from")) or datetime(1970, 1, 1)
    end = _parse_day(date_range.get("to")) or datetime(2999, 12, 31)

    if kind == "completed":
        return [t for t in tasks if t.is_done and _in_range(t.date_done or t.due_date, start, end)]

    if kind == "blocked":
        # Sans borne de temps : un blocage est un état, et le dater à sa date
        # d'échéance masquerait précisément ceux qui n'en ont pas.
        return [t for t in tasks if t.is_at_risk and not t.is_done]

    if kind == "to_validate":
        # Même raisonnement que "blocked" : un statut, pas une date.
        return [t for t in tasks if t.status_category == "to_validate"]

    if kind == "late":
        return [t for t in tasks if not t.is_done and t.due_date is not None and t.due_date.date() < end.date()]

    if kind == "upcoming":
        return [t for t in tasks if not t.is_done and _in_range(t.due_date, start, end)]

    return list(tasks)


def find(tasks: list, element_id) -> object:
    """L'élément visé par un bloc `focus`, ou `None` s'il est sorti du périmètre."""
    if not element_id:
        return None

    wanted = str(element_id)
    for task in tasks:
        if task.id == wanted:
            return task
    return None


def _row(task) -> list:
    """Les quatre colonnes communes aux trois formats."""
    return [
        task.name,
        (task.item_type_label or ""),
        (task.status or "—").capitalize(),
        task.due_date.strftime("%d/%m/%Y") if task.due_date else "—",
    ]


HEADERS = ["Élément", "Type", "Statut", "Échéance"]


# ══════════════════════════════════════════════════════════════════
# Liste PowerPoint (retours du 25/09/2026) — même langage visuel que
# `dashboards.py` : tableau natif épuré, filets horizontaux seuls, fond
# blanc. Distinct de `_row()`/`HEADERS` ci-dessus, qui restent le format
# Word/Excel, plus sobre et non retouché par cette demande.
# ══════════════════════════════════════════════════════════════════

ELEMENTS_HEADERS = ["Élément", "Type", "Statut", "Échéance", "Responsable"]
ELEMENTS_COLS = [4.6, 1.8, 2.0, 1.6, 2.6]

# Couleur volontairement rare (retours du 25/09/2026) : un chantier se
# distingue par le fond de **toute sa ligne** (`_chantier_row_style()`), pas
# par une cellule isolée. Le Jalon garde une pastille sur la colonne Type,
# seul repère à fond coloré du tableau — Tâche et Réunion partagent le même
# texte nu. La colonne Statut n'a jamais de **fond** propre (sur une ligne
# chantier, sa couleur est celle du fond de ligne) mais son **texte** reste
# teinté par catégorie, mêmes trois couleurs que le reste du rapport.
_JALON_BADGE = (dashboards.ORANGE, dashboards.ORANGE_SOFT)

_STATUS_TEXT_COLORS = {
    "blocked": dashboards.RED,
    "waiting": dashboards.AMBER,
    "to_validate": dashboards.AMBER,
    "in_progress": dashboards.BLUE,
    "done": dashboards.GREEN,
}


def _chantier_row_style():
    return {"bg": dashboards.BLUE_SOFT, "fg": dashboards.BLUE, "bold": True}


def _status_value(task):
    label = (task.status or "—").capitalize()
    color = _STATUS_TEXT_COLORS.get(dashboards.category(task))
    return [(label, {"color": color})] if color else label


def _elements_row(task):
    type_value = task.item_type_label or "—"
    if task.item_type == TYPE_JALON:
        type_value = dashboards.Badge(type_value, *_JALON_BADGE)

    return [
        task.name,
        type_value,
        _status_value(task),
        task.due_date.strftime("%d/%m/%Y") if task.due_date else "—",
        dashboards.responsables(task),
    ]


def _hierarchy_order(tasks):
    """
    Les tâches dans l'ordre où les lire : chaque parent immédiatement suivi
    de ses enfants, récursivement, et une même fratrie triée par échéance
    (sans date en dernier). Une tâche dont le parent n'est pas dans `tasks`
    (hors du filtre du bloc, ou une reprise qui ne l'a pas gardé) démarre
    son propre sous-arbre à la racine plutôt que de disparaître — même
    principe que `render.py::_tasks_for_types()`, pour l'ordre d'affichage
    au lieu du contenu.

    Renvoie une liste de `(task, depth)`.
    """
    by_id = {t.id: t for t in tasks}
    children: dict = {}
    roots = []
    for t in tasks:
        parent_id = t.parent_id if t.parent_id in by_id else None
        children.setdefault(parent_id, []).append(t)
        if parent_id is None:
            roots.append(t)

    def due_key(t):
        return (t.due_date or datetime.max, t.orderindex)

    for siblings in children.values():
        siblings.sort(key=due_key)
    roots.sort(key=due_key)

    ordered = []

    def walk(task, depth):
        ordered.append((task, depth))
        for child in children.get(task.id, []):
            walk(child, depth + 1)

    for root in roots:
        walk(root, 0)

    return ordered


# ══════════════════════════════════════════════════════════════════
# PowerPoint
# ══════════════════════════════════════════════════════════════════

def _pptx_slide(prs, title: str, subtitle: str, project_name: str = None):
    from pptx.util import Inches, Pt
    from pptx.dml.color import RGBColor

    from core.design_kit import SIMPLE_PROJECT_NAME_PLACEHOLDER_IDX, SIMPLE_TITLE_PLACEHOLDER_IDX

    slide = prs.slides.add_slide(prs.slide_layouts[SIMPLE_LAYOUT_IDX])

    # `project_name` reste vide côté Joseph tant qu'un rapport peut couvrir
    # plus d'un projet — aujourd'hui `ReportPayload` en garantit toujours un
    # seul (`$scope->project`, obligatoire), donc ce cas ne se produit pas
    # encore, mais rien n'y oblige un futur générateur transversal.
    if project_name:
        slide.placeholders[SIMPLE_PROJECT_NAME_PLACEHOLDER_IDX].text_frame.text = project_name

    # Le placeholder titre du layout "Simple" porte un texte **littéral** —
    # pas une simple invite d'édition — hérité du masque
    # ("Modifiez le style du titre") : laissé vide, il reste visible tel
    # quel à l'ouverture et se superpose à notre propre zone de texte
    # ci-dessous (retours du 25/09/2026). On le vide explicitement plutôt
    # que de le remplir : sa mise en forme (taille, position) ne colle pas
    # à ce qu'on veut ici, d'où la zone de texte dédiée qui suit.
    slide.placeholders[SIMPLE_TITLE_PLACEHOLDER_IDX].text_frame.text = ""

    box = slide.shapes.add_textbox(Inches(0.35), Inches(0.3), Inches(12.6), Inches(0.5))
    run = box.text_frame.paragraphs[0].add_run()
    run.text = title
    run.font.size = Pt(22)
    run.font.bold = True
    run.font.color.rgb = RGBColor(0x23, 0x49, 0x64)

    sub = slide.shapes.add_textbox(Inches(0.35), Inches(0.85), Inches(12.6), Inches(0.3))
    srun = sub.text_frame.paragraphs[0].add_run()
    srun.text = subtitle
    srun.font.size = Pt(11)
    srun.font.color.rgb = RGBColor(0x6B, 0x7A, 0x8D)

    return slide


def render_elements_pptx(prs, block: dict, tasks: list, project_name: str = None):
    """Le tableau, découpé par chantier si le bloc le demande."""
    for group, subset in split_by_chantier(select(tasks, block), block):
        _elements_pptx_group(prs, block, subset, group, project_name)


def _elements_pptx_group(prs, block: dict, tasks: list, group, project_name: str = None):
    """
    Tableau natif dans le langage visuel de `dashboards.py` (retours du
    25/09/2026) : filets horizontaux seuls, fond blanc, couleur rare — un
    chantier teinte toute sa ligne (`_chantier_row_style()`), un jalon garde
    une pastille sur la colonne Type, rien d'autre n'est coloré. Les tâches
    sont lues par hiérarchie (`_hierarchy_order()`), un parent immédiatement
    suivi de ses enfants, chaque fratrie triée par échéance — avec une
    indentation par niveau pour que la hiérarchie se voie sans avoir à la
    déduire du texte.
    """
    from pptx.enum.text import PP_ALIGN

    base_title = block.get("title") or "Éléments"
    if group is not None:
        base_title = "{} — {}".format(base_title, group)

    slide = _pptx_slide(prs, base_title, block.get("range_label") or "", project_name)
    ordered = _hierarchy_order(tasks)

    if not ordered:
        from pptx.util import Inches, Pt
        from pptx.dml.color import RGBColor

        box = slide.shapes.add_textbox(Inches(0.35), Inches(1.5), Inches(12.6), Inches(0.4))
        run = box.text_frame.paragraphs[0].add_run()
        run.text = "Rien à signaler sur cette période."
        run.font.size = Pt(12)
        run.font.italic = True
        run.font.color.rgb = RGBColor(0x6B, 0x7A, 0x8D)
        return

    # Une diapositive tient une vingtaine de lignes ; au-delà on en ouvre une
    # nouvelle plutôt que de produire un tableau illisible. C'est la même limite
    # que le planning en mode « fill ».
    per_slide = 18
    chunks = [ordered[i:i + per_slide] for i in range(0, len(ordered), per_slide)]
    indent_step, max_indent_depth = 0.18, 6
    aligns = [PP_ALIGN.LEFT, PP_ALIGN.CENTER, PP_ALIGN.CENTER, PP_ALIGN.CENTER, PP_ALIGN.CENTER]

    for index, chunk in enumerate(chunks):
        if index > 0:
            slide = _pptx_slide(prs, base_title + " (suite)", block.get("range_label") or "", project_name)

        rows = [_elements_row(task) for task, _ in chunk]
        indents = [min(depth, max_indent_depth) * indent_step for _, depth in chunk]
        row_style = [_chantier_row_style() if task.item_type == TYPE_CHANTIER else None for task, _ in chunk]
        dashboards.table(slide, 0.35, 1.3, ELEMENTS_HEADERS, rows, ELEMENTS_COLS,
                         aligns=aligns, indents=indents, row_style=row_style)


def render_focus_pptx(prs, block: dict, tasks: list, project_name: str = None):
    from pptx.util import Inches, Pt
    from pptx.dml.color import RGBColor

    task = find(tasks, block.get("target_element_id"))
    slide = _pptx_slide(
        prs, block.get("title") or "Focus", "" if task is None else (task.status or ""),
        project_name,
    )

    box = slide.shapes.add_textbox(Inches(0.35), Inches(1.4), Inches(12.6), Inches(5.0))
    frame = box.text_frame
    frame.word_wrap = True

    if task is None:
        run = frame.paragraphs[0].add_run()
        run.text = "L'élément mis en avant n'est plus dans le périmètre du rapport."
        run.font.size = Pt(12)
        run.font.italic = True
        run.font.color.rgb = RGBColor(0x6B, 0x7A, 0x8D)
        return

    head = frame.paragraphs[0].add_run()
    head.text = task.name
    head.font.size = Pt(16)
    head.font.bold = True

    meta = frame.add_paragraph().add_run()
    meta.text = " · ".join(_row(task)[1:])
    meta.font.size = Pt(11)
    meta.font.color.rgb = RGBColor(0x6B, 0x7A, 0x8D)

    body = (task.description or task.description_gantt or task.note_contexte or "").strip()
    if body:
        run = frame.add_paragraph().add_run()
        run.text = body
        run.font.size = Pt(11)


# ══════════════════════════════════════════════════════════════════
# Word
# ══════════════════════════════════════════════════════════════════

def _docx_heading(doc, block: dict, fallback: str):
    from docx.shared import RGBColor, Pt

    heading = doc.add_heading(block.get("title") or fallback, level=1)
    for run in heading.runs:
        run.font.color.rgb = RGBColor(0x23, 0x49, 0x64)

    label = block.get("range_label")
    if label:
        para = doc.add_paragraph()
        run = para.add_run(label)
        run.font.size = Pt(9)
        run.font.italic = True
        run.font.color.rgb = RGBColor(0x6B, 0x7A, 0x8D)


def render_elements_docx(doc, block: dict, tasks: list):
    """Le tableau, découpé par chantier si le bloc le demande."""
    groups = split_by_chantier(select(tasks, block), block)

    for index, (group, subset) in enumerate(groups):
        if index == 0:
            _docx_heading(doc, block, "Éléments")
        if group is not None:
            _docx_subheading(doc, group)

        _elements_docx_group(doc, subset)


def _elements_docx_group(doc, rows: list):
    from docx.shared import Pt, RGBColor

    if not rows:
        para = doc.add_paragraph("Rien à signaler sur cette période.")
        para.runs[0].font.italic = True
        para.runs[0].font.color.rgb = RGBColor(0x6B, 0x7A, 0x8D)
        return

    table = doc.add_table(rows=1, cols=4)
    table.style = "Table Grid"

    for column, header in enumerate(HEADERS):
        cell = table.rows[0].cells[column]
        cell.text = ""
        run = cell.paragraphs[0].add_run(header)
        run.font.bold = True
        run.font.size = Pt(9)

    for task in rows:
        cells = table.add_row().cells
        for column, value in enumerate(_row(task)):
            cells[column].text = ""
            run = cells[column].paragraphs[0].add_run(value)
            run.font.size = Pt(9)

    doc.add_paragraph()


def render_focus_docx(doc, block: dict, tasks: list):
    from docx.shared import Pt, RGBColor

    task = find(tasks, block.get("target_element_id"))
    _docx_heading(doc, block, "Focus")

    if task is None:
        para = doc.add_paragraph("L'élément mis en avant n'est plus dans le périmètre du rapport.")
        para.runs[0].font.italic = True
        para.runs[0].font.color.rgb = RGBColor(0x6B, 0x7A, 0x8D)
        return

    title = doc.add_paragraph()
    run = title.add_run(task.name)
    run.font.bold = True
    run.font.size = Pt(12)

    meta = doc.add_paragraph()
    mrun = meta.add_run(" · ".join(_row(task)[1:]))
    mrun.font.size = Pt(9)
    mrun.font.color.rgb = RGBColor(0x6B, 0x7A, 0x8D)

    body = (task.description or task.description_gantt or task.note_contexte or "").strip()
    if body:
        doc.add_paragraph(body)

    doc.add_paragraph()


# ══════════════════════════════════════════════════════════════════
# Excel
# ══════════════════════════════════════════════════════════════════

def _excel_sheet(wb, title: str):
    """
    Excel plafonne les noms d'onglets à 31 caractères et refuse `[]:*?/\\`.
    Un titre de bloc saisi librement viole les deux règles sans prévenir — d'où
    ce nettoyage, plutôt qu'une erreur au moment de la sauvegarde.
    """
    clean = "".join(character for character in (title or "Éléments") if character not in "[]:*?/\\")

    return wb.create_sheet((clean or "Éléments")[:31])


def render_elements_xlsx(wb, block: dict, tasks: list):
    """Le tableau, une feuille par chantier si le bloc le demande."""
    for group, subset in split_by_chantier(select(tasks, block), block):
        label = block.get("title") or "Éléments"
        _elements_xlsx_sheet(wb, block, subset, label if group is None else "{} {}".format(label, group))


def _elements_xlsx_sheet(wb, block: dict, rows: list, sheet_name: str):
    from openpyxl.styles import Alignment, Font, PatternFill

    ws = _excel_sheet(wb, sheet_name)

    ws["A1"] = block.get("range_label") or ""
    ws["A1"].font = Font(italic=True, color=SLATE, size=9)

    for column, header in enumerate(HEADERS, start=1):
        cell = ws.cell(row=2, column=column, value=header)
        cell.font = Font(bold=True, color=WHITE, size=10)
        cell.fill = PatternFill("solid", fgColor=BLUE)
        cell.alignment = Alignment(vertical="center")

    for line, task in enumerate(rows, start=3):
        for column, value in enumerate(_row(task), start=1):
            ws.cell(row=line, column=column, value=value).font = Font(size=10)

    for column, width in zip("ABCD", (70, 14, 18, 14)):
        ws.column_dimensions[column].width = width


def render_focus_xlsx(wb, block: dict, tasks: list):
    from openpyxl.styles import Font

    task = find(tasks, block.get("target_element_id"))
    ws = _excel_sheet(wb, block.get("title") or "Focus")

    if task is None:
        ws["A1"] = "L'élément mis en avant n'est plus dans le périmètre du rapport."
        ws["A1"].font = Font(italic=True, color=SLATE)
        return

    ws["A1"] = task.name
    ws["A1"].font = Font(bold=True, size=12, color=BLUE)

    for line, (label, value) in enumerate(zip(HEADERS[1:], _row(task)[1:]), start=3):
        ws.cell(row=line, column=1, value=label).font = Font(bold=True, size=10)
        ws.cell(row=line, column=2, value=value).font = Font(size=10)

    body = (task.description or task.description_gantt or task.note_contexte or "").strip()
    if body:
        ws.cell(row=7, column=1, value=body).font = Font(size=10)

    ws.column_dimensions["A"].width = 24
    ws.column_dimensions["B"].width = 70

# ══════════════════════════════════════════════════════════════════
# Kanban — la même sélection, lue par statut
# ══════════════════════════════════════════════════════════════════

# Les colonnes du Kanban, dans l'ordre où on les lit en réunion : ce qui reste à
# faire, ce qui avance, ce qui coince, ce qui est fini. Le libellé exact du
# statut vient de Joseph et varie d'un projet à l'autre ; on regroupe donc sur
# les **drapeaux** que le payload porte déjà, pas sur le texte.
KANBAN_COLUMNS = [
    ("À faire", lambda t: not t.is_done and not t.is_at_risk and not t.is_waiting),
    ("En attente", lambda t: t.is_waiting and not t.is_done),
    ("Bloqué", lambda t: t.is_at_risk and not t.is_done),
    ("Terminé", lambda t: t.is_done),
]


def kanban_columns(tasks: list):
    """
    Répartit les éléments en colonnes.

    Le premier prédicat qui accepte l'emporte : un élément bloqué *et* en
    attente ne peut apparaître qu'une fois, sinon les totaux ne voudraient plus
    rien dire.
    """
    columns = [(name, []) for name, _ in KANBAN_COLUMNS]

    for task in tasks:
        for index, (_, matches) in enumerate(KANBAN_COLUMNS):
            if matches(task):
                columns[index][1].append(task)
                break

    return columns


def split_by_chantier(tasks: list, block: dict):
    """
    Découpe la sélection par chantier, ou la laisse d'un bloc.

    `page_mode` vaut `all` (défaut) ou `chantier`. Le regroupement se fait sur le
    **domaine** (`folder_name`), que Joseph a déjà résolu au chantier racine —
    voir `ReportPayload::domainOf()`. L'ordre des groupes suit celui d'arrivée,
    donc celui du projet.

    Retourne : liste de couples (titre du groupe ou `None`, éléments).
    """
    if (block.get("options") or {}).get("page_mode") != "chantier":
        return [(None, tasks)]

    groups = []
    index = {}

    for task in tasks:
        name = task.folder_name or task.list_name or "Hors chantier"
        if name not in index:
            index[name] = len(groups)
            groups.append((name, []))
        groups[index[name]][1].append(task)

    return groups or [(None, tasks)]


def _kanban_rows(columns):
    """
    Le Kanban mis à plat en lignes de tableau : autant de lignes que la colonne
    la plus fournie, une colonne de tableau par colonne de Kanban.
    """
    height = max((len(items) for _, items in columns), default=0)

    return [
        [items[i].name if i < len(items) else "" for _, items in columns]
        for i in range(height)
    ]


def render_kanban_pptx(prs, block: dict, tasks: list, project_name: str = None):
    """Le Kanban **dessiné** : c'est le seul format qui a la surface pour ça."""
    from pptx.util import Inches, Pt
    from pptx.dml.color import RGBColor

    for group, subset in split_by_chantier(select(tasks, block), block):
        columns = kanban_columns(subset)
        title = block.get("title") or "Éléments"
        slide = _pptx_slide(
            prs,
            title if group is None else "{} — {}".format(title, group),
            block.get("range_label") or "",
            project_name,
        )

        col_w = 3.0
        gap = 0.15
        left0 = 0.35
        top0 = 1.35

        for i, (name, items) in enumerate(columns):
            x = left0 + i * (col_w + gap)

            head = slide.shapes.add_shape(1, Inches(x), Inches(top0), Inches(col_w), Inches(0.32))
            head.fill.solid()
            head.fill.fore_color.rgb = RGBColor(0x23, 0x49, 0x64)
            head.line.fill.background()
            head.shadow.inherit = False
            para = head.text_frame.paragraphs[0]
            run = para.add_run()
            run.text = "{} ({})".format(name, len(items))
            run.font.size = Pt(10)
            run.font.bold = True
            run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)

            # 14 cartes par colonne : au-delà, la diapositive déborde et le
            # compteur de l'en-tête dit déjà combien il en reste.
            for j, task in enumerate(items[:14]):
                y = top0 + 0.42 + j * 0.4
                card = slide.shapes.add_shape(1, Inches(x), Inches(y), Inches(col_w), Inches(0.34))
                card.fill.solid()
                card.fill.fore_color.rgb = _card_color(task)
                card.line.color.rgb = RGBColor(0xDD, 0xE3, 0xEA)
                card.shadow.inherit = False
                cpara = card.text_frame.paragraphs[0]
                crun = cpara.add_run()
                crun.text = task.name[:58]
                crun.font.size = Pt(8)
                crun.font.color.rgb = RGBColor(0x1A, 0x27, 0x33)


def _card_color(task):
    """La couleur du chantier si Joseph en a résolu une, un gris neutre sinon."""
    from pptx.dml.color import RGBColor

    light = getattr(task, "color_light", None)

    if light:
        return RGBColor(int(light[0:2], 16), int(light[2:4], 16), int(light[4:6], 16))

    return RGBColor(0xF3, 0xF5, 0xF7)


def render_kanban_docx(doc, block: dict, tasks: list):
    """En Word, le Kanban est un tableau : une colonne par statut."""
    from docx.shared import Pt, RGBColor

    for group, subset in split_by_chantier(select(tasks, block), block):
        columns = kanban_columns(subset)
        _docx_heading(doc, block, "Éléments") if group is None else _docx_subheading(doc, group)

        table = doc.add_table(rows=1, cols=len(columns))
        table.style = "Table Grid"

        for i, (name, items) in enumerate(columns):
            cell = table.rows[0].cells[i]
            cell.text = ""
            run = cell.paragraphs[0].add_run("{} ({})".format(name, len(items)))
            run.font.bold = True
            run.font.size = Pt(9)

        for line in _kanban_rows(columns):
            cells = table.add_row().cells
            for i, value in enumerate(line):
                cells[i].text = ""
                run = cells[i].paragraphs[0].add_run(value)
                run.font.size = Pt(8)

        doc.add_paragraph()


def _docx_subheading(doc, text: str):
    from docx.shared import RGBColor

    heading = doc.add_heading(text, level=2)
    for run in heading.runs:
        run.font.color.rgb = RGBColor(0x2E, 0x5F, 0x80)


def render_kanban_xlsx(wb, block: dict, tasks: list):
    """En Excel, une feuille par groupe, une colonne par statut."""
    from openpyxl.styles import Font, PatternFill

    for group, subset in split_by_chantier(select(tasks, block), block):
        columns = kanban_columns(subset)
        label = block.get("title") or "Kanban"
        ws = _excel_sheet(wb, label if group is None else "{} {}".format(label, group))

        ws["A1"] = block.get("range_label") or ""
        ws["A1"].font = Font(italic=True, color=SLATE, size=9)

        for i, (name, items) in enumerate(columns, start=1):
            cell = ws.cell(row=2, column=i, value="{} ({})".format(name, len(items)))
            cell.font = Font(bold=True, color=WHITE, size=10)
            cell.fill = PatternFill("solid", fgColor=BLUE)
            ws.column_dimensions[chr(64 + i)].width = 38

        for line_no, line in enumerate(_kanban_rows(columns), start=3):
            for i, value in enumerate(line, start=1):
                ws.cell(row=line_no, column=i, value=value).font = Font(size=10)
