#!/usr/bin/env python3
"""Export du seed backend v1 (backend/seed.py) vers un Bundle JSON v2.

Émet frontend/app/data/seeds/duniter-g1.bundle.json au schéma Bundle v2
(frontend/app/types/domain.ts), sans perte de contenu Ğ1.

ISOLATION DES DONNÉES (aucune dépendance DB) :
    Importer seed.py déclencherait `from app.database import …` (SQLAlchemy,
    driver async, config). Ce script n'importe donc PAS seed.py : il le PARSE
    avec `ast` et extrait les structures par évaluation littérale tolérante :
      - littéraux au niveau module : GENESIS_CERTIFICATION,
        ENGAGEMENT_CERTIFICATION_ITEMS (33), GENESIS_FORGERON,
        ENGAGEMENT_FORGERON_ITEMS (59), VOTER_NAMES (11), RUNTIME_UPGRADE_STEPS ;
      - littéraux internes aux fonctions : configs (FormulaConfigs ×7),
        protocols (VotingProtocols ×3), mandates_data (×3), steps (licence),
        orgs_data, kwargs des appels get_or_create / QualificationProtocol.
    Les valeurs non littérales (ids SQLAlchemy, datetime.now()…) sont ignorées
    par l'extracteur et recalculées côté v2 (dates historiques fixes,
    uuid5 déterministes).

REPRODUCTIBILITÉ : uuid5(NAMESPACE fixe, slug) partout + dates constantes
    (aucun datetime.now) → deux exécutions produisent un JSON identique
    octet à octet.

ÉCARTS ASSUMÉS (voir rapport imprimé en fin d'exécution) :
    - les 3 VoteSessions de démo v1 (10 pour / 1 contre simulés sur les 3
      premiers items forgeron) ne sont PAS portées : votes factices sans
      contenu Ğ1 réel — le vrai vote (97/23/19) vit dans provenance.voteRecord ;
    - l'organisation v1 'axiom-team' n'est pas exportée (un bundle = UN
      collectif) ;
    - icon 'i-lucide-network' (spec BLUEPRINT-V2 « Seeds ») remplace
      'i-lucide-globe' (v1) ; la description de l'organisation v1 est conservée
      dans la provenance du Pacte ;
    - v1 mappait TOUS les items cert sur « Vote WoT standard » (son
      inertia_protocol_map pointait le même protocole pour les 4 presets) ;
      v2 câble clauseByInertia par preset (règle R3 du blueprint) — les
      FormulaConfigs v1 basse/haute/très haute deviennent des protocoles ;
    - item_type v1 (preamble/section/clause/rule/verification) conservé dans
      les tags de la décision fondatrice de chaque clause (`v1:<item_type>`).

Usage : backend/.venv/bin/python backend/scripts/export_seed_bundle.py
"""

from __future__ import annotations

import ast
import hashlib
import json
import uuid
from pathlib import Path

HERE = Path(__file__).resolve()
BACKEND = HERE.parents[1]
REPO = HERE.parents[2]
SEED_PATH = BACKEND / "seed.py"
OUT_PATH = REPO / "frontend" / "app" / "data" / "seeds" / "duniter-g1.bundle.json"

# ─────────────────────────────────────────────────────────────
# Extraction AST — aucun import de seed.py
# ─────────────────────────────────────────────────────────────

_SKIP = object()


def _tolerant(node: ast.AST):
    """Évaluation littérale tolérante : tout nœud non littéral est ignoré."""
    if isinstance(node, ast.Constant):
        return node.value
    if isinstance(node, ast.Dict):
        out = {}
        # (clé None = **unpacking — absent des littéraux extraits de seed.py)
        for key_node, value_node in zip(node.keys, node.values):
            if key_node is None:
                continue
            key = _tolerant(key_node)
            value = _tolerant(value_node)
            if key is _SKIP or value is _SKIP:
                continue
            out[key] = value
        return out
    if isinstance(node, (ast.List, ast.Tuple)):
        return [v for v in (_tolerant(e) for e in node.elts) if v is not _SKIP]
    if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
        operand = _tolerant(node.operand)
        return -operand if operand is not _SKIP else _SKIP
    return _SKIP


def _assign_value(scope: ast.AST, name: str):
    """Valeur littérale de la première assignation `name = …` dans le scope."""
    for node in ast.walk(scope):
        target = None
        if (
            isinstance(node, ast.Assign)
            and len(node.targets) == 1
            and isinstance(node.targets[0], ast.Name)
        ):
            target = node.targets[0].id
        elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
            target = node.target.id
        if target == name and getattr(node, "value", None) is not None:
            value = _tolerant(node.value)
            if value is not _SKIP:
                return value
    raise KeyError(f"assignation littérale introuvable : {name}")


def _function(tree: ast.Module, name: str) -> ast.AST:
    for node in tree.body:
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name:
            return node
    raise KeyError(f"fonction introuvable : {name}")


def _call_kwargs(scope: ast.AST, callee: str) -> dict:
    """Kwargs littéraux du premier appel `callee(...)` du scope."""
    for node in ast.walk(scope):
        if not isinstance(node, ast.Call):
            continue
        func = node.func
        fname = func.id if isinstance(func, ast.Name) else getattr(func, "attr", None)
        if fname != callee:
            continue
        kwargs = {}
        for kw in node.keywords:
            if kw.arg is None:
                continue
            value = _tolerant(kw.value)
            if value is not _SKIP:
                kwargs[kw.arg] = value
        return kwargs
    raise KeyError(f"appel introuvable : {callee}")


def _json_dumps_arg(scope: ast.AST):
    """Premier argument littéral d'un appel json.dumps(...) dans le scope."""
    for node in ast.walk(scope):
        if (
            isinstance(node, ast.Call)
            and isinstance(node.func, ast.Attribute)
            and node.func.attr == "dumps"
            and node.args
        ):
            value = _tolerant(node.args[0])
            if value is not _SKIP:
                return value
    return None


_tree = ast.parse(SEED_PATH.read_text(encoding="utf-8"))

GEN_CERT = _assign_value(_tree, "GENESIS_CERTIFICATION")
CERT_ITEMS = _assign_value(_tree, "ENGAGEMENT_CERTIFICATION_ITEMS")
GEN_FORGE = _assign_value(_tree, "GENESIS_FORGERON")
FORGE_ITEMS = _assign_value(_tree, "ENGAGEMENT_FORGERON_ITEMS")
VOTER_NAMES = _assign_value(_tree, "VOTER_NAMES")
RUNTIME_STEPS = _assign_value(_tree, "RUNTIME_UPGRADE_STEPS")

FORMULAS_V1 = _assign_value(_function(_tree, "seed_formula_configs"), "configs")
PROTOCOLS_V1 = _assign_value(_function(_tree, "seed_voting_protocols"), "protocols")
MANDATES_V1 = _assign_value(_function(_tree, "seed_mandates"), "mandates_data")
ORGS_V1 = _assign_value(_function(_tree, "seed_organizations"), "orgs_data")
LICENCE_STEPS = _assign_value(_function(_tree, "seed_decision_licence_evolution"), "steps")

_fn_qual = _function(_tree, "seed_qualification_protocol")
QUAL_V1 = _call_kwargs(_fn_qual, "QualificationProtocol")
QUAL_MODALITIES_V1 = _json_dumps_arg(_fn_qual) or []

CERT_DOC_META = _call_kwargs(
    _function(_tree, "seed_document_engagement_certification"), "get_or_create"
)
FORGE_DOC_META = _call_kwargs(
    _function(_tree, "seed_document_engagement_forgeron"), "get_or_create"
)
RUNTIME_META = _call_kwargs(_function(_tree, "seed_decision_runtime_upgrade"), "get_or_create")
LICENCE_META = _call_kwargs(_function(_tree, "seed_decision_licence_evolution"), "get_or_create")

