"""
Moteur de génération de la Fiche Mission TPZ.

Prend un dictionnaire de données (voir `build_docx(data, out_path)`) et remplit le
template Word (templates_word/FICHE_MISSION_TPZ_template.docx) en conservant
exactement sa mise en forme d'origine (tableaux, couleurs, cases à cocher natives,
menus déroulants pilotes).
"""
import copy
import os
import docx
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
TEMPLATE_PATH = os.path.join(BASE_DIR, "templates_word", "FICHE_MISSION_TPZ_template.docx")


# ---------------------------------------------------------------------------
# Helpers bas niveau
# ---------------------------------------------------------------------------

def _row_label(row):
    return row.cells[0].text.strip()


def find_row(table, label_prefix):
    for r in table.rows:
        if _row_label(r).startswith(label_prefix):
            return r
    raise ValueError(f"Ligne introuvable pour le libellé: {label_prefix!r}")


def set_cell_text(cell, text):
    p = cell.paragraphs[0]
    runs = p.runs
    if runs:
        keep = runs[0]
        keep.text = text
        for r in runs[1:]:
            r._element.getparent().remove(r._element)
    else:
        p.add_run(text)
    for extra_p in cell.paragraphs[1:]:
        extra_p._element.getparent().remove(extra_p._element)


def set_value(table, label_prefix, value, col=1):
    row = find_row(table, label_prefix)
    set_cell_text(row.cells[col], value)


def set_inline_suffix_in_row(row, prefix_text, value):
    """Pour les cellules du type 'Si oui, Nom : <valeur>' où libellé + valeur
    sont dans la même cellule, en se limitant à une ligne précise (évite de
    matcher une autre ligne du tableau ayant le même libellé)."""
    for c in row.cells:
        if c.text.strip().startswith(prefix_text):
            set_cell_text(c, f"{prefix_text} : {value}" if value else f"{prefix_text} : ")
            return
    raise ValueError(f"Cellule introuvable pour: {prefix_text!r} dans la ligne {_row_label(row)!r}")


def _iter_cell_checkboxes(tc):
    """Comme _iter_row_checkboxes mais limité à une seule cellule (w:tc), pour
    éviter que le label d'une case en fin de cellule n'aspire le texte de la
    cellule suivante."""
    items = []
    nodes = list(tc.iter())
    pending_sdt = None
    pending_text = []
    for node in nodes:
        tag = node.tag.split('}')[-1]
        if tag == 'sdt':
            sdtPr = node.find(qn('w:sdtPr'))
            is_checkbox = sdtPr is not None and sdtPr.find(qn('w14:checkbox')) is not None
            if is_checkbox:
                if pending_sdt is not None:
                    items.append((''.join(pending_text).strip(), pending_sdt))
                pending_sdt = node
                pending_text = []
        elif tag == 't' and pending_sdt is not None:
            if node.getparent() is not None:
                anc = node
                inside_pending = False
                while anc is not None:
                    if anc is pending_sdt:
                        inside_pending = True
                        break
                    anc = anc.getparent()
                if not inside_pending:
                    pending_text.append(node.text or '')
    if pending_sdt is not None:
        items.append((''.join(pending_text).strip(), pending_sdt))
    return items


def _iter_row_checkboxes(row):
    """Retourne une liste de (label_option, sdt_element) dans l'ordre du document,
    pour toutes les cases à cocher SDT présentes dans la ligne. Le label associé
    à chaque case est le texte qui suit immédiatement la case, sans jamais
    déborder sur la cellule suivante (chaque w:tc est traité indépendamment,
    y compris les cellules dupliquées par gridSpan)."""
    items = []
    seen_tc_ids = set()
    for tc in row._tr.findall(qn('w:tc')):
        if id(tc) in seen_tc_ids:
            continue
        seen_tc_ids.add(id(tc))
        items.extend(_iter_cell_checkboxes(tc))
    return items


