#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Génère cli/doc/cliMajSql.odt : récapitulatif du mode --majsql (cli/cliMajSql2.php).
Produit un ODT natif en écrivant directement le XML ODF, sans conversion HTML.

Usage : python3 cli/genDocCliMajSql.py
Sortie : cli/doc/cliMajSql.odt
"""

import os
import zipfile
from xml.sax.saxutils import escape

OUT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "doc", "cliMajSql.odt")

# ---------------------------------------------------------------------------
# Helpers ODF
# ---------------------------------------------------------------------------

def p(text, style="Corps"):
    return f'<text:p text:style-name="{style}">{escape(text)}</text:p>'

def h(text, level, style):
    return f'<text:h text:style-name="{style}" text:outline-level="{level}">{escape(text)}</text:h>'

def code_p(text):
    # Style monospace ; utiliser des espaces insécables pour préserver l'indentation
    safe = escape(text).replace(" ", " ")
    return f'<text:p text:style-name="Code">{safe}</text:p>'

def list_items(items, style="ListeBullet"):
    xml = f'<text:list text:style-name="L1">'
    for it in items:
        xml += f'<text:list-item><text:p text:style-name="{style}">{escape(it)}</text:p></text:list-item>'
    xml += '</text:list>'
    return xml

def table(headers, rows, col_widths=None):
    ncols = len(headers)
    if col_widths is None:
        col_widths = [4.0] * ncols
    xml = '<table:table table:name="T1" table:style-name="TableauStd">'
    for w in col_widths:
        xml += f'<table:table-column table:style-name="Col{int(w*10)}"/>'
    # en-têtes
    xml += '<table:table-header-rows><table:table-row>'
    for htxt in headers:
        xml += (
            '<table:table-cell table:style-name="CelluleHead" office:value-type="string">'
            f'<text:p text:style-name="CelluleHeadTxt">{escape(htxt)}</text:p>'
            '</table:table-cell>'
        )
    xml += '</table:table-row></table:table-header-rows>'
    # lignes
    for row in rows:
        xml += '<table:table-row>'
        for cell in row:
            xml += (
                '<table:table-cell table:style-name="Cellule" office:value-type="string">'
                f'<text:p text:style-name="CelluleTxt">{escape(cell)}</text:p>'
                '</table:table-cell>'
            )
        xml += '</table:table-row>'
    xml += '</table:table>'
    # paragraphe vide après tableau pour respiration
    xml += p("")
    return xml

# ---------------------------------------------------------------------------
# Contenu du document
# ---------------------------------------------------------------------------

body_parts = []

body_parts.append(h("Script cliMajSql2.php — Mode --majsql", 1, "Titre1"))
body_parts.append(p(
    "Récapitulatif du mode --majsql (mise à jour automatique de la BDD). Le code de "
    "référence est cli/cliMajSql2.php (environ 841 lignes), évolution de l'ancien "
    "cli/cliMajSql.php. Validé sous PHP 7.4 (cible) et PHP 5.6."
))

# Contexte
body_parts.append(h("Contexte", 1, "Titre2"))
body_parts.append(p(
    "Le script disposait historiquement d'un unique mode --maj qui exécute un "
    "fichier SQL fourni en paramètre (--file=... ou stdin) et refuse les fichiers "
    "de plus de 100 lignes sans --force."
))
body_parts.append(p(
    "Le fichier majsql.txt (environ 12 756 lignes, 2 924 commandes SQL) concentre "
    "toutes les évolutions de schéma et de données du projet mais devait être "
    "découpé manuellement pour appliquer uniquement les commandes nouvelles sur "
    "chaque BDD. Ce processus manuel était source d'oublis et d'erreurs."
))

# Objectif
body_parts.append(h("Objectif", 1, "Titre2"))
body_parts.append(p("Le mode --majsql :"))
body_parts.append(list_items([
    "lit automatiquement ./majsql.txt,",
    "détermine l'état courant de la BDD via une table de suivi,",
    "applique uniquement les commandes manquantes (y compris les « trous »),",
    "trace précisément chaque commande appliquée dans la BDD elle-même,",
    "sécurise la première exécution sur une base déjà à jour manuellement.",
]))

# Invocation
body_parts.append(h("Invocation", 1, "Titre2"))
body_parts.append(code_p("php74 cli/cliMajSql2.php --majsql [--start=N] [--simul=1] [--force] [--debug=1|2]"))
body_parts.append(p("Le mode --maj existant (exécution d'un fichier SQL ponctuel) est inchangé."))

# Première utilisation
body_parts.append(h("Première utilisation : reprise de l'existant sans rejouer le SQL", 1, "Titre2"))
body_parts.append(p(
    "À la première exécution sur une base donnée, --start=N est OBLIGATOIRE. Il sert "
    "exactement au cas « la base a déjà été mise à jour manuellement avec l'ancienne "
    "méthode » :"
))
body_parts.append(list_items([
    "N = numéro de ligne de majsql.txt jusqu'auquel les commandes sont considérées comme déjà appliquées.",
    "Toutes les commandes dont la ligne de fin (line_end) est <= N sont enregistrées dans _majsql_version (applied_by='init') SANS être exécutées.",
    "Les commandes au-delà de la ligne N seront exécutées au run suivant (--majsql sans --start).",
]))
body_parts.append(table(
    ["Valeur", "Signification"],
    [
        ["--start=0", "Rien n'a été appliqué -> tout sera exécuté"],
        ["--start=<dernière ligne>", "Base déjà totalement à jour -> rien à exécuter"],
        ["--start=N (intermédiaire)", "Commandes jusqu'à la ligne N déjà appliquées manuellement"],
    ],
    col_widths=[4.0, 12.0],
))
body_parts.append(p(
    "Une fois la table initialisée, repasser --start est refusé (sauf --force, qui "
    "fait TRUNCATE puis ré-initialise — destructif)."
))

# Table de suivi
body_parts.append(h("Table de suivi _majsql_version", 1, "Titre2"))
body_parts.append(p(
    "Créée automatiquement (CREATE TABLE IF NOT EXISTS) dans la BDD courante. Une "
    "ligne par commande, pour un diagnostic fin et la détection de divergences."
))
body_parts.append(table(
    ["Colonne", "Type", "Rôle"],
    [
        ["cmd_index",  "INT (PK)",    "N° de la commande (1..N). L'index 0 est un marqueur d'initialisation."],
        ["cmd_hash",   "VARCHAR(32)", "md5 de la commande SQL normalisée"],
        ["line_start", "INT",         "Ligne de début dans majsql.txt"],
        ["line_end",   "INT",         "Ligne de fin dans majsql.txt"],
        ["applied_at", "DATETIME",    "Horodatage d'insertion"],
        ["applied_by", "VARCHAR(16)", "init (via --start) ou majsql (exécution réelle)"],
    ],
    col_widths=[3.0, 3.0, 10.0],
))

body_parts.append(h("Marqueur d'initialisation (index 0)", 2, "Titre3"))
body_parts.append(p(
    "À l'initialisation, une ligne cmd_index=0 est insérée pour matérialiser « base "
    "initialisée ». Elle distingue « jamais initialisée » de « initialisée à "
    "--start=0 » : sans elle, --start=0 n'insérait aucune ligne et le run suivant "
    "rebouclait indéfiniment sur le garde-fou de première exécution. Le comptage des "
    "commandes réelles et la vérification d'intégrité ignorent cmd_index < 1 "
    "(rétrocompatible avec les bases déjà suivies, qui n'ont que des index >= 1)."
))

# Parseur
body_parts.append(h("Parseur SQL : tokeniseur (parseSqlCommands)", 1, "Titre2"))
body_parts.append(p("Découpe majsql.txt en instructions sur le délimiteur réel, en respectant :"))
body_parts.append(list_items([
    "les littéraux chaîne '...' et \"...\" (avec échappement antislash),",
    "les identifiants entre backticks,",
    "les commentaires -- (ligne), # (ligne) et /* ... */ (bloc),",
    "la directive DELIMITER.",
]))
body_parts.append(p(
    "Les espaces hors chaîne sont normalisés (collapse) : le md5 est stable et "
    "insensible au reformatage. Chaque commande conserve son texte normalisé, ses "
    "bornes de lignes, son contexte (dernier commentaire -- jj-mm-aaaa ...) et un "
    "indicateur unterminated si la dernière instruction n'a pas de délimiteur final."
))
body_parts.append(p("Validation parseur : 2 924 commandes sur le majsql.txt réel, 0 résidu."))

# Exécution incrémentale
body_parts.append(h("Exécution incrémentale (table initialisée, sans --start)", 1, "Titre2"))
body_parts.append(list_items([
    "Chargement de l'ensemble des index déjà enregistrés et de leur hash.",
    "Vérification d'intégrité de TOUTES les commandes enregistrées : hash divergent (fichier modifié au-dessus d'une commande appliquée) ou index hors fichier (majsql.txt tronqué) -> arrêt, sauf --force.",
    "Commandes manquantes = tout index 1..N absent de la table. Cela ré-essaie automatiquement les « trous » laissés par d'anciens échecs --force (pas de reprise sur le seul MAX(cmd_index)).",
    "Exécution une par une ; chaque succès est immédiatement tracé. Si le traçage échoue après une DDL réussie, un avertissement explicite est émis.",
    "Arrêt sur première erreur SQL. Avec --force, la commande échouée n'est pas enregistrée -> ré-essayée au run suivant.",
]))

# Arbre de décision
body_parts.append(h("Arbre de décision majSqlAuto", 1, "Titre2"))
for line in [
    "Table _majsql_version initialisée ? (>= 1 ligne, marqueur index 0 inclus)",
    "  NON (première exécution)",
    "    --start absent   -> ERREUR + aide (arrêt)",
    "    --start=N        -> INIT : marqueur 0 + cmds (line_end <= N) en 'init' (aucun SQL exécuté)",
    "  OUI",
    "    --start fourni",
    "      pas --force        -> ERREUR (arrêt)",
    "      --force + --simul  -> décrit la purge, NE purge PAS, return (pas de récursion)",
    "      --force            -> TRUNCATE puis relance (retombe en « non initialisée »)",
    "    --start absent        -> MAJ normale",
    "                             - vérif hash de TOUTES les cmds enregistrées",
    "                                OK -> exécute les index manquants (trous inclus)",
    "                                KO -> arrêt (sauf --force)",
    "                             - arrêt sur première erreur SQL (sauf --force)",
]:
    body_parts.append(code_p(line))

# Connexion BDD
body_parts.append(h("Connexion BDD (connectBdd)", 1, "Titre2"))
body_parts.append(p(
    "Lecture structurée de la configuration : require de config/autoload/global.php "
    "puis config/autoload/local.php, fusionnés par array_replace_recursive. La clé "
    "standard ['db'] est privilégiée (DSN contenant dbname=), avec repli sur une "
    "recherche récursive d'un nœud dsn/username/password. Cela élimine la collision "
    "possible avec d'autres clés username/password du fichier (ex. SFTP). Handler "
    "PDO en ERRMODE_EXCEPTION ; le DSN force host=localhost (socket système)."
))

# Fonctions
body_parts.append(h("Fonctions", 1, "Titre2"))
body_parts.append(table(
    ["Fonction", "Rôle"],
    [
        ["parseSqlCommands",      "Tokeniseur : majsql.txt -> liste de commandes + bornes + contexte"],
        ["initTrackingTable",     "CREATE TABLE IF NOT EXISTS _majsql_version"],
        ["getTrackingCount",      "Nombre de commandes réelles enregistrées (cmd_index >= 1)"],
        ["isTrackingInitialized", "La table a-t-elle été initialisée ? (>= 1 ligne, marqueur inclus)"],
        ["insertTracking",        "INSERT d'une ligne dans la table de suivi"],
        ["connectBdd",            "Connexion PDO factorisée (lecture structurée de la config)"],
        ["findDbConfigRec",       "Recherche récursive du nœud de config BDD"],
        ["majSqlAuto",            "Orchestration du mode --majsql (cas 1/2/3)"],
        ["logprintf",             "printf miroir stdout + fichier de log"],
    ],
    col_widths=[4.0, 12.0],
))
body_parts.append(p("getLastTracking subsiste mais n'est plus utilisé par la logique incrémentale."))

# Paramètres
body_parts.append(h("Paramètres récapitulatifs", 1, "Titre2"))
body_parts.append(table(
    ["Paramètre", "Effet en mode --majsql"],
    [
        ["--simul=1",     "Aucune écriture BDD (ni tracking, ni DDL/DML). En --force+--start, décrit la purge sans l'exécuter (pas de récursion)"],
        ["--force",       "Ignore divergence de hash / index hors fichier, permet re--start (TRUNCATE), continue sur erreur SQL (commande non tracée -> ré-essayée)"],
        ["--start=N",     "Entier >= 0. Obligatoire en première exécution, interdit ensuite (sauf --force)"],
        ["--debug=1|2",   "Affichage de la ligne de commande et de la connexion"],
    ],
    col_widths=[3.5, 12.5],
))

# Traçabilité
body_parts.append(h("Traçabilité", 1, "Titre2"))
body_parts.append(list_items([
    "Dossier cli/histo/ créé automatiquement.",
    "Log horodaté cli/histo/cliMajSql_YYYYMMDD_HHhMM en parallèle de stdout (logprintf).",
    "Le contexte (-- jj-mm-aaaa ...) est affiché à chaque changement.",
]))

# État / Tests
body_parts.append(h("État / Tests", 1, "Titre2"))
body_parts.append(p(
    "Matrice de test dynamique validée le 2026-06-05 : 39 assertions PASS / 0 FAIL, "
    "sous PHP 7.4 ET PHP 5.6."
))
body_parts.append(p(
    "Environnement : instance MariaDB jetable isolée (datadir /tmp, socket et port "
    "3307 dédiés), sans aucun impact sur la base de production. La connexion du "
    "script (host=localhost) est redirigée vers l'instance de test via "
    "php -d pdo_mysql.default_socket=..."
))
body_parts.append(table(
    ["#", "Scénario", "Vérifie"],
    [
        ["S1", "Garde-fou première exécution", "--majsql sans --start -> message + sortie en erreur, rien créé"],
        ["S2", "Initialisation partielle --start=5", "Commandes <= ligne 5 marquées init, sans exécution SQL"],
        ["S3", "Application complète", "--start=0 puis --majsql -> 6/6 appliquées (accents, ALTER, UPDATE)"],
        ["S4", "Idempotence", "Re-run -> « à jour », aucune ré-exécution"],
        ["S5", "Trou --force ré-essayé", "Échec --force laisse un trou ; corrigé, ré-appliqué au run suivant"],
        ["S6", "Divergence de hash", "majsql.txt modifié au-dessus d'une cmd appliquée -> arrêt, contourné par --force"],
        ["S7", "--simul + non-récursion", "Simulation n'écrit rien ; --force+--start+--simul décrit sans boucler ni purger"],
        ["S8", "Arrêt sur erreur", "Erreur SQL sans --force -> arrêt, commandes suivantes non tentées"],
    ],
    col_widths=[2.5, 4.0, 10.0],
))
body_parts.append(p(
    "Bug corrigé par cette campagne : --start=0 ne posait aucune marque et rebouclait "
    "sur le garde-fou (cf. marqueur d'initialisation index 0). Lint OK PHP 7.4 + PHP 5.6."
))

# Fichiers touchés
body_parts.append(h("Fichiers concernés", 1, "Titre2"))
body_parts.append(list_items([
    "cli/cliMajSql2.php : code du mode --majsql (et --maj historique).",
    "majsql.txt : 3 « ; » manquants ajoutés (commandes auparavant fusionnées -> SQL invalide).",
    "cli/histo/ : dossier de logs, créé à la première exécution --majsql.",
    "_majsql_version : table de suivi, créée à la première exécution dans la BDD courante.",
]))

BODY_XML = "\n".join(body_parts)

# ---------------------------------------------------------------------------
# content.xml
# ---------------------------------------------------------------------------
CONTENT_XML = f'''<?xml version="1.0" encoding="UTF-8"?>
<office:document-content
 xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
 xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
 xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
 xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
 xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
 office:version="1.2">
 <office:automatic-styles>
  <style:style style:name="Col25" style:family="table-column">
   <style:table-column-properties style:column-width="2.5cm"/>
  </style:style>
  <style:style style:name="Col30" style:family="table-column">
   <style:table-column-properties style:column-width="3cm"/>
  </style:style>
  <style:style style:name="Col35" style:family="table-column">
   <style:table-column-properties style:column-width="3.5cm"/>
  </style:style>
  <style:style style:name="Col40" style:family="table-column">
   <style:table-column-properties style:column-width="4cm"/>
  </style:style>
  <style:style style:name="Col100" style:family="table-column">
   <style:table-column-properties style:column-width="10cm"/>
  </style:style>
  <style:style style:name="Col110" style:family="table-column">
   <style:table-column-properties style:column-width="11cm"/>
  </style:style>
  <style:style style:name="Col120" style:family="table-column">
   <style:table-column-properties style:column-width="12cm"/>
  </style:style>
  <style:style style:name="Col125" style:family="table-column">
   <style:table-column-properties style:column-width="12.5cm"/>
  </style:style>
 </office:automatic-styles>
 <office:body>
  <office:text>
   {BODY_XML}
  </office:text>
 </office:body>
</office:document-content>'''

# ---------------------------------------------------------------------------
# styles.xml
# ---------------------------------------------------------------------------
STYLES_XML = '''<?xml version="1.0" encoding="UTF-8"?>
<office:document-styles
 xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
 xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
 xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
 xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
 xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
 office:version="1.2">
 <office:styles>

  <style:style style:name="Corps" style:family="paragraph">
   <style:paragraph-properties fo:margin-top="0.1cm" fo:margin-bottom="0.2cm" fo:text-align="justify"/>
   <style:text-properties fo:font-family="Liberation Serif" fo:font-size="11pt"/>
  </style:style>

  <style:style style:name="Titre1" style:family="paragraph">
   <style:paragraph-properties fo:margin-top="0.6cm" fo:margin-bottom="0.3cm" fo:keep-with-next="always"/>
   <style:text-properties fo:font-family="Liberation Sans" fo:font-size="20pt" fo:font-weight="bold" fo:color="#1a4670"/>
  </style:style>

  <style:style style:name="Titre2" style:family="paragraph">
   <style:paragraph-properties fo:margin-top="0.5cm" fo:margin-bottom="0.2cm" fo:keep-with-next="always"/>
   <style:text-properties fo:font-family="Liberation Sans" fo:font-size="15pt" fo:font-weight="bold" fo:color="#1a4670"/>
  </style:style>

  <style:style style:name="Titre3" style:family="paragraph">
   <style:paragraph-properties fo:margin-top="0.3cm" fo:margin-bottom="0.15cm" fo:keep-with-next="always"/>
   <style:text-properties fo:font-family="Liberation Sans" fo:font-size="12pt" fo:font-weight="bold" fo:color="#333333"/>
  </style:style>

  <style:style style:name="Code" style:family="paragraph">
   <style:paragraph-properties fo:margin-top="0.05cm" fo:margin-bottom="0.05cm"
     fo:background-color="#f4f4f4" fo:padding="0.1cm"
     fo:border="0.5pt solid #cccccc"/>
   <style:text-properties fo:font-family="Liberation Mono" fo:font-size="9.5pt"/>
  </style:style>

  <style:style style:name="ListeBullet" style:family="paragraph">
   <style:paragraph-properties fo:margin-top="0.05cm" fo:margin-bottom="0.05cm"/>
   <style:text-properties fo:font-family="Liberation Serif" fo:font-size="11pt"/>
  </style:style>

  <style:style style:name="CelluleHeadTxt" style:family="paragraph">
   <style:paragraph-properties fo:margin="0.05cm" fo:text-align="center"/>
   <style:text-properties fo:font-family="Liberation Sans" fo:font-size="10.5pt" fo:font-weight="bold" fo:color="#ffffff"/>
  </style:style>

  <style:style style:name="CelluleTxt" style:family="paragraph">
   <style:paragraph-properties fo:margin="0.05cm"/>
   <style:text-properties fo:font-family="Liberation Serif" fo:font-size="10.5pt"/>
  </style:style>

  <style:style style:name="TableauStd" style:family="table">
   <style:table-properties style:width="16cm" table:align="left" fo:margin-top="0.2cm" fo:margin-bottom="0.2cm"/>
  </style:style>

  <style:style style:name="CelluleHead" style:family="table-cell">
   <style:table-cell-properties fo:background-color="#1a4670" fo:padding="0.1cm"
     fo:border="0.5pt solid #1a4670"/>
  </style:style>

  <style:style style:name="Cellule" style:family="table-cell">
   <style:table-cell-properties fo:padding="0.1cm" fo:border="0.5pt solid #cccccc"/>
  </style:style>

  <text:list-style style:name="L1">
   <text:list-level-style-bullet text:level="1" text:bullet-char="•">
    <style:list-level-properties text:space-before="0.6cm" text:min-label-width="0.4cm"/>
   </text:list-level-style-bullet>
  </text:list-style>

 </office:styles>

 <office:automatic-styles>
  <style:page-layout style:name="PL1">
   <style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm"
    fo:margin-top="2cm" fo:margin-bottom="2cm"
    fo:margin-left="2.5cm" fo:margin-right="2cm"/>
  </style:page-layout>
 </office:automatic-styles>

 <office:master-styles>
  <style:master-page style:name="Standard" style:page-layout-name="PL1"/>
 </office:master-styles>
</office:document-styles>'''

# ---------------------------------------------------------------------------
# meta.xml
# ---------------------------------------------------------------------------
META_XML = '''<?xml version="1.0" encoding="UTF-8"?>
<office:document-meta
 xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
 xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0"
 xmlns:dc="http://purl.org/dc/elements/1.1/"
 office:version="1.2">
 <office:meta>
  <dc:title>cliMajSql2.php - Récapitulatif du mode --majsql</dc:title>
  <dc:creator>pmsweb7</dc:creator>
  <meta:generator>genDocCliMajSql.py</meta:generator>
 </office:meta>
</office:document-meta>'''

# ---------------------------------------------------------------------------
# manifest
# ---------------------------------------------------------------------------
MANIFEST_XML = '''<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
 <manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.text"/>
 <manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
 <manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
 <manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
</manifest:manifest>'''

# ---------------------------------------------------------------------------
# Assemblage ODT
# ---------------------------------------------------------------------------
MIMETYPE = "application/vnd.oasis.opendocument.text"

with zipfile.ZipFile(OUT_PATH, "w", zipfile.ZIP_DEFLATED) as z:
    # mimetype doit être le premier, non compressé
    zi = zipfile.ZipInfo("mimetype")
    zi.compress_type = zipfile.ZIP_STORED
    z.writestr(zi, MIMETYPE)
    z.writestr("META-INF/manifest.xml", MANIFEST_XML)
    z.writestr("content.xml", CONTENT_XML)
    z.writestr("styles.xml", STYLES_XML)
    z.writestr("meta.xml", META_XML)

print(f"Généré : {OUT_PATH}")