# ── Garde-fous de non-perte à l'extraction ───────────────────
assert len(CERT_ITEMS) == 33, f"attendu 33 items Certification, extrait {len(CERT_ITEMS)}"
assert len(FORGE_ITEMS) == 59, f"attendu 59 items Forgeron, extrait {len(FORGE_ITEMS)}"
assert len(VOTER_NAMES) == 11, f"attendu 11 votants, extrait {len(VOTER_NAMES)}"
assert len(MANDATES_V1) == 3, f"attendu 3 mandats v1, extrait {len(MANDATES_V1)}"
assert len(FORMULAS_V1) == 7, f"attendu 7 FormulaConfigs, extrait {len(FORMULAS_V1)}"
assert len(PROTOCOLS_V1) == 3, f"attendu 3 VotingProtocols, extrait {len(PROTOCOLS_V1)}"
_vr = GEN_FORGE["vote_record"]["result"]
assert (_vr["pour"], _vr["contre"], _vr["nuls_invalides"]) == (97, 23, 19)
assert (_vr["wot_size"], _vr["threshold_required"]) == (7224, 97)

# ─────────────────────────────────────────────────────────────
# Constantes v2 — ids déterministes et dates historiques fixes
# ─────────────────────────────────────────────────────────────

NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://libredecision.org/seeds/duniter-g1")


def uid(slug: str) -> str:
    return str(uuid.uuid5(NAMESPACE, slug))


COLLECTIVE_ID = uid("collective:duniter-g1")
EXPORTED_AT = "2026-08-11T12:00:00Z"

T_GENESIS = "2025-12-01T10:00:00Z"  # création du collectif, adoption cert + Pacte
T_CERT_DRAFT = "2025-11-15T10:00:00Z"
T_FORGE_OPEN = "2026-01-07T09:00:00Z"  # ouverture du vote forgeron (seed v1)
T_FORGE_ADOPT = "2026-02-06T18:00:00Z"  # clôture/adoption (97/23, WoT 7224)
T_COMTECH_OPEN = "2026-02-04T09:00:00Z"
T_COMTECH_ADOPT = "2026-03-06T18:00:00Z"

PID = {name: uid(f"person:{name.lower()}") for name in VOTER_NAMES}
SMITH_NAMES = VOTER_NAMES[:5]  # v1 : is_smith=(i < 5)

C_ROOT = uid("circle:toile-de-confiance")
C_FORGE = uid("circle:forgerons")
C_TECH = uid("circle:comite-technique")

DOC_CERT = uid("doc:engagement-certification")
DOC_FORGE = uid("doc:engagement-forgeron")
DOC_TECH = uid("doc:engagement-comite-tech")
DOC_PACT = uid("doc:pacte-duniter-g1")

P_CONSENT = uid("protocol:consentement")
P_WOT = uid("protocol:vote-wot-standard")
P_NUANCED = uid("protocol:vote-nuance")
P_SMITH = uid("protocol:vote-forgeron-smith")
P_TECHCOMM = uid("protocol:vote-comite-technique")
P_PARAMETRIC = uid("protocol:reglage-collectif")
P_ELECTION = uid("protocol:election-sans-candidat")
P_INERTIA_LOW = uid("protocol:inertie-basse")
P_INERTIA_HIGH = uid("protocol:inertie-haute")
P_INERTIA_MAX = uid("protocol:inertie-tres-haute")

# Presets v1 → InertiaPreset v2 (spec : very_high → 'max')
PRESET_MAP = {"low": "low", "standard": "standard", "high": "high", "very_high": "max"}

# R3 : protocole d'amendement d'une clause = clauseByInertia[preset]
PROTO_BY_INERTIA = {
    "low": P_INERTIA_LOW,
    "standard": P_WOT,
    "high": P_INERTIA_HIGH,
    "max": P_INERTIA_MAX,
}


def _fmt(value: float) -> str:
    """0.1 → '.1', 50 → '50' (DSL modeParams, forme canonique)."""
    if value == int(value):
        return str(int(value))
    s = str(value)
    return s[1:] if s.startswith("0.") else s


def entity(eid: str, created: str, updated: str | None = None, **fields) -> dict:
    return {
        "id": eid,
        "collectiveId": COLLECTIVE_ID,
        "createdAt": created,
        "updatedAt": updated or created,
        **fields,
    }


# ─────────────────────────────────────────────────────────────
# Personnes (11, dont 5 smiths — v1 VOTER_NAMES)
# ─────────────────────────────────────────────────────────────

people = []
for i, name in enumerate(VOTER_NAMES):
    # Même dérivation d'adresse que seed.py (sha256(name)[:32] préfixé '5')
    address = "5" + hashlib.sha256(name.encode()).hexdigest()[:32]
    people.append(
        entity(
            PID[name],
            T_GENESIS,
            displayName=name,
            isMe=False,
            duniterAddress=address,
            wotStatus="smith" if i < 5 else "member",
        )
    )

# ─────────────────────────────────────────────────────────────
# Cercles (3, typés kind)
# ─────────────────────────────────────────────────────────────

COMTECH_MEMBERS = ["Elois", "Cgeek", "Maaltir", "Hugo", "Tuxmain"]  # v1 : outcome de l'élection

circles = [
    entity(
        C_ROOT,
        T_GENESIS,
        name="Toile de confiance",
        purpose="Tous les membres de la toile de confiance Ğ1 — le corps souverain du collectif.",
        memberIds=[PID[n] for n in VOTER_NAMES],
        domains=["toile", "certification", "licence"],
    ),
    entity(
        C_FORGE,
        T_GENESIS,
        name="Forgerons",
        purpose="Les forgerons (validateurs de blocs) de la blockchain Duniter V2 — la sous-toile Smith.",
        kind="team",
        memberIds=[PID[n] for n in SMITH_NAMES],
        parentCircleId=C_ROOT,
        domains=["forge", "noeuds", "runtime"],
    ),
    entity(
        C_TECH,
        T_GENESIS,
        name="Comité Technique",
        purpose="Le Comité Technique protège la souveraineté de la communauté sur les mises à jour de la blockchain.",
        kind="team",
        memberIds=[PID[n] for n in COMTECH_MEMBERS],
        parentCircleId=C_ROOT,
        domains=["runtime", "code", "on-chain"],
    ),
]

# ─────────────────────────────────────────────────────────────
# Protocoles (10) — FormulaParams complets depuis les FormulaConfigs v1
# ─────────────────────────────────────────────────────────────

_f_std = FORMULAS_V1["Standard Licence G1"]
_f_low = FORMULAS_V1["Inertie basse (Annexes)"]
_f_high = FORMULAS_V1["Inertie haute (Formule)"]
_f_vhigh = FORMULAS_V1["Inertie très haute (Méta-réglage)"]
_f_smith = FORMULAS_V1["Forgeron avec Smith"]
_f_tech = FORMULAS_V1["Comité Tech"]
_f_nuanced = FORMULAS_V1["Vote Nuance"]


def _formula(cfg: dict, **extra) -> dict:
    return {
        "majorityPct": cfg["majority_pct"],
        "baseExponent": cfg["base_exponent"],
        "gradientExponent": cfg["gradient_exponent"],
        "constantBase": cfg["constant_base"],
        **extra,
    }


def _binary_mode(cfg: dict, suffix: str = "") -> str:
    return (
        f"D{cfg['duration_days']}M{_fmt(cfg['majority_pct'])}"
        f"B{_fmt(cfg['base_exponent'])}G{_fmt(cfg['gradient_exponent'])}{suffix}"
    )