def set_checkbox(sdt, checked: bool):
    sdtPr = sdt.find(qn('w:sdtPr'))
    cb = sdtPr.find(qn('w14:checkbox'))
    checked_el = cb.find(qn('w14:checked'))
    checked_state = cb.find(qn('w14:checkedState'))
    unchecked_state = cb.find(qn('w14:uncheckedState'))
    checked_el.set(qn('w14:val'), '1' if checked else '0')
    state = checked_state if checked else unchecked_state
    char = chr(int(state.get(qn('w14:val')), 16))
    content = sdt.find(qn('w:sdtContent'))
    for t in content.iter(qn('w:t')):
        t.text = char


def check_option(table, label_prefix, option_label):
    """Coche la case dont le texte suivant correspond à `option_label` (comparaison
    insensible à la casse/espaces) dans la ligne repérée par `label_prefix`, et
    décoche toutes les autres cases du même groupe (choix exclusif, ex: Oui/Non)."""
    row = find_row(table, label_prefix)
    boxes = _iter_row_checkboxes(row)
    _apply_choice(boxes, option_label, exclusive=True)


def check_option_multirow(table, anchor_label_prefix, n_rows, option_label, exclusive=True):
    """Comme check_option mais la case cherchée peut se trouver sur plusieurs
    lignes consécutives à partir de celle repérée par anchor_label_prefix
    (ex: bloc densité GRC sur 3 lignes, mitigations sur 3 lignes)."""
    rows = list(table.rows)
    start = None
    for i, r in enumerate(rows):
        if _row_label(r).startswith(anchor_label_prefix):
            start = i
            break
    if start is None:
        raise ValueError(f"Ligne ancre introuvable: {anchor_label_prefix!r}")
    boxes = []
    for r in rows[start:start + n_rows]:
        boxes.extend(_iter_row_checkboxes(r))
    _apply_choice(boxes, option_label, exclusive)


def _apply_choice(boxes, option_label, exclusive):
    target_norm = _norm(option_label)
    matched = False
    for label, sdt in boxes:
        is_match = _norm(label) == target_norm or target_norm in _norm(label)
        if is_match:
            set_checkbox(sdt, True)
            matched = True
        elif exclusive:
            set_checkbox(sdt, False)
    if not matched:
        available = [l for l, _ in boxes]
        raise ValueError(f"Option {option_label!r} introuvable parmi {available}")


def check_options_multi(table, anchor_label_prefix, n_rows, selected_labels):
    """Pour les groupes multi-sélection (ex: mitigations) : coche toutes les
    options listées dans `selected_labels`, décoche les autres."""
    rows = list(table.rows)
    start = None
    for i, r in enumerate(rows):
        if _row_label(r).startswith(anchor_label_prefix):
            start = i
            break
    if start is None:
        raise ValueError(f"Ligne ancre introuvable: {anchor_label_prefix!r}")
    boxes = []
    for r in rows[start:start + n_rows]:
        boxes.extend(_iter_row_checkboxes(r))
    selected_norm = {_norm(s) for s in selected_labels}
    found = set()
    for label, sdt in boxes:
        hit = any(sn in _norm(label) or _norm(label) in sn for sn in selected_norm)
        set_checkbox(sdt, hit)
        if hit:
            found.add(label)


def _norm(s):
    return ''.join(s.split()).lower().replace('²', '2')


def set_combo_text(sdt, text):
    content = sdt.find(qn('w:sdtContent'))
    ts = list(content.iter(qn('w:t')))
    if ts:
        ts[0].text = text
        for extra in ts[1:]:
            extra.text = ''


def _iter_row_combos(row):
    combos = []
    for sdt in row._tr.iter(qn('w:sdt')):
        sdtPr = sdt.find(qn('w:sdtPr'))
        if sdtPr is not None and sdtPr.find(qn('w:comboBox')) is not None:
            combos.append(sdt)
    return combos


