#!/usr/bin/env python3
"""Générateur de dossier financier bancable trilingue FR/EN/ES · Sprint 3.

Livrable **Faisabilité · Sprint 3** (roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md`
§Sprint 3 « Faisabilité Auto » → Faisabilité « génération 4 volets <1h » ; le volet
**financier bancable FR/EN/ES** est la déclinaison S3 planifiée dans les
`daily_reports` — « 40_llm_outputs/ + rapports bancables FR/EN/ES » — et exigée par le
Portail Bancables 4Big, non un libellé du fichier roadmap). Remplit le
répertoire `50_financier_bancable/` d'une data_room — jusqu'ici vide (`.gitkeep`)
— que le **Portail Bancables 4Big** (`PORTAIL_BANCABLES_4BIG.md`, variante 06
retenue par Michel) exige à l'étape 1 de son workflow de déploiement projet.

Chaîne de valeur :

    brief.json ─► faisabilite_gen ─► data_room/PXX/  (4 volets, template v1.0)
                                          │
                          bancable_gen ───┤  (ce module)
                                          ▼
                     data_room/PXX/50_financier_bancable/{fr,en,es}.md + manifest.json
                                          │
                                          ▼ (VPS · hors périmètre worker)
                     PDFs FR/EN/ES ─► Portail Bancables 4Big (privé · noindex)

Anti-invention (#6) : figures sourcées reprises verbatim ; agrégats calculés de
façon TRAÇABLE (formule + opérandes sourcés publiés) ; aucun montant sur base non
documentée ; tout champ absent reste placeholder `{{…}}` (jamais 0 fabriqué).

⚠ Ce worker n'écrit JAMAIS sur le VPS (#8). Les .md sont produits en local ; la
conversion PDF + le rebuild du portail restent côté serveur.

Sous-commandes :
  build     BRIEF.json -o DATA_ROOM   → DATA_ROOM/PXX/50_financier_bancable/*
  validate  BRIEF.json                → manifeste + invariants (sans écrire)
"""

from __future__ import annotations

import argparse
import json
import math
import os
import sys

_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)

from banclib import deps, finance, i18n, report  # noqa: E402


def _eprint(*args) -> None:
    print(*args, file=sys.stderr)


def _load_schema() -> dict:
    with open(os.path.join(_HERE, "bancable.schema.json"), encoding="utf-8") as fh:
        return json.load(fh)


def _validate_projet(brief: dict) -> None:
    code = brief.get("projet")
    if not (isinstance(code, str) and len(code) == 3 and code[:2] == "P0"
            and code[2] in "123456789"):
        raise ValueError(f"brief.projet invalide (attendu P01..P09) : {code!r}")


# --------------------------------------------------------------------------- #
# Invariants — le CLI REFUSE d'écrire si l'un casse (honnêteté #6).
# --------------------------------------------------------------------------- #
def check_invariants(brief: dict, manifest: dict,
                     files: dict[str, str]) -> list[str]:
    motifs: list[str] = []

    # (a) manifeste conforme au schéma (validateur maison).
    errs = deps.validate(manifest, _load_schema())
    motifs += [f"manifest: {e}" for e in errs]

    # (b) exactement les 3 langues canoniques, chacune rendue.
    if manifest["langues"] != ["fr", "en", "es"]:
        motifs.append(f"langues != [fr,en,es] : {manifest['langues']}")
    for lang in i18n.LANGS:
        rel = f"50_financier_bancable/{lang}.md"
        if rel not in files:
            motifs.append(f"fichier langue manquant : {rel}")

    # (c) chaque .md porte la bannière CONFIDENTIEL ; les synthétiques portent
    #     aussi l'avertissement « ne jamais publier » (#6) et sont non publiables.
    for lang in i18n.LANGS:
        md = files.get(f"50_financier_bancable/{lang}.md", "")
        if "CONFIDENTIEL" not in md and "CONFIDENTIAL" not in md \
                and "CONFIDENCIAL" not in md:
            motifs.append(f"{lang}.md : bannière CONFIDENTIEL absente")
        if brief.get("synthetique"):
            if "SYNTH" not in md.upper():
                motifs.append(f"{lang}.md : avertissement synthétique absent")
        # Garde-fou : aucun `None` Python ne doit fuiter dans le rendu.
        if " None " in md or "| None " in md or ": None\n" in md:
            motifs.append(f"{lang}.md : littéral None dans le rendu")
    if brief.get("synthetique") and manifest["publiable"]:
        motifs.append("synthétique mais publiable=true (#6)")

    # (d) figures calculées : recoupement anti-invention. Chaque valeur non nulle
    #     doit se RECALCULER à partir des opérandes sourcés (formule publiée).
    motifs += _check_derived_arithmetic(brief, manifest)

    # (e) cohérence publiable ↔ champs manquants : un dossier publiable ne peut pas
    #     manquer le trio coût/revenu/marge.
    if manifest["publiable"]:
        fs = manifest["figures_sourcees"]
        if fs["cout_construction_usd"] is None or fs["revenu_brut_usd"] is None \
                or fs["marge_pct"] is None:
            motifs.append("publiable=true mais cœur financier incomplet")

    return motifs