def protocol(pid, name, method, description, days, formula, mode, pact_clause=None):
    p = entity(
        pid,
        T_GENESIS,
        name=name,
        method=method,
        description=description,
        durationDays=days,
        ballot="open",
        formula=formula,
        modeParams=mode,
    )
    if pact_clause:
        p["pactClauseId"] = uid(f"clause:pacte-duniter-g1:{pact_clause}")
    return p


protocols = [
    protocol(
        P_CONSENT,
        "Consentement",
        "consent",
        "Ça me va / J'objecte — zéro objection argumentée maintenue vaut adoption. "
        "La modalité par défaut des petits corps, et le repli universel de toute "
        "clé de protocole absente. Hors du réversible, au moins un accord "
        "explicite est requis à l'échéance.",
        7,
        {"majorityPct": 100, "baseExponent": 0.1, "gradientExponent": 0, "constantBase": 0},
        "D7M100B.1G0",
        pact_clause="M1",
    ),
    protocol(
        P_WOT,
        "Vote WoT standard",
        "binary",
        "L'outil de dernier recours des très grands corps — héritage Toile de "
        "Confiance. " + PROTOCOLS_V1["Vote WoT standard"]["description"],
        _f_std["duration_days"],
        _formula(_f_std),
        PROTOCOLS_V1["Vote WoT standard"]["mode_params"],
        pact_clause="M2",
    ),
    protocol(
        P_NUANCED,
        "Vote nuancé",
        "nuanced",
        "Vote nuancé à 6 niveaux — 0 « Pas du tout » à 5 « Tout à fait » : chacun "
        "se prononce en nuances, pas en camps. Histogramme des 6 niveaux visible "
        "pendant et après le vote ; commentaire obligatoire sur 0-1. "
        "(v1 : « " + _f_nuanced["description"] + " »)",
        _f_nuanced["duration_days"],
        _formula(
            _f_nuanced,
            nuancedMinParticipants=_f_nuanced["nuanced_min_participants"],
            nuancedThresholdPct=_f_nuanced["nuanced_threshold_pct"],
        ),
        _binary_mode(_f_nuanced),
        pact_clause="M3",
    ),
    protocol(
        P_SMITH,
        "Vote forgeron (Smith)",
        "binary",
        PROTOCOLS_V1["Vote forgeron (Smith)"]["description"],
        _f_smith["duration_days"],
        _formula(_f_smith, smithExponent=_f_smith["smith_exponent"]),
        PROTOCOLS_V1["Vote forgeron (Smith)"]["mode_params"],
    ),
    protocol(
        P_TECHCOMM,
        "Vote Comité Technique",
        "binary",
        PROTOCOLS_V1["Vote Comité Technique"]["description"],
        _f_tech["duration_days"],
        _formula(_f_tech, techcommExponent=_f_tech["techcomm_exponent"]),
        PROTOCOLS_V1["Vote Comité Technique"]["mode_params"],
    ),
    protocol(
        P_PARAMETRIC,
        "Réglage collectif",
        "parametric",
        "Décider au curseur — chacun pose sa valeur, la médiane basse éclaire, "
        "le geste du garant cristallise. Quorum : 5 participants.",
        14,
        {
            "majorityPct": 50,
            "baseExponent": 0.1,
            "gradientExponent": 0,
            "constantBase": 0,
            "parametricMinParticipants": 5,
        },
        "D14M50B.1G0",
        pact_clause="M4",
    ),
    protocol(
        P_ELECTION,
        "Élection sans candidat",
        "election",
        "Désigner sans candidature — chacun nomme la personne qu'il estime la "
        "mieux placée. Vote blanc possible (compte pour la participation, pas "
        "pour la désignation). En cas d'égalité : session de départage entre "
        "ex æquo — jamais l'outil.",
        14,
        {
            "majorityPct": 50,
            "baseExponent": 0.1,
            "gradientExponent": 0,
            "constantBase": 0,
            "electionMinParticipants": 5,
            "tieBreak": "runoff",
        },
        "D14M50B.1G0",
        pact_clause="M5",
    ),
    protocol(
        P_INERTIA_LOW,
        "Inertie basse (Annexes)",
        "binary",
        _f_low["description"],
        _f_low["duration_days"],
        _formula(_f_low),
        _binary_mode(_f_low),
        pact_clause="N1",
    ),
    protocol(
        P_INERTIA_HIGH,
        "Inertie haute (Formule)",
        "binary",
        _f_high["description"],
        _f_high["duration_days"],
        _formula(_f_high),
        _binary_mode(_f_high),
        pact_clause="N3",
    ),
    protocol(
        P_INERTIA_MAX,
        "Inertie très haute (Méta-réglage)",
        "binary",
        _f_vhigh["description"],
        _f_vhigh["duration_days"],
        _formula(_f_vhigh),
        _binary_mode(_f_vhigh),
        pact_clause="N4",
    ),
]

# ─────────────────────────────────────────────────────────────
# TextDocs, Clauses, ClauseVersions, Décisions fondatrices
# ─────────────────────────────────────────────────────────────

docs: list[dict] = []
clauses: list[dict] = []
versions: list[dict] = []
decisions: list[dict] = []


def _steps_md(steps: list[dict]) -> str:
    lines = []
    for s in steps:
        line = f"{s.get('step_order')}. **{s.get('title')}** — {s.get('description', '').strip()}"
        if s.get("status"):
            line += f" *(v1 : {s['status']}"
            if s.get("outcome"):
                line += f" — {s['outcome']}"
            line += ")*"
        lines.append(line)
    return "\n".join(lines)


def add_doc(doc_id, slug, title, role, description, provenance, created, updated=None):
    docs.append(
        entity(
            doc_id,
            created,
            updated,
            slug=slug,
            title=title,
            role=role,
            description=description,
            provenance=provenance,
        )
    )


def add_clause(
    *,
    doc_slug: str,
    doc_id: str,
    code: str,
    section: str,
    position: int,
    title: str,
    inertia: str,
    content: str,
    version_label: str,
    created: str,
    adopted: str,
    author: str,
    protocol_id: str,
    scope_circles: list[str],
    tags: list[str],
    setting_key: str | None = None,
    setting_value=None,
    body: str | None = None,
):
    cid = uid(f"clause:{doc_slug}:{code}")
    vid = uid(f"version:{doc_slug}:{code}:current")
    did = uid(f"decision:fondatrice:{doc_slug}:{code}")

    clause = entity(
        cid,
        adopted,
        docId=doc_id,
        section=section,
        position=position,
        code=code,
        title=title,
        inertia=inertia,
        currentVersionId=vid,
    )
    if setting_key is not None:
        clause["settingKey"] = setting_key
    clauses.append(clause)

    version = entity(
        vid,
        created,
        adopted,
        clauseId=cid,
        decisionId=did,
        versionLabel=version_label,
        content=content,
        status="current",
        adoptedAt=adopted,
    )
    if setting_value is not None:
        version["settingValue"] = setting_value
    versions.append(version)

    decisions.append(
        entity(
            did,
            created,
            adopted,
            authorId=PID[author],
            title=f"Adoption fondatrice — {code}",
            body=body
            or (
                f"Adoption fondatrice de la clause {code} « {title} » "
                f"(document {doc_slug}). Import du seed v1 — contenu en vigueur "
                "dans la communauté Ğ1."
            ),
            tags=tags,
            reversibility="costly",
            weight="binding",
            urgent=False,
            scope={"selfOnly": False, "circleIds": scope_circles, "personIds": []},
            route="collective",
            triageRule="R3",
            routeOverridden=False,
            amendsClauseId=cid,
            protocolId=protocol_id,
            status="adopted",
            decidedAt=adopted,
            stewardIds=[],
            measurerIds=[],
            visibility="collective",
        )
    )
    return cid