# ---------------------------------------------------------------------------
# Cellules encapsulées en content control (w:sdt englobant tout un <w:tc>) —
# utilisées par les grilles "Mitigations par parcelle" et "Confinement par
# parcelle". python-docx ne les voit pas via row.cells, d'où ces helpers bas
# niveau qui parcourent le XML brut.
# ---------------------------------------------------------------------------

def real_cells(tr):
    """Liste des vraies cellules d'une ligne <w:tr> : {"tc":..., "sdt":...}
    (sdt est None pour une cellule normale, sinon le <w:sdt> parent)."""
    out = []
    for child in tr:
        tag = child.tag.split('}')[-1]
        if tag == 'tc':
            out.append({"tc": child, "sdt": None})
        elif tag == 'sdt':
            content = child.find(qn('w:sdtContent'))
            if content is not None:
                tc = content.find(qn('w:tc'))
                if tc is not None:
                    out.append({"tc": tc, "sdt": child})
    return out


def find_tr_by_label(table, label_prefix):
    """Comme find_row mais retourne l'élément <w:tr> brut (pas l'objet _Row
    python-docx), pour pouvoir naviguer avec getnext() sans les limites de
    l'API haut niveau face aux cellules-content-control."""
    for tr in table._tbl.findall(qn('w:tr')):
        cells = real_cells(tr)
        if cells and tc_text(cells[0]["tc"]).startswith(label_prefix):
            return tr
    raise ValueError(f"Ligne introuvable pour le libellé: {label_prefix!r}")


def tc_text(tc):
    return ''.join(t.text or '' for t in tc.iter(qn('w:t'))).strip()


def set_tc_text(tc, text):
    p = tc.find(qn('w:p'))
    runs = p.findall(qn('w:r'))
    if runs:
        keep = runs[0]
        t = keep.find(qn('w:t'))
        if t is None:
            t = keep.makeelement(qn('w:t'), {})
            keep.append(t)
        t.text = text
        t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
        for r in runs[1:]:
            p.remove(r)
    else:
        r = p.makeelement(qn('w:r'), {})
        t = r.makeelement(qn('w:t'), {})
        t.text = text
        t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
        r.append(t)
        p.append(r)


# ---------------------------------------------------------------------------
# Grille "Mitigations du risque au sol appliquées" — 5 lignes parcelle x 3
# cases à cocher (M1(A) / M1(C) / M2), chaque case étant une cellule-content-
# control indépendante.
# ---------------------------------------------------------------------------

def fill_mitigations_grid(table1, parcelles, mitigations_par_parcelle):
    """`parcelles` = liste de dicts (voir fill_parcelles). `mitigations_par_parcelle`
    = liste parallèle de listes d'options cochées (sous-ensemble de
    ["M1(A) - Low", "M1(C) - Low", "M2 - Medium"]), même longueur que `parcelles`."""
    anchor_tr = find_tr_by_label(table1, "Mitigations du risque au sol")
    tr = anchor_tr
    for i in range(5):
        tr = tr.getnext()
        if tr is None:
            break
        cells = real_cells(tr)
        if len(cells) < 2:
            break
        if i < len(parcelles):
            set_tc_text(cells[1]["tc"], parcelles[i].get("nom", f"Parcelle {i+1}"))
            selected = set(_norm(s) for s in (mitigations_par_parcelle[i] if i < len(mitigations_par_parcelle) else []))
            # Le libellé de chaque mitigation est dans l'en-tête de colonne (pas répété
            # à côté de chaque case) : on coche donc par position de colonne
            # (2=M1(A), 3=M1(C), 4=M2), dans l'ordre où les colonnes apparaissent dans le template.
            col_labels = ["M1(A) - Low", "M1(C) - Low", "M2 - Medium"]
            for col_idx, cb_cell in enumerate(cells[2:5]):
                if cb_cell["sdt"] is None:
                    continue
                is_checked = _norm(col_labels[col_idx]) in selected
                set_checkbox(cb_cell["sdt"], is_checked)
        else:
            set_tc_text(cells[1]["tc"], "")
            for cb_cell in cells[2:5]:
                if cb_cell["sdt"] is not None:
                    set_checkbox(cb_cell["sdt"], False)