def _check_derived_arithmetic(brief: dict, manifest: dict) -> list[str]:
    """Reproduit indépendamment les 4 agrégats et compare au manifeste.

    Prouve qu'aucune valeur calculée n'a été « posée » : elle DOIT découler des
    typologies sourcées et du taux canonique de point d'équilibre. Ce taux est lu
    à la SOURCE UNIQUE (`CANONICAL` · CLAUDE.md #9, gaté), jamais recopié en dur
    ici — sinon un changement du mandat (#9) casserait cet oracle en silence.
    """
    motifs: list[str] = []
    rows = finance.typologies(brief)
    figs = {f["cle"]: f["valeur"] for f in manifest["figures_calculees"]}

    qtes = [r["quantite"] for r in rows]
    all_q = bool(rows) and all(q is not None for q in qtes)

    exp_total = sum(qtes) if all_q else None
    exp_usd = (sum(q * r["prix_usd"] for q, r in zip(qtes, rows))
               if all_q and all(r["prix_usd"] is not None for r in rows) else None)
    exp_dop = (sum(q * r["prix_dop"] for q, r in zip(qtes, rows))
               if all_q and all(r["prix_dop"] is not None for r in rows) else None)
    pe_pct = finance._pct(deps.CANONICAL["point_equilibre_pct"])
    exp_pe = (float(math.ceil(pe_pct * exp_total))
              if exp_total is not None and pe_pct is not None else None)

    for cle, exp in (("total_unites", exp_total),
                     ("valeur_catalogue_usd", exp_usd),
                     ("valeur_catalogue_dop", exp_dop),
                     ("point_equilibre_unites", exp_pe)):
        got = figs.get(cle)
        if exp is None and got is not None:
            motifs.append(f"figure {cle} calculée ({got}) alors qu'un opérande manque")
        elif exp is not None and got != exp:
            motifs.append(f"figure {cle} : manifeste {got} != recalcul {exp}")
    return motifs


# --------------------------------------------------------------------------- #
def cmd_build(ns: argparse.Namespace) -> int:
    with open(ns.brief, encoding="utf-8") as fh:
        brief = json.load(fh)
    _validate_projet(brief)
    code = brief["projet"]

    files = report.render_all(brief, generated_at=ns.generated_at)
    manifest = json.loads(files["50_financier_bancable/manifest.json"])

    motifs = check_invariants(brief, manifest, files)
    if motifs:
        _eprint(f"❌ {code} : invariants cassés — RIEN écrit :")
        for m in motifs:
            _eprint("     ·", m)
        return 1

    dest_dir = os.path.join(ns.out, code, "50_financier_bancable")
    os.makedirs(dest_dir, exist_ok=True)
    for rel, content in files.items():
        with open(os.path.join(ns.out, code, rel), "w", encoding="utf-8") as fh:
            fh.write(content)

    _eprint(f"✅ {code} → {dest_dir}")
    _eprint(f"   langues={manifest['langues']} · publiable={manifest['publiable']} "
            f"· synthétique={manifest['synthetique']}")
    _eprint(f"   typologies={manifest['typologies_count']} · "
            f"champs 🔴 manquants={len(manifest['champs_manquants'])}")
    for f in manifest["figures_calculees"]:
        _eprint(f"     · {f['cle']} = {f['valeur']}  [{f['formule']}]")
    return 0


def cmd_validate(ns: argparse.Namespace) -> int:
    with open(ns.brief, encoding="utf-8") as fh:
        brief = json.load(fh)
    _validate_projet(brief)
    code = brief["projet"]

    files = report.render_all(brief, generated_at=ns.generated_at)
    manifest = json.loads(files["50_financier_bancable/manifest.json"])
    motifs = check_invariants(brief, manifest, files)

    print(json.dumps(manifest, ensure_ascii=False, indent=2))
    if motifs:
        _eprint(f"❌ {code} : {len(motifs)} invariant(s) cassé(s) :")
        for m in motifs:
            _eprint("     ·", m)
        return 1
    _eprint(f"✅ {code} : manifeste conforme + invariants OK "
            f"(publiable={manifest['publiable']}).")
    return 0


# --------------------------------------------------------------------------- #
def build_argparser() -> argparse.ArgumentParser:
    ap = argparse.ArgumentParser(prog="bancable_gen",
                                 description=__doc__.splitlines()[0])
    sub = ap.add_subparsers(dest="cmd", required=True)

    p = sub.add_parser("build", help="brief.json → 50_financier_bancable/{fr,en,es}.md")
    p.add_argument("brief")
    p.add_argument("-o", "--out", required=True, help="dossier data_room de sortie")
    p.add_argument("--generated-at", dest="generated_at", default=None)
    p.set_defaults(func=cmd_build)

    p = sub.add_parser("validate", help="brief.json → manifeste + invariants (sans écrire)")
    p.add_argument("brief")
    p.add_argument("--generated-at", dest="generated_at", default=None)
    p.set_defaults(func=cmd_validate)
    return ap


def main(argv: list[str] | None = None) -> int:
    ns = build_argparser().parse_args(argv)
    return ns.func(ns)


if __name__ == "__main__":
    raise SystemExit(main())