def _sources_from_genesis(genesis: dict) -> list[dict]:
    sources = []
    src = genesis.get("source_document", {})
    if src:
        item = {"title": src["title"], "url": src["url"]}
        if src.get("date"):
            item["date"] = src["date"]
        if src.get("version"):
            item["version"] = src["version"]
        sources.append(item)
        if src.get("repo"):
            sources.append({"title": "Dépôt git officiel (licence Ğ1)", "url": src["repo"]})
    for topic in genesis.get("forum_synthesis", []):
        sources.append({"title": topic["title"], "url": topic["url"]})
    tools = genesis.get("référence_tools", {})
    tool_labels = {
        "g1vote_repo": "g1vote-view (dépôt)",
        "g1vote_live": "g1vote-view (live)",
        "cesium": "Cesium",
        "gecko": "Gecko",
        "pad_source": "Pad source (charte forgeron)",
    }
    for key, label in tool_labels.items():
        if tools.get(key):
            sources.append({"title": label, "url": tools[key]})
    return sources


def _forum_status_notes(genesis: dict) -> str:
    lines = []
    for topic in genesis.get("forum_synthesis", []):
        line = f"- {topic['title']} : {topic.get('status', '?')}"
        if topic.get("posts"):
            line += f" ({topic['posts']} messages)"
        lines.append(line)
    return "\n".join(lines)


# ── Doc 1 : Acte d'engagement Certification (33 clauses) ─────

add_doc(
    DOC_CERT,
    "engagement-certification",
    CERT_DOC_META["title"],
    "reference",
    CERT_DOC_META["description"],
    {
        "sources": _sources_from_genesis(GEN_CERT),
        "contributors": [f"{c['name']} — {c['role']}" for c in GEN_CERT["contributors"]],
        "notes": (
            "Déclencheur formule (v1) : "
            + GEN_CERT["formula_trigger"]
            + "\n\nÉtat des discussions forum au moment du seed v1 :\n"
            + _forum_status_notes(GEN_CERT)
            + f"\n\nDocument v1 : version {CERT_DOC_META['version']}, "
            f"type {CERT_DOC_META['doc_type']}, statut {CERT_DOC_META['status']}."
        ),
    },
    T_GENESIS,
)

for item in CERT_ITEMS:
    preset = PRESET_MAP[item["inertia_preset"]]
    add_clause(
        doc_slug="engagement-certification",
        doc_id=DOC_CERT,
        code=item["position"],
        section=item["section_tag"],
        position=item["sort_order"],
        title=item["title"],
        inertia=preset,
        content=item["current_text"],
        version_label=CERT_DOC_META["version"],
        created=T_CERT_DRAFT,
        adopted=T_GENESIS,
        author="Galuel",
        protocol_id=PROTO_BY_INERTIA[preset],
        scope_circles=[C_ROOT],
        tags=["engagement-certification", item["section_tag"], f"v1:{item['item_type']}"],
    )

# ── Doc 2 : Acte d'engagement forgeron (59 clauses v2.0.0-fr) ─

_vote_record_v1 = GEN_FORGE["vote_record"]
add_doc(
    DOC_FORGE,
    "engagement-forgeron",
    FORGE_DOC_META["title"],
    "reference",
    FORGE_DOC_META["description"],
    {
        "sources": _sources_from_genesis(GEN_FORGE)
        + [{"title": "g1vote — suivi du vote 33165", "url": _vote_record_v1["g1vote_url"]}],
        "voteRecord": {
            "url": _vote_record_v1["topic_url"],
            "modeParams": _vote_record_v1["mode_params"],
            "period": _vote_record_v1["period"],
            "result": {
                "for": _vr["pour"],
                "against": _vr["contre"],
                "invalid": _vr["nuls_invalides"],
                "wotSize": _vr["wot_size"],
                "thresholdRequired": _vr["threshold_required"],
                "status": _vr["status"],
            },
        },
        "contributors": [f"{c['name']} — {c['role']}" for c in GEN_FORGE["contributors"]],
        "notes": (
            f"Critère Smith du vote v2.0.0-fr : {_vr['smith_pour']} forgerons pour / "
            f"{_vr['smith_contre']} contre. Adresses de vote — pour : "
            f"{_vote_record_v1['addresses']['pour']} ; contre : "
            f"{_vote_record_v1['addresses']['contre']}.\n\n"
            "Actions d'implémentation votées :\n- "
            + "\n- ".join(GEN_FORGE["implementation_actions"])
            + "\n\nDéclencheur formule (v1) : "
            + GEN_FORGE["formula_trigger"]
            + "\n\nÉtat des discussions forum au moment du seed v1 :\n"
            + _forum_status_notes(GEN_FORGE)
        ),
    },
    T_FORGE_OPEN,
    T_FORGE_ADOPT,
)

for item in FORGE_ITEMS:
    preset = PRESET_MAP[item["inertia_preset"]]
    add_clause(
        doc_slug="engagement-forgeron",
        doc_id=DOC_FORGE,
        code=item["position"],
        section=item["section_tag"],
        position=item["sort_order"],
        title=item["title"],
        inertia=preset,
        content=item["current_text"],
        version_label=GEN_FORGE["source_document"]["version"],  # '2.0.0-fr'
        created=T_FORGE_OPEN,
        adopted=T_FORGE_ADOPT,
        author="Elois",
        # v1 : tous les items forgeron votés au protocole Smith (double critère)
        protocol_id=P_SMITH,
        scope_circles=[C_ROOT, C_FORGE],
        tags=["engagement-forgeron", item["section_tag"], f"v1:{item['item_type']}"],
    )

# ── Doc 3 : Acte d'engagement du Comité Technique (6 clauses) ─
# Contenu ABSENT de seed.py — reconstruit depuis research_duniter_forum.md §3
# (Engagement Comité Tech v2.0.0-fr, vote 2026-02-04 → 2026-03-06, en cours
# au moment de la recherche). Documenté dans provenance.notes.

COMTECH_CLAUSES = [
    {
        "code": "CT1",
        "section": "mission",
        "title": "Mission du Comité Technique",
        "inertia": "standard",
        "content": (
            "Le Comité Technique protège la souveraineté de la communauté Ğ1 "
            "sur les mises à jour de la blockchain. Ses missions :\n\n"
            "- Auditer le code\n"
            "- Détecter le code malveillant\n"
            "- Vérifier les fonctionnalités annoncées\n"
            "- Déployer uniquement les mises à jour servant la communauté"
        ),
    },
    {
        "code": "CT2",
        "section": "engagements",
        "title": "Respect des règles en vigueur",
        "inertia": "standard",
        "content": (
            "Chaque membre du Comité Technique s'engage à respecter les règles "
            "décrites dans la version en vigueur du présent document."
        ),
    },
    {
        "code": "CT3",
        "section": "engagements",
        "title": "Démission en cas de désaccord",
        "inertia": "standard",
        "content": (
            "Chaque membre s'engage à démissionner s'il est en désaccord "
            "avec les règles en vigueur."
        ),
    },
    {
        "code": "CT4",
        "section": "engagements",
        "title": "Révocation des membres en violation",
        "inertia": "standard",
        "content": (
            "Chaque membre s'engage à voter pour retirer le mandat des "
            "collègues qui violent visiblement les directives du présent document."
        ),
    },
    {
        "code": "CT5",
        "section": "formule",
        "title": "Conditions d'adoption",
        "inertia": "high",
        "content": (
            "L'adoption d'une modification exige le seuil unani-majoritaire "
            "de la toile ET le support minimum des membres du comité :\n\n"
            "```\nvotesPour >= ceil(WotSize^0.1 + (0.5 + (1 - 0.5) "
            "× (1 - (TotalVotes/WotSize)^0.2)) × TotalVotes)\n```\n"
            "ET\n"
            "```\nvotesCoTecPour >= ceil(CoTecSize^0.1)\n```\n\n"
            "Mode compact : D30M50B.1G.2T.1."
        ),
    },
    {
        "code": "CT6",
        "section": "composition",
        "title": "Composition du comité",
        "inertia": "high",
        "content": (
            "L'entrée et la sortie d'un membre nécessitent l'approbation des "
            "2/3 des membres existants (modifiable uniquement par mise à jour "
            "runtime avec validation des 2/3)."
        ),
    },
]

