"""
Génère la vue PDF "humainement lisible" d'une facture à partir de sa
structure JSON, destinée à être fusionnée avec le XML CII pour produire
le fichier Factur-X final.

Usage :
    python generate_facture_pdf.py facture_exemple.json facture_visuelle.pdf
"""

import json
import os
import sys
import urllib.request
from datetime import datetime
from io import BytesIO

from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm, cm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import (
    SimpleDocTemplate,
    Paragraph,
    Spacer,
    Table,
    TableStyle,
    Image,
)

# PDF/A-3 interdit les polices standard non embarquées (Helvetica, Times...).
# On enregistre donc une police TTF réelle, qui sera embarquée en sous-ensemble
# automatiquement par reportlab.
#
# Le chemin d'installation de DejaVu Sans varie selon la distribution : on
# cherche donc parmi plusieurs emplacements connus plutôt que de coder un
# chemin unique en dur (ce qui casserait le script sur toute distribution
# différente de celle utilisée au moment de l'écriture).
CHEMINS_CANDIDATS_POLICE = [
    # Debian / Ubuntu (paquet fonts-dejavu-core)
    ("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
     "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
    # CentOS / RHEL / Fedora (paquet dejavu-sans-fonts)
    ("/usr/share/fonts/dejavu/DejaVuSans.ttf",
     "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf"),
    ("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
     "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf"),
    # Alpine Linux (paquet font-dejavu)
    ("/usr/share/fonts/dejavu/DejaVuSans.ttf",
     "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf"),
    # macOS (Homebrew, paquet font-dejavu-sans)
    ("/opt/homebrew/share/fonts/dejavu/DejaVuSans.ttf",
     "/opt/homebrew/share/fonts/dejavu/DejaVuSans-Bold.ttf"),
]


def localiser_police_dejavu():
    """Recherche DejaVu Sans parmi plusieurs emplacements connus selon la
    distribution. Lève une erreur explicite (plutôt qu'un retour silencieux
    à Helvetica non embarquée) si la police est introuvable partout."""

    for chemin_normal, chemin_gras in CHEMINS_CANDIDATS_POLICE:
        if os.path.isfile(chemin_normal) and os.path.isfile(chemin_gras):
            return chemin_normal, chemin_gras

    raise FileNotFoundError(
        "Police DejaVu Sans introuvable sur ce système (chemins testés : "
        + ", ".join(c[0] for c in CHEMINS_CANDIDATS_POLICE) + "). "
        "PDF/A-3 exige des polices embarquées : installez le paquet "
        "correspondant à votre distribution avant de continuer, par exemple :\n"
        "  Debian/Ubuntu : apt-get install fonts-dejavu-core\n"
        "  CentOS/RHEL   : yum install dejavu-sans-fonts\n"
        "  Alpine        : apk add font-dejavu\n"
        "Puis vérifiez le chemin réel avec 'rpm -ql dejavu-sans-fonts' (CentOS) "
        "ou 'dpkg -L fonts-dejavu-core' (Debian/Ubuntu), et ajoutez-le à "
        "CHEMINS_CANDIDATS_POLICE si besoin."
    )


CHEMIN_POLICE, CHEMIN_POLICE_GRAS = localiser_police_dejavu()

pdfmetrics.registerFont(TTFont("DejaVuSans", CHEMIN_POLICE))
pdfmetrics.registerFont(TTFont("DejaVuSans-Bold", CHEMIN_POLICE_GRAS))

# reportlab.rl_config.canvas_basefontname (ci-dessous) ne couvre pas tous les
# cas : certains mécanismes internes de Platypus (ex. une cellule de Table
# contenant une liste de flowables, comme le bloc "Facturé à") émettent un
# BT/ET de service avec la police "Helvetica" codée en dur, indépendamment de
# ce réglage. Un diagnostic PDF/A-3 (police non embarquée /F3 -> /Helvetica)
# l'a confirmé sur un cas réel. Plutôt que de traquer chaque occurrence, on
# réenregistre directement les noms standard "Helvetica"/"Helvetica-Bold"
# pour qu'ils pointent vers notre police TTF embarquée : tout usage interne
# de ces noms, repéré ou non, embarque alors la bonne police.
pdfmetrics.registerFont(TTFont("Helvetica", CHEMIN_POLICE))
pdfmetrics.registerFont(TTFont("Helvetica-Bold", CHEMIN_POLICE_GRAS))

# reportlab initialise chaque bloc de texte du canvas avec une police par
# défaut (Helvetica) avant même que Platypus n'applique le style réel. Cette
# police reste alors déclarée dans les ressources de la page sans être
# embarquée, ce qui fait échouer la validation PDF/A-3. On force donc la
# police par défaut du canvas vers notre police TTF embarquée.
import reportlab.rl_config
reportlab.rl_config.canvas_basefontname = "DejaVuSans"


def charger_facture(chemin_json: str) -> dict:
    with open(chemin_json, "r", encoding="utf-8") as f:
        return json.load(f)


def format_date_fr(date_iso):
    """Convertit une date ISO (AAAA-MM-JJ) en format français JJ/MM/AAAA.
    Renvoie la valeur d'origine telle quelle si elle est vide ou ne suit
    pas le format ISO attendu, plutôt que de faire planter la génération
    du PDF pour un simple problème d'affichage."""
    if not date_iso:
        return date_iso
    try:
        return datetime.strptime(date_iso, "%Y-%m-%d").strftime("%d/%m/%Y")
    except ValueError:
        return date_iso


def fmt_montant(valeur):
    """Formate un montant en euros. Un montant absent/null (ex. TVA sur une
    facture hors champ) est traité comme 0 plutôt que de faire planter la
    génération du PDF. Accepte aussi bien un nombre qu'une chaîne numérique
    ("10.00"), cas rencontré en pratique selon la source du JSON."""
    return f'{float(valeur if valeur is not None else 0):.2f} \u20ac'


def fmt_taux(valeur):
    """Formate un taux de TVA. Un taux absent/null (catégorie 'Hors champ de
    TVA' ou 'Exonéré', où BT-152 n'est pas censé être renseigné) s'affiche
    comme un tiret plutôt que de faire planter la génération du PDF. Accepte
    aussi bien un nombre qu'une chaîne numérique."""
    return f'{float(valeur):.1f} %' if valeur is not None else '—'


def fmt_nombre_fr(valeur, decimales):
    """Format à la française (virgule décimale) pour les textes destinés à
    la lecture humaine (mentions légales) -- distinct de fmt_montant qui
    garde le point décimal, utilisé pour les colonnes de tableau. Accepte
    aussi bien un nombre qu'une chaîne numérique."""
    return f'{float(valeur):.{decimales}f}'.replace(".", ",")


def construire_mentions_legales(conditions_paiement, mentions_personnalisees):
    """Construit les mentions PMT (indemnité forfaitaire), PMD (pénalités de
    retard) et AAB (escompte) directement à partir des champs structurés de
    conditions_paiement, en miroir exact de la logique PHP côté XML : cela
    évite qu'un même montant existe à deux endroits du JSON (le champ
    structuré et un texte libre pré-rédigé) qui pourraient diverger sans
    qu'aucune validation ne le détecte. Toute mention personnalisée (ex.
    'AAI') passe telle quelle ; un éventuel PMT/PMD/AAB fourni manuellement
    dans mentions_legales est ignoré au profit de la version générée."""
    mentions = []

    penalites = conditions_paiement.get("penalites_retard") or {}
    if penalites.get("indemnite_forfaitaire_recouvrement"):
        mentions.append({
            "code": "PMT",
            "texte": (
                f'En cas de retard de paiement, une indemnit\u00e9 forfaitaire de '
                f'{fmt_nombre_fr(penalites["indemnite_forfaitaire_recouvrement"], 2)} EUR pour frais de '
                f'recouvrement sera exigible (art. L441-10 et D441-5 du Code de commerce).'
            ),
        })
    if penalites.get("taux"):
        mentions.append({
            "code": "PMD",
            "texte": (
                f'Tout retard de paiement entra\u00eenera l\u2019application d\u2019une p\u00e9nalit\u00e9 au '
                f'taux de {fmt_nombre_fr(penalites["taux"], 1)} % l\u2019an, sans qu\u2019un rappel soit '
                f'n\u00e9cessaire (art. L441-10 du Code de commerce).'
            ),
        })

    escompte = conditions_paiement.get("escompte_paiement_anticipe") or {}
    if escompte.get("taux"):
        mentions.append({
            "code": "AAB",
            "texte": (
                f'Escompte de {fmt_nombre_fr(escompte["taux"], 1)} % accord\u00e9 pour paiement sous '
                f'{escompte.get("delai_jours", "")} jours.'
            ),
        })
    else:
        mentions.append({"code": "AAB", "texte": "Pas d'escompte pour paiement anticipé."})

    for mention in mentions_personnalisees:
        if mention.get("code") not in ("PMT", "PMD", "AAB"):
            mentions.append(mention)

    return mentions


def ajouter_contact(bloc, contact, style):
    """Ajoute le nom, le téléphone et l'email d'un contact sur des lignes
    séparées (une Paragraph par champ renseigné), plutôt qu'une seule ligne
    combinée. Ne fait rien si aucun des trois champs n'est renseigné."""
    contact = contact or {}
    if contact.get("nom"):
        bloc.append(Paragraph(f'Contact : {contact["nom"]}', style))
    if contact.get("telephone"):
        bloc.append(Paragraph(f'T\u00e9l. : {contact["telephone"]}', style))
    if contact.get("email"):
        bloc.append(Paragraph(f'Email : {contact["email"]}', style))


def telecharger_logo(url, largeur_max=5 * cm, hauteur_max=5 * cm):
    """Télécharge le logo depuis l'URL fournie et renvoie un flowable Image
    dimensionné pour tenir dans un carré de 5x5cm (proportions conservées).
    Renvoie None en cas d'échec (URL injoignable, format non supporté...)
    plutôt que de faire échouer toute la génération du PDF pour un logo
    optionnel : mieux vaut une facture sans logo qu'une facture bloquée."""
    try:
        with urllib.request.urlopen(url, timeout=10) as reponse:
            donnees = reponse.read()
    except Exception as erreur:
        print(f"Avertissement : logo non téléchargé ({url}) : {erreur}", file=sys.stderr)
        return None

    try:
        image = Image(BytesIO(donnees))
    except Exception as erreur:
        print(f"Avertissement : logo illisible ({url}) : {erreur}", file=sys.stderr)
        return None

    largeur_originale, hauteur_originale = image.imageWidth, image.imageHeight
    if not largeur_originale or not hauteur_originale:
        return None

    echelle = min(largeur_max / largeur_originale, hauteur_max / hauteur_originale)
    image.drawWidth = largeur_originale * echelle
    image.drawHeight = hauteur_originale * echelle
    image.hAlign = "LEFT"
    return image


def construire_pdf(facture: dict, chemin_sortie: str) -> None:
    styles = getSampleStyleSheet()

    style_normal = ParagraphStyle(
        "NormalFx", parent=styles["Normal"], fontName="DejaVuSans"
    )
    style_normal_gras = ParagraphStyle(
        "NormalGras", parent=style_normal, fontName="DejaVuSans-Bold"
    )
    style_petit = ParagraphStyle(
        "Petit", parent=style_normal, fontSize=8, textColor=colors.grey
    )

    doc = SimpleDocTemplate(
        chemin_sortie,
        pagesize=A4,
        topMargin=15 * mm,
        bottomMargin=15 * mm,
        leftMargin=18 * mm,
        rightMargin=18 * mm,
    )

    style_doc_ref = ParagraphStyle(
        "DocRef", parent=style_normal_gras, fontSize=12, alignment=2  # TA_RIGHT
    )
    style_nom_vendeur = ParagraphStyle(
        "NomVendeur", parent=style_normal_gras, fontSize=13
    )

    story = []

    # -----------------------------------------------------------------
    # Ligne 1 : logo (haut gauche) + "Facture N°... du ..." (haut droit)
    # -----------------------------------------------------------------
    vendeur = facture["vendeur"]
    entete_facture = facture["facture"]

    # BT-3 (type_document) pilote le libellé du document sur le visuel PDF.
    # 381 = avoir/note de crédit, 384 = facture rectificative. On garde
    # "FACTURE" par défaut pour tout autre code (380 notamment).
    LIBELLES_TYPE_DOCUMENT = {
        "381": "AVOIR",
        "384": "FACTURE RECTIFICATIVE",
    }
    type_document = str(entete_facture.get("type_document", "380"))
    libelle_document = LIBELLES_TYPE_DOCUMENT.get(type_document, "FACTURE")
    est_document_de_reference = type_document in LIBELLES_TYPE_DOCUMENT

    # Logo (urlLogo), limité à 5x5cm. Absent silencieusement si le champ
    # n'est pas renseigné ou si le téléchargement échoue.
    logo = None
    if facture.get("urlLogo"):
        logo = telecharger_logo(facture["urlLogo"])

    texte_doc_ref = (
        f'{libelle_document.capitalize()} N° {entete_facture["numero"]} '
        f'du {format_date_fr(entete_facture["date_emission"])}'
    )

    table_logo_ref = Table(
        [[logo if logo is not None else "", Paragraph(texte_doc_ref, style_doc_ref)]],
        colWidths=[85 * mm, 85 * mm],
    )
    table_logo_ref.setStyle(
        TableStyle(
            [
                ("VALIGN", (0, 0), (-1, -1), "TOP"),
                ("ALIGN", (0, 0), (0, 0), "LEFT"),
                ("ALIGN", (1, 0), (1, 0), "RIGHT"),
            ]
        )
    )
    story.append(table_logo_ref)
    story.append(Spacer(1, 8 * mm))

    # -----------------------------------------------------------------
    # Ligne 2 : à gauche, vendeur + références documentaires ; à droite,
    # acheteur.
    # -----------------------------------------------------------------

    # --- Bloc gauche, partie A : coordonnées du vendeur ---
    bloc_gauche = [Paragraph(vendeur["nom"], style_nom_vendeur)]

    # BT-28 : nom commercial, affiché seulement s'il diffère de la raison
    # sociale légale déjà utilisée juste au-dessus.
    if vendeur.get("nom_commercial"):
        bloc_gauche.append(Paragraph(vendeur["nom_commercial"], style_normal_gras))

    adresse_v = vendeur["adresse"]
    bloc_gauche.append(
        Paragraph(
            f'{adresse_v["ligne1"]} — {adresse_v["code_postal"]} '
            f'{adresse_v["ville"]}, {adresse_v["pays"]}',
            style_normal,
        )
    )

    bloc_gauche.append(Paragraph(f'SIREN : {vendeur["siren"]}', style_normal))
    if vendeur.get("numero_tva_intra"):
        bloc_gauche.append(Paragraph(f'TVA intra : {vendeur["numero_tva_intra"]}', style_normal))

    # BG-11/BT-62/BT-63 : représentant fiscal du vendeur, affiché seulement
    # s'il est renseigné. Coexiste avec la TVA intra propre du vendeur
    # ci-dessus (ce n'est pas une alternative exclusive).
    representant_fiscal = vendeur.get("representant_fiscal") or {}
    if representant_fiscal.get("nom"):
        bloc_gauche.append(
            Paragraph(f'Repr\u00e9sentant fiscal : {representant_fiscal["nom"]}', style_normal)
        )
        if representant_fiscal.get("numero_tva_intra"):
            bloc_gauche.append(
                Paragraph(f'TVA : {representant_fiscal["numero_tva_intra"]}', style_normal)
            )

        adresse_rf = representant_fiscal.get("adresse") or {}
        if adresse_rf.get("ligne1"):
            bloc_gauche.append(Paragraph(adresse_rf["ligne1"], style_normal))
            bloc_gauche.append(
                Paragraph(
                    f'{adresse_rf.get("code_postal", "")} '
                    f'{adresse_rf.get("ville", "")}, {adresse_rf.get("pays", "")}',
                    style_normal,
                )
            )

    # BT-41/BT-42/BT-43 : contact vendeur, sur des lignes séparées, affiché
    # seulement si au moins un des trois champs est renseigné.
    ajouter_contact(bloc_gauche, vendeur.get("contact"), style_normal)

    bloc_gauche.append(Spacer(1, 5 * mm))

    # --- Bloc gauche, partie B : références documentaires (réf. commande
    # côté vendeur, échéance, période de facturation, réf. facture
    # d'origine pour un avoir/rectificatif) ---
    acheteur = facture["acheteur"]
    adresse_a = acheteur["adresse"]

    # BT-14 : référence de la commande de vente, propre au vendeur (son
    # propre numéro d'ordre de vente interne, distinct du n° de commande
    # transmis par l'acheteur qui est BT-13, affiché dans le bloc droit).
    if entete_facture.get("reference_commande_vendeur"):
        bloc_gauche.append(
            Paragraph(f'R\u00e9f. commande : {entete_facture["reference_commande_vendeur"]}', style_normal)
        )

    bloc_gauche.append(
        Paragraph(
            f'Date d\u2019\u00e9ch\u00e9ance : {format_date_fr(facture["conditions_paiement"]["date_echeance"])}',
            style_normal,
        )
    )

    # BT-73/BT-74 : période de facturation de toute la facture, affichée
    # seulement si au moins une des deux dates est renseignée.
    periode_facture = entete_facture.get("periode_facturation") or {}
    if periode_facture.get("debut") or periode_facture.get("fin"):
        debut = format_date_fr(periode_facture.get("debut")) or "?"
        fin = format_date_fr(periode_facture.get("fin")) or "?"
        bloc_gauche.append(Paragraph(f'P\u00e9riode : du {debut} au {fin}', style_normal))

    # Référence à la facture annulée/corrigée (BT-25/BT-26), à afficher sur
    # le visuel pour un avoir ou une facture rectificative — même logique
    # que le bloc InvoiceReferencedDocument ajouté côté XML CII.
    LIBELLES_REFERENCE_ORIGINE = {
        "381": "Avoir relatif à la facture n° {numero}",
        "384": "Corrige la facture n° {numero}",
    }
    reference_origine = entete_facture.get("reference_facture_origine")
    if est_document_de_reference and reference_origine:
        gabarit = LIBELLES_REFERENCE_ORIGINE.get(type_document, "Réf. facture n° {numero}")
        texte_reference = gabarit.format(numero=reference_origine["numero"])
        if reference_origine.get("date"):
            texte_reference += f' du {format_date_fr(reference_origine["date"])}'
        bloc_gauche.append(Paragraph(texte_reference, style_normal_gras))

    # --- Bloc droit : coordonnées de l'acheteur ---
    bloc_droit = [
        Paragraph("Facturé à", style_normal_gras),
        Paragraph(acheteur["nom"], style_normal),
        Paragraph(
            f'{adresse_a["ligne1"]} — {adresse_a["code_postal"]} {adresse_a["ville"]}',
            style_normal,
        ),
        Paragraph(f'SIREN : {acheteur["siren"]}', style_normal),
    ]
    if acheteur.get("numero_tva_intra"):
        bloc_droit.append(Paragraph(f'TVA intra : {acheteur["numero_tva_intra"]}', style_normal))

    # BT-13 : référence de la commande transmise par l'acheteur, affichée
    # seulement si renseignée.
    if entete_facture.get("reference_commande_acheteur"):
        bloc_droit.append(
            Paragraph(f'R\u00e9f. commande : {entete_facture["reference_commande_acheteur"]}', style_normal)
        )

    # BT-10 : référence imposée par l'acheteur (n° de service exécutant,
    # engagement juridique...), affichée seulement si renseignée.
    if entete_facture.get("reference_acheteur"):
        bloc_droit.append(
            Paragraph(f'R\u00e9f. acheteur : {entete_facture["reference_acheteur"]}', style_normal)
        )

    # BT-56/BT-57/BT-58 : contact acheteur, sur des lignes séparées, affiché
    # seulement si au moins un des trois champs est renseigné.
    ajouter_contact(bloc_droit, acheteur.get("contact"), style_normal)

    table_entete = Table(
        [[bloc_gauche, bloc_droit]],
        colWidths=[85 * mm, 85 * mm],
    )
    table_entete.setStyle(
        TableStyle(
            [
                ("VALIGN", (0, 0), (-1, -1), "TOP"),
                ("FONTNAME", (0, 0), (-1, -1), "DejaVuSans"),
            ]
        )
    )
    story.append(table_entete)
    story.append(Spacer(1, 8 * mm))

    # -----------------------------------------------------------------
    # Tableau des lignes de facture
    # -----------------------------------------------------------------
    en_tetes = ["Description", "Qté", "Unité", "PU HT", "Remise", "TVA", "Total HT"]
    lignes_tableau = [en_tetes]

    style_cellule = ParagraphStyle(
        "CelluleTableau",
        parent=style_normal,
        fontSize=9,
        leading=11,
    )

    for ligne in facture["lignes"]:
        remise = float(ligne.get("remise_ligne") or 0)
        texte_remise = f'-{remise:.2f} \u20ac' if remise else '—'

        # BT-134/BT-135 : période de facturation de la ligne, affichée en
        # sous-texte de la description seulement si renseignée sur cette
        # ligne (certaines lignes peuvent en avoir une et d'autres non).
        # BT-153 : nom de l'article, obligatoire. Validation explicite plutôt
        # qu'un KeyError brut, pour bien signaler aux JSON pas encore migrés
        # (l'ancien nom du champ était 'description') qu'il s'agit d'un
        # renommage à faire, pas d'un bug.
        if not ligne.get("nomArticle"):
            raise ValueError(
                f'nomArticle manquant pour la ligne id={ligne.get("id")} : obligatoire (BT-153). '
                f"Ce champ s'appelait 'description' avant migration -- vérifiez que votre JSON "
                f"source a bien été mis à jour."
            )
        texte_description = ligne["nomArticle"]

        # BT-155 : identifiant article du vendeur, optionnel, affiché en
        # sous-texte lorsqu'il est renseigné.
        if ligne.get("codeArticle"):
            texte_description += (
                f'<br/><font size="8" color="#666666">R\u00e9f. article : '
                f'{ligne["codeArticle"]}</font>'
            )

        periode_ligne = ligne.get("periode") or {}
        if periode_ligne.get("debut") or periode_ligne.get("fin"):
            debut = format_date_fr(periode_ligne.get("debut")) or "?"
            fin = format_date_fr(periode_ligne.get("fin")) or "?"
            texte_description += f'<br/><font size="8" color="#666666">P\u00e9riode : du {debut} au {fin}</font>'

        lignes_tableau.append(
            [
                Paragraph(texte_description, style_cellule),
                f'{float(ligne["quantite"]):g}',
                ligne["unite"],
                fmt_montant(ligne["prix_unitaire_ht"]),
                texte_remise,
                fmt_taux(ligne["taux_tva"]),
                fmt_montant(ligne["montant_ht"]),
            ]
        )

    table_lignes = Table(
        lignes_tableau,
        colWidths=[58 * mm, 12 * mm, 16 * mm, 22 * mm, 20 * mm, 16 * mm, 24 * mm],
        repeatRows=1,
    )
    table_lignes.setStyle(
        TableStyle(
            [
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2b2b2b")),
                ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                ("FONTNAME", (0, 0), (-1, 0), "DejaVuSans-Bold"),
                ("FONTNAME", (0, 1), (-1, -1), "DejaVuSans"),
                ("FONTSIZE", (0, 0), (-1, -1), 9),
                ("ALIGN", (1, 0), (-1, -1), "CENTER"),
                ("ALIGN", (0, 0), (0, -1), "LEFT"),
                ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#cccccc")),
                ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f7f7f7")]),
                ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
                ("TOPPADDING", (0, 0), (-1, -1), 5),
                ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
            ]
        )
    )
    story.append(table_lignes)
    story.append(Spacer(1, 6 * mm))

    # -----------------------------------------------------------------
    # Totaux
    # -----------------------------------------------------------------
    style_totaux_libelle = ParagraphStyle(
        "TotalLibelle", parent=style_normal, fontSize=9, leading=11
    )
    style_totaux_valeur = ParagraphStyle(
        "TotalValeur",
        parent=style_normal,
        fontSize=9,
        leading=11,
        alignment=2,  # TA_RIGHT — évite de dépendre de reportlab.lib.enums
    )
    style_totaux_libelle_gras = ParagraphStyle(
        "TotalLibelleGras", parent=style_totaux_libelle, fontName="DejaVuSans-Bold"
    )
    style_totaux_valeur_gras = ParagraphStyle(
        "TotalValeurGras", parent=style_totaux_valeur, fontName="DejaVuSans-Bold"
    )

    totaux = facture["totaux"]
    lignes_totaux = [
        [
            Paragraph("Total HT", style_totaux_libelle),
            Paragraph(fmt_montant(totaux["total_ht"]), style_totaux_valeur),
        ],
        [
            Paragraph("Total TVA", style_totaux_libelle),
            Paragraph(fmt_montant(totaux["total_tva"]), style_totaux_valeur),
        ],
        [
            Paragraph("Total TTC", style_totaux_libelle),
            Paragraph(fmt_montant(totaux["total_ttc"]), style_totaux_valeur),
        ],
    ]
    if totaux.get("acompte_deja_verse"):
        lignes_totaux.append(
            [
                Paragraph("Acompte déjà versé", style_totaux_libelle),
                Paragraph(f'-{fmt_montant(totaux["acompte_deja_verse"])}', style_totaux_valeur),
            ]
        )
    lignes_totaux.append(
        [
            Paragraph("Net à payer", style_totaux_libelle_gras),
            Paragraph(fmt_montant(totaux["montant_a_payer"]), style_totaux_valeur_gras),
        ]
    )

    table_totaux = Table(lignes_totaux, colWidths=[40 * mm, 30 * mm], hAlign="RIGHT")
    table_totaux.setStyle(
        TableStyle(
            [
                ("LINEABOVE", (0, -1), (-1, -1), 0.8, colors.black),
                ("TOPPADDING", (0, 0), (-1, -1), 3),
                ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
            ]
        )
    )
    story.append(table_totaux)
    story.append(Spacer(1, 8 * mm))

    # -----------------------------------------------------------------
    # Ventilation de la TVA (BG-23) : obligatoire dès qu'il y a plusieurs
    # taux/catégories de TVA sur la même facture (mention légale classique,
    # au-delà même de l'exigence Factur-X) -- redondant avec la ligne
    # "Total TVA" ci-dessus s'il n'y a qu'une seule entrée, donc omis dans
    # ce cas pour ne pas surcharger le PDF. Placé sous le récapitulatif des
    # totaux, aligné à gauche (contrairement au tableau des totaux, aligné
    # à droite).
    # -----------------------------------------------------------------
    ventilation_tva = facture.get("ventilation_tva") or []
    if len(ventilation_tva) > 1:
        entetes_ventilation = ["Catégorie", "Taux", "Base HT", "Montant TVA"]
        lignes_ventilation = [entetes_ventilation]
        for ventilation in ventilation_tva:
            lignes_ventilation.append(
                [
                    ventilation.get("categorie_tva", ""),
                    fmt_taux(ventilation.get("taux")),
                    fmt_montant(ventilation.get("base_ht")),
                    fmt_montant(ventilation.get("montant_tva")),
                ]
            )

        table_ventilation = Table(
            lignes_ventilation,
            colWidths=[30 * mm, 25 * mm, 30 * mm, 35 * mm],
            hAlign="LEFT",
        )
        table_ventilation.setStyle(
            TableStyle(
                [
                    ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2b2b2b")),
                    ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                    ("FONTNAME", (0, 0), (-1, 0), "DejaVuSans-Bold"),
                    ("FONTNAME", (0, 1), (-1, -1), "DejaVuSans"),
                    ("FONTSIZE", (0, 0), (-1, -1), 9),
                    ("ALIGN", (0, 0), (-1, -1), "CENTER"),
                    ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#cccccc")),
                    ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f7f7f7")]),
                    ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
                    ("TOPPADDING", (0, 0), (-1, -1), 4),
                    ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
                ]
            )
        )
        story.append(table_ventilation)
        story.append(Spacer(1, 6 * mm))

    # -----------------------------------------------------------------
    # Conditions de paiement, mentions légales, IBAN
    # -----------------------------------------------------------------
    conditions = facture["conditions_paiement"]
    story.append(Paragraph(conditions.get("description", ""), style_normal))
    story.append(Spacer(1, 2 * mm))

    # BG-10 : bénéficiaire du paiement, uniquement s'il diffère du vendeur
    # (ex. affacturage). BT-59 (nom) est le seul champ vraiment essentiel ;
    # BT-61 (SIREN) est accessoire, pas affiché ici pour rester lisible.
    beneficiaire = facture.get("beneficiaire") or {}
    if beneficiaire.get("nom_beneficiaire"):
        story.append(
            Paragraph(
                f'B\u00e9n\u00e9ficiaire du paiement : {beneficiaire["nom_beneficiaire"]}',
                style_normal_gras,
            )
        )
        story.append(Spacer(1, 2 * mm))

    if vendeur.get("iban"):
        ligne_iban = f'IBAN : {vendeur["iban"]} — BIC : {vendeur.get("bic", "")}'
        # BT-85 : nom du titulaire du compte, si différent de la raison
        # sociale du vendeur (ex. compte détenu par un tiers encaisseur).
        if vendeur.get("nom_titulaire_compte"):
            ligne_iban += f' — Titulaire : {vendeur["nom_titulaire_compte"]}'
        story.append(Paragraph(ligne_iban, style_normal))
        story.append(Spacer(1, 4 * mm))

    # BT-83 : libellé de virement / motif de paiement, affiché seulement
    # s'il est renseigné.
    if conditions.get("libelle_virement"):
        story.append(
            Paragraph(f'Libell\u00e9 de virement : {conditions["libelle_virement"]}', style_normal)
        )
        story.append(Spacer(1, 4 * mm))

    # BT-21/BT-22 : mentions légales, générées à partir des champs
    # structurés de conditions_paiement (PMT/PMD/AAB) et complétées par les
    # mentions personnalisées du JSON (ex. AAI) — même logique que côté XML,
    # pour garantir que le PDF et le XML affichent toujours exactement le
    # même texte.
    mentions_legales = construire_mentions_legales(
        conditions, facture.get("mentions_legales") or []
    )
    for mention in mentions_legales:
        if mention.get("texte"):
            story.append(Paragraph(mention["texte"], style_petit))

    if facture.get("notes"):
        story.append(Spacer(1, 4 * mm))
        story.append(Paragraph(facture["notes"], style_normal))

    doc.build(story)


def main() -> None:
    if len(sys.argv) < 3:
        print(f"Usage : python {sys.argv[0]} <facture.json> <sortie.pdf>")
        sys.exit(1)

    chemin_json, chemin_sortie = sys.argv[1], sys.argv[2]
    facture = charger_facture(chemin_json)
    construire_pdf(facture, chemin_sortie)
    print(f"PDF généré avec succès : {chemin_sortie}")


if __name__ == "__main__":
    main()