# ---------------------------------------------------------------------------
# Grille "Exigence de confinement" — 5 lignes Parcelle / Densité moyenne
# ---------------------------------------------------------------------------

def fill_confinement_grid(table3, parcelles, densites_5km):
    """`densites_5km` = liste parallèle à `parcelles` de valeurs (str ou
    nombre) de densité moyenne en ppl/km² sur un rayon de 5 km."""
    anchor_tr = find_tr_by_label(table3, "Densité moyenne dans un rayon de 5 km")
    tr = anchor_tr
    for i in range(5):
        tr = tr.getnext()
        if tr is None:
            break
        cells = real_cells(tr)
        if len(cells) < 3:
            break
        if i < len(parcelles):
            set_tc_text(cells[1]["tc"], parcelles[i].get("nom", f"Parcelle {i+1}"))
            val = densites_5km[i] if i < len(densites_5km) else ""
            set_tc_text(cells[2]["tc"], str(val) if val not in (None, "") else "")
        else:
            set_tc_text(cells[1]["tc"], "")
            set_tc_text(cells[2]["tc"], "")


# ---------------------------------------------------------------------------
# Bloc "Parcelles" (1 à 5 lignes dynamiques)
# ---------------------------------------------------------------------------

def fill_parcelles(table0, parcelles):
    """`parcelles` = liste de dicts {"nom": str, "commune": str, "ref_cadastrale": str}
    (1 à 5 éléments). La ligne gabarit vide se trouve juste après la ligne
    d'en-tête 'Parcelle | Commune | Réf. cadastrale'."""
    header_row = find_row(table0, "Parcelle")
    template_tr = header_row._tr.getnext()  # ligne gabarit vide
    anchor = template_tr
    for p in parcelles:
        new_tr = copy.deepcopy(template_tr)
        cells_tc = new_tr.findall(qn('w:tc'))
        values = [p.get("nom", ""), p.get("commune", ""), p.get("ref_cadastrale", "")]
        for tc, val in zip(cells_tc, values):
            _set_tc_text(tc, val)
        anchor.addnext(new_tr)
        anchor = new_tr
    # supprime la ligne gabarit d'origine (désormais vide et inutile)
    template_tr.getparent().remove(template_tr)


def _set_tc_text(tc, text):
    p = tc.find(qn('w:p'))
    runs = p.findall(qn('w:r'))
    if runs:
        keep = runs[0]
        t = keep.find(qn('w:t'))
        if t is None:
            t = keep.makeelement(qn('w:t'), {})
            keep.append(t)
        t.text = text
        t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
        for r in runs[1:]:
            p.remove(r)
    else:
        # crée un run minimal
        r = p.makeelement(qn('w:r'), {})
        t = r.makeelement(qn('w:t'), {})
        t.text = text
        t.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
        r.append(t)
        p.append(r)


# ---------------------------------------------------------------------------
# Pilotes
# ---------------------------------------------------------------------------

def fill_pilotes(table0, pilotes):
    """`pilotes` = liste de 0 à 4 dicts {"nom": str, "telephone": str}.

    La grille pilotes compte 4 emplacements : 2 lignes de noms (combos, 2x2) et
    2 lignes de téléphones (2x2), alignés par colonne. Les emplacements non
    utilisés affichent '/' (nom) et un téléphone vide."""
    # 4 combos de noms, dans l'ordre du document
    combos = []
    for tr in table0._tbl.findall(qn('w:tr')):
        for sdt in tr.iter(qn('w:sdt')):
            sdtPr = sdt.find(qn('w:sdtPr'))
            if sdtPr is not None and sdtPr.find(qn('w:comboBox')) is not None:
                combos.append(sdt)

    # 4 cellules de téléphone : la 1re ligne 'Coordonnées téléphoniques' + la
    # ligne suivante (2e ligne de la fusion verticale du libellé).
    trs = table0._tbl.findall(qn('w:tr'))
    phone_cells = []
    for idx, tr in enumerate(trs):
        cells = real_cells(tr)
        if cells and tc_text(cells[0]["tc"]).startswith("Coordonnées téléphoniques"):
            for c in cells[1:]:
                phone_cells.append(c["tc"])
            if idx + 1 < len(trs):
                for c in real_cells(trs[idx + 1])[1:]:
                    phone_cells.append(c["tc"])
            break

    for i, combo in enumerate(combos):
        set_combo_text(combo, pilotes[i]["nom"] if i < len(pilotes) else "/")
    for i, tc in enumerate(phone_cells):
        set_tc_text(tc, pilotes[i].get("telephone", "") if i < len(pilotes) else "")