add_doc(
    DOC_TECH,
    "engagement-comite-tech",
    "Acte d'engagement du Comité Technique",
    "reference",
    "Acte d'engagement des membres du Comité Technique Duniter V2 "
    "(v2.0.0-fr). Protège la souveraineté de la communauté Ğ1 sur les mises "
    "à jour de la blockchain : audit du code, détection de code malveillant, "
    "déploiement des seules mises à jour servant la communauté.",
    {
        "sources": [
            {
                "title": "Vote : Engagement Comité Tech 2.0.0-fr",
                "url": "https://forum.monnaie-libre.fr/t/vote-engagement-comite-tech-2-0-0-fr/33293",
                "date": "2026-02-04",
                "version": "2.0.0-fr",
            },
            {
                "title": "g1vote — mode D30M50B.1G.2T.1 (talk 32960)",
                "url": "https://g1vote-view-237903.pages.duniter.org/",
            },
        ],
        "notes": (
            "Contenu ABSENT du seed v1 (seed.py) — reconstruit depuis la "
            "synthèse interne research_duniter_forum.md (§3). Le vote "
            "(2026-02-04 → 2026-03-06) était en cours au moment de la "
            "recherche : texte intégral non disponible, clauses condensées "
            "depuis la synthèse. Méthode de vote : 0,01 Ğ1 envoyés aux "
            "adresses désignées (POUR ou CONTRE)."
        ),
    },
    T_COMTECH_OPEN,
    T_COMTECH_ADOPT,
)

for i, c in enumerate(COMTECH_CLAUSES):
    add_clause(
        doc_slug="engagement-comite-tech",
        doc_id=DOC_TECH,
        code=c["code"],
        section=c["section"],
        position=i + 1,
        title=c["title"],
        inertia=c["inertia"],
        content=c["content"],
        version_label="2.0.0-fr",
        created=T_COMTECH_OPEN,
        adopted=T_COMTECH_ADOPT,
        author="Cgeek",
        protocol_id=P_TECHCOMM,
        scope_circles=[C_ROOT, C_TECH],
        tags=["engagement-comite-tech", c["section"]],
    )

# ── Doc 4 : Pacte duniter-g1 (20 clauses, settingKeys) ───────

_org_v1 = next(o for o in ORGS_V1 if o["slug"] == "duniter-g1")

A1_TEXT = (
    "Nous, membres de la toile de confiance Ğ1, nous donnons cet outil pour "
    "décider ensemble sans remettre notre souveraineté à quiconque.\n\n"
    "**Autonomie** — chacun décide de ce qui ne concerne que lui. La monnaie "
    "libre garantit à chacun sa part de création monétaire ; notre gouvernance "
    "garantit à chacun sa part de décision.\n\n"
    "**Équilibre** — aucun pouvoir sans contrepoids : tout mandat est borné, "
    "révocable et rend compte ; toute règle porte son inertie et reste "
    "amendable par ceux qu'elle gouverne.\n\n"
    "**Liaison** — la confiance circule de personne à personne, comme les "
    "certifications : nos décisions relient ceux qu'elles concernent, du petit "
    "cercle à la toile entière.\n\n"
    "Notre finalité : une toile de confiance vivante où la monnaie et la "
    "décision appartiennent également à chacun — sacralisée dans ce Pacte, "
    "jamais immuable."
)

PACT_CLAUSES = [
    # (code, section, title, inertia, settingKey, settingValue, content)
    ("A1", "preambule", "Notre finalité", "high", None, None, A1_TEXT),
    (
        "P1", "triage", "Petit cercle", "standard",
        "triage.smallGroupMax", QUAL_V1["small_group_max"],
        "Jusqu'à 5 personnes concernées, la voie légère suffit : demander "
        "l'avis de chacun, écouter, puis décider.",
    ),
    (
        "P2", "triage", "Grand corps", "standard",
        "triage.collectiveMin", QUAL_V1["collective_wot_min"],
        "Au-delà de 50 personnes concernées, la décision passe par le "
        "protocole des grands corps — pour la toile Ğ1 : le vote binaire "
        "inertiel, héritage Toile de Confiance.",
    ),
    (
        "P3", "triage", "Tour de consentement", "standard",
        "triage.consentMax", 7,
        "Jusqu'à 7 personnes, un tour d'accord suffit : sans objection "
        "argumentée maintenue, c'est adopté.",
    ),
    (
        "P4", "triage", "Fenêtre d'objection", "standard",
        "triage.objectionWindowHours", 48,
        "Toute décision sous mandat ou en voie légère reste ouverte à "
        "l'objection pendant 48 heures avant d'entrer en vigueur.",
    ),
    (
        "P5", "triage", "Fenêtre d'avis", "standard",
        "triage.adviceWindowHours", 72,
        "Quand la voie est l'avis, chacun dispose de 72 heures pour le donner "
        "avant que l'auteur décide.",
    ),
    (
        "P6", "triage", "Temps de formulation", "standard",
        "triage.framingDays", 14,
        "Quatorze jours pour s'instruire et formuler des contre-propositions "
        "avant l'ouverture du vote des décisions qui engagent.",
    ),
    (
        "P7", "triage", "Affluence", "standard",
        "triage.concernEscalateRatio", 0.5,
        "Quand la moitié des personnes concernées se déclare, le traitement "
        "collectif devient obligatoire : élargir le périmètre, ou motiver "
        "publiquement son maintien.",
    ),
    (
        "P8", "triage", "Récurrence", "standard",
        "triage.recurrenceThreshold", 3,
        "À la troisième décision semblable, l'outil propose de réclamer un "
        "mandat ou de créer une règle.",
    ),
    (
        "P9", "triage", "Épreuve du réel", "standard",
        "triage.reviewDelayDays", 90,
        "Toute décision structurante ou irréversible est revue au plus tard "
        "90 jours après son adoption : ça tient, à revoir, ou à révoquer.",
    ),
    (
        "P10", "triage", "Matière exigée", "standard",
        "triage.requireEffects", "binding",
        "Ouvrir un vote collectif sur une décision qui engage exige d'énoncer "
        "au moins un effet recherché — et une cible mesurable quand elle est "
        "structurante.",
    ),
    (
        "M1", "protocoles", "Protocole de consentement", "high",
        "protocols.consent", P_CONSENT,
        "Le consentement est notre modalité de base et le repli universel : "
        "toute clé de protocole non résolue y retombe.",
    ),
    (
        "M2", "protocoles", "Protocole des grands corps", "high",
        "protocols.large", P_WOT,
        "Au-delà du grand corps, la toile Ğ1 vote pour/contre sous la formule "
        "d'inertie héritée de la Toile de Confiance — l'outil de dernier "
        "recours des très grands corps.",
    ),
    (
        "M3", "protocoles", "Protocole nuancé", "high",
        "protocols.nuanced", P_NUANCED,
        "Entre le tour de consentement et le grand corps : le vote nuancé à "
        "6 niveaux — chacun se prononce en nuances, pas en camps.",
    ),
    (
        "M4", "protocoles", "Réglage collectif", "high",
        "protocols.parametric", P_PARAMETRIC,
        "Quand la décision est un nombre, un taux ou un montant : décider au "
        "curseur — la médiane basse éclaire, le geste du garant cristallise.",
    ),
    (
        "M5", "protocoles", "Élection sans candidat", "high",
        "protocols.election", P_ELECTION,
        "Pour désigner une personne : élection sans candidat, vote blanc "
        "possible, départage entre ex æquo décidé d'avance.",
    ),
    (
        "N1", "inertie", "Inertie basse", "max",
        "protocols.clauseByInertia.low", P_INERTIA_LOW,
        "Amender une clause d'inertie basse (annexes, recommandations) : "
        "G = 0,1 — M = 50 %.",
    ),
    (
        "N2", "inertie", "Inertie standard", "max",
        "protocols.clauseByInertia.standard", P_WOT,
        "Amender une clause d'inertie standard (engagements fondamentaux et "
        "techniques) : G = 0,2 — M = 50 %.",
    ),
    (
        "N3", "inertie", "Inertie haute", "max",
        "protocols.clauseByInertia.high", P_INERTIA_HIGH,
        "Amender une clause d'inertie haute (formule de vote) : "
        "G = 0,4 — M = 60 %.",
    ),
    (
        "N4", "inertie", "Inertie très haute — méta-réglage", "max",
        "protocols.clauseByInertia.max", P_INERTIA_MAX,
        "Amender le réglage de l'inertie lui-même exige la quasi-unanimité : "
        "G = 0,6 — M = 66 %. La protection des règles de modification — "
        "l'héritage N1 des actes Ğ1.",
    ),
]

add_doc(
    DOC_PACT,
    "pacte-duniter-g1",
    "Pacte de la toile Ğ1",
    "pact",
    "Notre contrat social — sacralisé, jamais immuable",
    {
        "sources": [],
        "notes": (
            f"Import v1 : organisation « {_org_v1['name']} » "
            f"({_org_v1['org_type']}, transparente) — "
            f"« {_org_v1['description']} » Seuils importés du protocole de "
            f"qualification v1 « {QUAL_V1['name']} » "
            f"(small_group_max={QUAL_V1['small_group_max']}, "
            f"collective_wot_min={QUAL_V1['collective_wot_min']}, "
            f"modalités par défaut : {', '.join(QUAL_MODALITIES_V1)})."
        ),
    },
    T_GENESIS,
)

for i, (code, section, title, inertia, key, value, content) in enumerate(PACT_CLAUSES):
    add_clause(
        doc_slug="pacte-duniter-g1",
        doc_id=DOC_PACT,
        code=code,
        section=section,
        position=i + 1,
        title=title,
        inertia=inertia,
        content=content,
        version_label="1.0.0",
        created=T_CERT_DRAFT,
        adopted=T_GENESIS,
        author="Cgeek",
        protocol_id=P_CONSENT,
        scope_circles=[C_ROOT],
        tags=["pacte-duniter-g1", section],
        setting_key=key,
        setting_value=value,
    )

# ─────────────────────────────────────────────────────────────
# Décisions historiques
# ─────────────────────────────────────────────────────────────

# ── Runtime Upgrade 1100 — adoptée, structurelle, gravée ─────

D_RUNTIME = uid("decision:runtime-upgrade-1100")
_runtime_decided = "2026-05-20T17:00:00Z"
decisions.append(
    entity(
        D_RUNTIME,
        "2026-05-02T09:00:00Z",
        _runtime_decided,
        authorId=PID["Cgeek"],
        title="Runtime Upgrade 1100",
        body=(
            RUNTIME_META["description"]
            + "\n\n"
            + RUNTIME_META["context"]
            + "\n\n**Processus (import v1 — titre v1 : « Runtime Upgrade », "
            "type runtime_upgrade) :**\n"
            + _steps_md(RUNTIME_STEPS)
        ),
        brief={
            "context": RUNTIME_META["context"],
            "sources": [
                {"label": "Dépôt Duniter V2", "url": "https://git.duniter.org/nodes/rust/duniter-v2s"},
            ],
            "effects": [
                {
                    "label": "Réseau stable après la mise à niveau",
                    "target": "zéro anomalie critique sous 7 jours",
                    "measured": {
                        "note": "Aucune anomalie critique observée sur 7 jours — métriques réseau nominales.",
                        "at": "2026-05-27T17:00:00Z",
                        "byId": PID["Moul"],
                    },
                },
                {
                    "label": "Nœuds validateurs synchronisés",
                    "target": "100 % des validateurs à jour sous 48 h",
                    "measured": {
                        "note": "Ensemble des validateurs en ligne à jour en 36 h.",
                        "at": "2026-05-22T09:00:00Z",
                        "byId": PID["Tuxmain"],
                    },
                },
            ],
        },
        resources={
            "note": "Coordination des forgerons pour la synchronisation des nœuds — fenêtre de maintenance.",
            "unit": "heures",
        },
        tags=["runtime", "upgrade", "on-chain"],
        reversibility="costly",
        weight="structural",
        urgent=False,
        scope={"selfOnly": False, "circleIds": [C_ROOT, C_FORGE, C_TECH], "personIds": []},
        route="collective",
        triageRule="R5",
        routeOverridden=False,
        protocolId=P_TECHCOMM,
        status="adopted",
        decidedAt=_runtime_decided,
        review={"dueAt": "2026-08-18T17:00:00Z"},
        stewardIds=[PID["Cgeek"]],
        measurerIds=[PID["Moul"], PID["Tuxmain"]],
        visibility="collective",
        engraving={
            "sha256": hashlib.sha256(
                f"runtime-upgrade-1100|{_runtime_decided}".encode()
            ).hexdigest(),
            "engravedAt": _runtime_decided,
            "proofLevel": "local",
        },
    )
)

# ── Évolution Licence Ğ1 v0.4.0 — en formulation, versions proposées ──

D_LICENCE = uid("decision:evolution-licence-0.4.0")
decisions.append(
    entity(
        D_LICENCE,
        "2026-07-28T09:00:00Z",
        "2026-08-01T09:00:00Z",
        authorId=PID["Poka"],
        title="Évolution Licence G1 v0.4.0",
        body=(
            LICENCE_META["description"]
            + "\n\n**Processus (import v1) :**\n"
            + _steps_md(LICENCE_STEPS)
        ),
        baselineNote="Aujourd'hui : Licence Ğ1 v0.3.0 en vigueur.",
        brief={
            "context": LICENCE_META["context"],
            "sources": [
                {
                    "label": "Préparation licence v0.4.0 (topic 32375)",
                    "url": "https://forum.monnaie-libre.fr/t/preparation-dune-proposition-devolution-de-la-licence-1/32375",
                },
                {
                    "label": "Proposition Charte 1.0, rejetée (topic 31066)",
                    "url": "https://forum.monnaie-libre.fr/t/proposition-charte-1-0/31066",
                },
            ],
            "effects": [
                {
                    "label": "Une licence clarifiée, comprise par les nouveaux certificateurs",
                    "target": "adoption de la v0.4.0 au seuil WoT",
                }
            ],
        },
        resources={
            "note": "Co-rédaction et animation du vote — temps bénévole des contributeurs.",
            "unit": "heures",
        },
        tags=["licence", "certification"],
        reversibility="costly",
        weight="structural",
        urgent=False,
        scope={"selfOnly": False, "circleIds": [C_ROOT], "personIds": []},
        route="collective",
        triageRule="R5",
        routeOverridden=False,
        protocolId=P_NUANCED,
        status="framing",
        windowEndsAt="2026-08-15T09:00:00Z",
        stewardIds=[PID["Poka"]],
        measurerIds=[],
        visibility="collective",
    )
)