# ---------------------------------------------------------------------------
# Annexes (insertion d'image à la place du texte placeholder)
# ---------------------------------------------------------------------------

# Les pages d'annexe du modèle sont en PAYSAGE (29,7 x 21 cm). L'image doit tenir
# sur la même page que son bandeau-titre : on borne donc largeur et hauteur à
# l'espace utile réel, en réservant ~2,5 cm de hauteur pour le titre.
ANNEXE_MAX_WIDTH_CM = 24.0   # 29,7 - marges G/D (~2,5 cm chacune)
ANNEXE_MAX_HEIGHT_CM = 13.5  # 16 cm utiles - bandeau-titre - petite marge


def _fit_image_size(image_path, max_w_cm=ANNEXE_MAX_WIDTH_CM, max_h_cm=ANNEXE_MAX_HEIGHT_CM):
    """Retourne (width_cm, height_cm) pour afficher l'image en respectant son
    ratio, sans dépasser la largeur ni la hauteur utiles de la page."""
    try:
        from PIL import Image
        with Image.open(image_path) as im:
            iw, ih = im.size
    except Exception:
        return max_w_cm, None  # repli : largeur fixe, hauteur auto
    if iw <= 0 or ih <= 0:
        return max_w_cm, None
    aspect = iw / ih
    width_cm = max_w_cm
    height_cm = width_cm / aspect
    if height_cm > max_h_cm:
        height_cm = max_h_cm
        width_cm = height_cm * aspect
    return width_cm, height_cm


def _is_spacer_paragraph(p_el):
    """Vrai si le paragraphe est un simple espaceur supprimable : pas de texte,
    pas de saut de section (sectPr), pas d'image (drawing)."""
    if p_el.findall('.//' + qn('w:t')):
        if any((t.text or '').strip() for t in p_el.findall('.//' + qn('w:t'))):
            return False
    if p_el.findall('.//' + qn('w:sectPr')):
        return False
    if p_el.findall('.//' + qn('w:drawing')):
        return False
    return True


def _put_image_in_paragraph(paragraph, image_path):
    """Vide un paragraphe et y insère l'image, centrée et dimensionnée pour
    tenir sur la page annexe (paysage)."""
    for r in list(paragraph.runs):
        r._element.getparent().remove(r._element)
    paragraph.alignment = 1  # centré
    pf = paragraph.paragraph_format
    pf.space_before = Pt(0)
    pf.space_after = Pt(0)
    width_cm, height_cm = _fit_image_size(image_path)
    run = paragraph.add_run()
    if height_cm:
        run.add_picture(image_path, width=Cm(width_cm), height=Cm(height_cm))
    else:
        run.add_picture(image_path, width=Cm(width_cm))


def _remove_following_spacers(paragraph):
    """Supprime les paragraphes espaceurs juste après `paragraph` (jusqu'au
    prochain paragraphe non vide ou porteur d'un saut de section)."""
    nxt = paragraph._p.getnext()
    while nxt is not None and nxt.tag == qn('w:p') and _is_spacer_paragraph(nxt):
        to_remove = nxt
        nxt = nxt.getnext()
        to_remove.getparent().remove(to_remove)