# Versions proposées (liées aux clauses cert E3 et E7, contre-propositions v0.4.0)
versions.append(
    entity(
        uid("version:engagement-certification:E3:0.4.0-draft"),
        "2026-08-05T09:00:00Z",
        clauseId=uid("clause:engagement-certification:E3"),
        decisionId=D_LICENCE,
        versionLabel="0.4.0-draft",
        content=(
            "Je me suis assuré de connaître suffisamment la personne qui gère "
            "cette clé publique : je sais la recontacter par au moins deux "
            "canaux indépendants, je suis en mesure de repérer un double-compte, "
            "et je m'engage à signaler tout doute aux experts de la communauté. "
            "« Avoir vu » quelqu'un ne suffit pas."
        ),
        status="proposed",
    )
)
versions.append(
    entity(
        uid("version:engagement-certification:E7:0.4.0-draft"),
        "2026-08-05T09:00:00Z",
        clauseId=uid("clause:engagement-certification:E7"),
        decisionId=D_LICENCE,
        versionLabel="0.4.0-draft",
        content=(
            "J'ai rencontré la personne physiquement (préférable), **OU** j'ai "
            "vérifié à distance le lien personne / clé publique par au moins "
            "trois moyens de communication différents et indépendants, dont un "
            "échange en visio en direct."
        ),
        status="proposed",
    )
)

# ─────────────────────────────────────────────────────────────
# Mandats (3) + décisions d'origine, session de vote, votes
# ─────────────────────────────────────────────────────────────

_m_comtech = next(m for m in MANDATES_V1 if m["mandate_type"] == "techcomm")
_m_smith = next(m for m in MANDATES_V1 if m["mandate_type"] == "smith")
_m_forum = next(m for m in MANDATES_V1 if m["mandate_type"] == "custom")

# ── Mandat 1 : Comité Technique — actif, holder Cgeek, 1 rapport dû ──

D_M_COMTECH = uid("decision:mandat-comite-technique")
M_COMTECH = uid("mandate:comite-technique")
decisions.append(
    entity(
        D_M_COMTECH,
        "2025-08-20T09:00:00Z",
        "2025-09-10T18:00:00Z",
        authorId=PID["Elois"],
        title=_m_comtech["title"],
        body=(
            _m_comtech["description"]
            + "\n\n**Parcours (import v1 — type techcomm) :**\n"
            + _steps_md(_m_comtech["steps"])
        ),
        tags=["mandat", "comite-technique", "v1:techcomm"],
        reversibility="costly",
        weight="binding",
        urgent=False,
        scope={"selfOnly": False, "circleIds": [C_ROOT], "personIds": []},
        route="collective",
        triageRule="R5",
        routeOverridden=False,
        protocolId=P_NUANCED,
        createsMandate={
            "title": _m_comtech["title"],
            "domainCircleIds": [C_TECH],
            "domainTags": ["runtime", "code", "on-chain"],
            "durationDays": 365,
            "reportEveryDays": 180,
        },
        status="adopted",
        decidedAt="2025-09-10T18:00:00Z",
        stewardIds=[PID["Cgeek"]],
        measurerIds=[],
        visibility="collective",
    )
)
mandates = [
    entity(
        M_COMTECH,
        "2025-09-15T09:00:00Z",
        title=_m_comtech["title"],
        holderId=PID["Cgeek"],
        originDecisionId=D_M_COMTECH,
        domain={"circleIds": [C_TECH], "tags": ["runtime", "code", "on-chain"]},
        startsAt="2025-09-15T09:00:00Z",
        endsAt="2026-09-15T09:00:00Z",
        electorCircleId=C_ROOT,
        nominationMethod="nuanced-vote",
        reports=[{"dueAt": "2026-03-15T09:00:00Z"}],  # rapport de mi-mandat dû, non remis
        status="active",
    )
]

# ── Mandat 2 : Administrateur des Forgerons — proposé, EN vote nuancé ──
# v1 : status 'voting' (rotation 2026-Q1) ; la session v2 est positionnée
# OUVERTE à la date du seed (2026-07-25 → 2026-08-24) — écart documenté.

D_M_SMITH = uid("decision:mandat-admin-forgerons")
M_SMITH = uid("mandate:admin-forgerons")
S_SMITH = uid("session:mandat-admin-forgerons")
decisions.append(
    entity(
        D_M_SMITH,
        "2026-07-10T09:00:00Z",
        "2026-07-25T09:00:00Z",
        authorId=PID["Inso"],
        title=_m_smith["title"],
        body=(
            _m_smith["description"]
            + "\n\n**Parcours (import v1 — type smith, statut v1 : voting) :**\n"
            + _steps_md(_m_smith["steps"])
        ),
        tags=["mandat", "forgerons", "v1:smith"],
        reversibility="costly",
        weight="binding",
        urgent=False,
        scope={"selfOnly": False, "circleIds": [C_ROOT, C_FORGE], "personIds": []},
        route="collective",
        triageRule="R5",
        routeOverridden=False,
        protocolId=P_NUANCED,
        createsMandate={
            "title": "Administrateur des Forgerons",
            "domainCircleIds": [C_FORGE],
            "domainTags": ["forge", "noeuds"],
            "durationDays": 180,
        },
        status="voting",
        windowEndsAt="2026-08-24T09:00:00Z",
        stewardIds=[PID["Inso"]],
        measurerIds=[],
        visibility="collective",
    )
)
mandates.append(
    entity(
        M_SMITH,
        "2026-07-10T09:00:00Z",
        title=_m_smith["title"],
        holderId=PID["Moul"],  # v1 : mandatee = Moul (Forgeron senior)
        originDecisionId=D_M_SMITH,
        domain={"circleIds": [C_FORGE], "tags": ["forge", "noeuds"]},
        startsAt="2026-09-01T09:00:00Z",
        endsAt="2027-03-01T09:00:00Z",
        electorCircleId=C_FORGE,
        nominationMethod="nuanced-vote",
        reports=[],
        status="proposed",
    )
)
sessions = [
    entity(
        S_SMITH,
        "2026-07-25T09:00:00Z",
        decisionId=D_M_SMITH,
        protocolId=P_NUANCED,
        corpusPersonIds=[PID[n] for n in VOTER_NAMES],
        corpusSize=len(VOTER_NAMES),
        opensAt="2026-07-25T09:00:00Z",
        closesAt="2026-08-24T09:00:00Z",
        status="open",
    )
]
votes = [
    entity(
        uid("vote:mandat-admin-forgerons:tuxmain"),
        "2026-07-26T14:00:00Z",
        sessionId=S_SMITH,
        voterId=PID["Tuxmain"],
        value=5,
        comment="Rôle nécessaire — l'embarquement des nouveaux forgerons traîne depuis des mois.",
    ),
    entity(
        uid("vote:mandat-admin-forgerons:hugo"),
        "2026-07-27T10:30:00Z",
        sessionId=S_SMITH,
        voterId=PID["Hugo"],
        value=4,
    ),
    entity(
        uid("vote:mandat-admin-forgerons:matograine"),
        "2026-07-29T19:45:00Z",
        sessionId=S_SMITH,
        voterId=PID["Matograine"],
        value=2,
        comment="Réservé : je préfère une rotation plus courte, renouvelée à chaque trimestre.",
    ),
]

# ── Mandat 3 : Modération Forum — actif, tags forum/moderation ──
# v1 : status 'draft' — la spec Seeds v2 le pose actif (écart documenté).