def _set_page_break_before(p_el):
    pPr = p_el.find(qn('w:pPr'))
    if pPr is None:
        pPr = p_el.makeelement(qn('w:pPr'), {})
        p_el.insert(0, pPr)
    if pPr.find(qn('w:pageBreakBefore')) is None:
        pPr.append(pPr.makeelement(qn('w:pageBreakBefore'), {}))


def _new_centered_image_paragraph(doc, image_path):
    """Crée un paragraphe (ajouté en fin de corps) contenant l'image centrée et
    dimensionnée. Le paragraphe est ensuite déplacé au bon endroit par l'appelant."""
    p = doc.add_paragraph()
    _put_image_in_paragraph(p, image_path)
    return p._p


def insert_annexe_images(doc, title_contains, placeholder_text, image_paths):
    """Insère 1..N images dans une annexe. La 1re remplace le paragraphe
    placeholder ('[Carte ...]') sous le titre existant ; chaque image
    supplémentaire est placée sur SA PROPRE page (saut de page) au sein de la
    même section paysage, précédée d'une copie du bandeau-titre de l'annexe.

    `image_paths` : liste de chemins d'images (peut être vide -> ne fait rien)."""
    image_paths = [p for p in (image_paths or []) if p]
    if not image_paths:
        return False

    title_p = None
    placeholder = None
    for p in doc.paragraphs:
        if title_p is None and title_contains in p.text:
            title_p = p
        if placeholder_text in p.text:
            placeholder = p
            break
    if placeholder is None:
        return False

    # 1re image dans le placeholder, puis nettoyage des espaceurs réservés
    _put_image_in_paragraph(placeholder, image_paths[0])
    _remove_following_spacers(placeholder)

    anchor = placeholder._p
    for img in image_paths[1:]:
        if title_p is not None:
            title_clone = copy.deepcopy(title_p._p)
            _set_page_break_before(title_clone)  # nouvelle page pour cette image
            anchor.addnext(title_clone)
            anchor = title_clone
            img_p = _new_centered_image_paragraph(doc, img)
        else:
            img_p = _new_centered_image_paragraph(doc, img)
            _set_page_break_before(img_p)
        anchor.addnext(img_p)
        anchor = img_p
    return True


def insert_annexe_image(doc, placeholder_text, image_path):
    """Compat : insertion d'une image unique (annexe à une seule carte)."""
    return insert_annexe_images(doc, "", placeholder_text, [image_path] if image_path else [])


# ---------------------------------------------------------------------------
# En-tête (Réf dossier / Fiche mission n° / type mission)
# ---------------------------------------------------------------------------

def set_footer_note(doc, note):
    """Inscrit une mention discrète (petite, grisée, centrée) en pied de page,
    p.ex. la source des données INSEE. Les pieds de page des sections annexes
    étant liés au premier, une seule écriture suffit."""
    if not note:
        return
    for section in doc.sections:
        footer = section.footer
        footer.is_linked_to_previous = False
        p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
        for r in list(p.runs):
            r._element.getparent().remove(r._element)
        p.alignment = 1  # centré
        run = p.add_run(note)
        run.font.size = Pt(7)
        run.font.color.rgb = RGBColor(0x80, 0x80, 0x80)
        run.italic = True


def fill_header(doc, ref_dossier, fiche_mission_n, type_mission):
    for section in doc.sections:
        header = section.header
        if not header.tables:
            continue
        t = header.tables[0]
        set_value(t, "Réf. Dossier METEOR", ref_dossier)
        set_value(t, "Fiche mission n°", fiche_mission_n)
        # 3e colonne (type de mission) répétée sur les 2 lignes
        for r in t.rows:
            if len(r.cells) >= 3:
                set_cell_text(r.cells[2], type_mission)


# ---------------------------------------------------------------------------
# Fonction principale
# ---------------------------------------------------------------------------