D_M_FORUM = uid("decision:mandat-moderation-forum")
M_FORUM = uid("mandate:moderation-forum")
decisions.append(
    entity(
        D_M_FORUM,
        "2026-04-10T09:00:00Z",
        "2026-04-25T18:00:00Z",
        authorId=PID["Hugo"],
        title=_m_forum["title"],
        body=(
            _m_forum["description"]
            + "\n\n**Parcours (import v1 — type custom, statut v1 : draft) :**\n"
            + _steps_md(_m_forum["steps"])
        ),
        tags=["mandat", "forum", "moderation", "v1:custom"],
        reversibility="easy",
        weight="binding",
        urgent=False,
        scope={"selfOnly": False, "circleIds": [C_ROOT], "personIds": []},
        route="collective",
        triageRule="R5",
        routeOverridden=False,
        protocolId=P_ELECTION,
        createsMandate={
            "title": _m_forum["title"],
            "domainCircleIds": [C_ROOT],
            "domainTags": ["forum", "moderation"],
            "durationDays": 365,
            "reportEveryDays": 180,
        },
        status="adopted",
        decidedAt="2026-04-25T18:00:00Z",
        stewardIds=[PID["Tortue"]],
        measurerIds=[],
        visibility="collective",
    )
)
mandates.append(
    entity(
        M_FORUM,
        "2026-05-01T09:00:00Z",
        title=_m_forum["title"],
        holderId=PID["Tortue"],
        originDecisionId=D_M_FORUM,
        domain={"circleIds": [C_ROOT], "tags": ["forum", "moderation"]},
        startsAt="2026-05-01T09:00:00Z",
        endsAt="2027-05-01T09:00:00Z",
        electorCircleId=C_ROOT,
        nominationMethod="election-no-candidate",
        reports=[{"dueAt": "2026-11-01T09:00:00Z"}],
        status="active",
    )
)

# ─────────────────────────────────────────────────────────────
# Collectif + Bundle
# ─────────────────────────────────────────────────────────────

collective = {
    "id": COLLECTIVE_ID,
    "slug": "duniter-g1",
    "name": _org_v1["name"],
    "color": _org_v1["color"],  # '#22c55e' (v1 == spec)
    "icon": "i-lucide-network",  # spec Seeds v2 (v1 : i-lucide-globe, noté)
    "template": "free-currency",
    "isTransparent": _org_v1["is_transparent"],
    "pactDocId": DOC_PACT,
    "rootCircleId": C_ROOT,
    "createdAt": T_GENESIS,
    "updatedAt": EXPORTED_AT,
}

bundle = {
    "schemaVersion": 2,
    "exportedAt": EXPORTED_AT,
    "collective": collective,
    "people": people,
    "circles": circles,
    "decisions": decisions,
    "concerns": [],
    "objections": [],
    "advices": [],
    "assents": [],
    "mandates": mandates,
    "docs": docs,
    "clauses": clauses,
    "versions": versions,
    "protocols": protocols,
    "sessions": sessions,
    "votes": votes,
}

# ─────────────────────────────────────────────────────────────
# Validation interne (invariants) puis écriture
# ─────────────────────────────────────────────────────────────


def _validate() -> None:
    ids: set[str] = set()
    for key in (
        "people", "circles", "decisions", "mandates", "docs",
        "clauses", "versions", "protocols", "sessions", "votes",
    ):
        for item in bundle[key]:
            assert item["id"] not in ids, f"id dupliqué : {item['id']} ({key})"
            ids.add(item["id"])
            assert item["collectiveId"] == COLLECTIVE_ID, f"collectiveId manquant ({key})"
            assert item["createdAt"] and item["updatedAt"], f"dates manquantes ({key})"

    decision_ids = {d["id"] for d in decisions}
    version_ids = {v["id"] for v in versions}
    protocol_ids = {p["id"] for p in protocols}
    person_ids = {p["id"] for p in people}
    circle_ids = {c["id"] for c in circles}
    doc_ids = {d["id"] for d in docs}

    for clause in clauses:
        assert clause["docId"] in doc_ids
        assert clause["currentVersionId"] in version_ids, f"version manquante : {clause['code']}"
    for version in versions:
        assert version["decisionId"] in decision_ids, f"décision manquante : {version['id']}"
    for decision in decisions:
        assert decision["authorId"] in person_ids
        if decision.get("protocolId"):
            assert decision["protocolId"] in protocol_ids
        for cid in decision["scope"]["circleIds"]:
            assert cid in circle_ids
    for mandate in mandates:
        assert mandate["holderId"] in person_ids
        assert mandate["originDecisionId"] in decision_ids
        assert mandate["electorCircleId"] in circle_ids
    for session_ in sessions:
        assert session_["decisionId"] in decision_ids
        assert session_["protocolId"] in protocol_ids
    for vote in votes:
        assert vote["voterId"] in person_ids
        assert vote["sessionId"] in {s["id"] for s in sessions}

    # Décision fondatrice pour CHAQUE clause courante
    by_id_version = {v["id"]: v for v in versions}
    by_id_decision = {d["id"]: d for d in decisions}
    for clause in clauses:
        version = by_id_version[clause["currentVersionId"]]
        founding = by_id_decision[version["decisionId"]]
        assert founding["status"] == "adopted", f"fondatrice non adoptée : {clause['code']}"
        assert founding["title"].startswith("Adoption fondatrice — ")

    # Invariant consent (importBundle le valide aussi)
    consent = [p for p in protocols if p["method"] == "consent"]
    assert len(consent) == 1

    # Comptages de non-perte
    cert = [c for c in clauses if c["docId"] == DOC_CERT]
    forge = [c for c in clauses if c["docId"] == DOC_FORGE]
    assert len(cert) == 33 and len(forge) == 59
    assert len(people) == 11
    assert sum(1 for p in people if p["wotStatus"] == "smith") == 5
    assert len(mandates) == 3
    assert len(protocols) >= 7


def _dist(doc_id: str) -> dict:
    out: dict[str, int] = {}
    for clause in clauses:
        if clause["docId"] == doc_id:
            out[clause["inertia"]] = out.get(clause["inertia"], 0) + 1
    return dict(sorted(out.items()))


def main() -> None:
    _validate()
    OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
    payload = json.dumps(bundle, ensure_ascii=False, indent=2) + "\n"
    OUT_PATH.write_text(payload, encoding="utf-8")

    print("=" * 64)
    print("Export seed v1 → Bundle v2 : duniter-g1.bundle.json")
    print("=" * 64)
    print(f"Sortie : {OUT_PATH}")
    print(f"Taille : {len(payload.encode('utf-8'))} octets")
    print()
    print("Diff de comptage seed.py (extrait) → bundle (émis) :")
    print(f"  items Certification   : {len(CERT_ITEMS):>3} → {len([c for c in clauses if c['docId'] == DOC_CERT]):>3} clauses  {_dist(DOC_CERT)}")
    print(f"  items Forgeron        : {len(FORGE_ITEMS):>3} → {len([c for c in clauses if c['docId'] == DOC_FORGE]):>3} clauses  {_dist(DOC_FORGE)}")
    print(f"  items Comité Tech     :   0 → {len([c for c in clauses if c['docId'] == DOC_TECH]):>3} clauses (research_duniter_forum.md §3)")
    print(f"  clauses Pacte         :   0 → {len([c for c in clauses if c['docId'] == DOC_PACT]):>3} (settingKeys v2 ; seuils v1 qualification)")
    print(f"  VOTER_NAMES           : {len(VOTER_NAMES):>3} → {len(people):>3} personnes ({sum(1 for p in people if p['wotStatus'] == 'smith')} smiths)")
    print(f"  mandates_data         : {len(MANDATES_V1):>3} → {len(mandates):>3} mandats")
    print(f"  VotingProtocols       : {len(PROTOCOLS_V1):>3} → {len(protocols):>3} protocoles (7 FormulaConfigs portées)")
    print(f"  décisions             :   2 → {len(decisions):>3} (dont {len([d for d in decisions if d['title'].startswith('Adoption fondatrice')])} fondatrices)")
    print(f"  sessions / votes      :   3 → {len(sessions):>3} / {len(votes):>3} (3 sessions de démo v1 NON portées — votes factices)")
    print(f"  versions              :   — → {len(versions):>3} ({len([v for v in versions if v['status'] == 'proposed'])} proposées, licence v0.4.0)")
    print()
    print("voteRecord forgeron :", json.dumps(
        next(d for d in docs if d["id"] == DOC_FORGE)["provenance"]["voteRecord"]["result"],
        ensure_ascii=False,
    ))


if __name__ == "__main__":
    main()