def build_docx(data, out_path):
    doc = docx.Document(TEMPLATE_PATH)

    fill_header(doc, data["ref_dossier"], data["fiche_mission_n"], data["type_mission"])
    set_footer_note(doc, data.get("insee_footer_note", ""))

    t0, t1, t2, t3, t4 = doc.tables[0], doc.tables[1], doc.tables[2], doc.tables[3], doc.tables[4]

    # --- Section A ---
    fill_parcelles(t0, data["parcelles"])
    set_value(t0, "Dates souhaitées", data["dates_vols"])
    fill_pilotes(t0, data["pilotes"])
    set_value(t0, "Mode de vol", data.get("mode_vol", "VLOS"))

    # --- Section B ---
    set_value(t1, "Noms des fichiers kml joints", data["kml_noms"])
    check_option(t1, "Les fichiers kml contiennent", "Oui" if data["kml_conforme_manex"] else "Non")
    # NB : la valeur numérique de densité GRC n'est plus saisie dans le tableau
    # (le libellé "Données quantitatives : Voir Annexe 1." est fixe) ; seule la
    # catégorie est cochée, sur 5 options désormais (jusqu'à iGRC 5).
    check_option_multirow(t1, "Densité de population maximale", 4, data["densite_grc_categorie"])
    fill_mitigations_grid(t1, data["parcelles"], data.get("mitigations_par_parcelle", []))
    # NB : le champ "GRC final" a été retiré de cette version du template
    # (les mitigations sont désormais par parcelle ; à confirmer avec Kabe s'il
    # faut réintroduire un GRC final par parcelle ou global).
    annexe1_paths = [p for p in data.get("annexe1_image_paths", []) if p]
    check_option(t1, "Annexe 1", "Oui" if annexe1_paths else "Non")
    set_value(t1, "Remarques", data.get("remarques_sol", ""))

    # --- Section C ---
    # Hauteur de vol max. et ARC final : toujours présents. Si l'utilisateur ne
    # renseigne rien, on garde les valeurs par défaut demandées (40 m AGL / ARC-B).
    set_value(t2, "Hauteur de vol maximale", data.get("hauteur_vol_max", "").strip() or "40 m AGL")
    set_value(t2, "ARC final associé", data.get("arc_final", "").strip() or "ARC-B")
    check_option(t2, "Proximité d’un aérodrome", "Oui" if data["proximite_aerodrome"] else "Non")
    row_aerodrome = find_row(t2, "Proximité d’un aérodrome")
    set_inline_suffix_in_row(row_aerodrome, "Si oui, Nom", data.get("nom_aerodrome", "") if data["proximite_aerodrome"] else "")

    check_option(t2, "Opération au sein d’une CTR", "Oui" if data["operation_ctr"] else "Non")
    row_ctr = find_row(t2, "Opération au sein d’une CTR")
    set_inline_suffix_in_row(row_ctr, "Si oui, Nom", data.get("nom_ctr", "") if data["operation_ctr"] else "")
    check_option(t2, "Accords/protocoles", "Oui" if data["accords_joints"] else "Non")
    annexe2_fournie = bool(data.get("annexe2_image_path"))
    check_option(t2, "Annexe 2", "Oui" if annexe2_fournie else "Non")
    set_value(t2, "Remarques", data.get("remarques_air", ""))

    # --- Section D ---
    fill_confinement_grid(t3, data["parcelles"], data.get("densite_5km_par_parcelle", []))
    check_option_multirow(t3, "Densité moyenne dans un rayon de 5 km", 7, data["densite_5km_categorie"])
    check_option(t3, "Rassemblement de personnes", data["rassemblement_1km"])

    # --- Annexes ---
    # Annexe 1 : une ou plusieurs cartes de densité, chacune sur sa page paysage.
    insert_annexe_images(doc, "ANNEXE 1", "[Carte Géoportail", annexe1_paths)
    # Annexe 2 : carte OACI VFR (une seule image).
    if data.get("annexe2_image_path"):
        insert_annexe_images(doc, "ANNEXE 2", "[Carte OACI", [data["annexe2_image_path"]])

    doc.save(out_path)
    return out_path
