"""Assemblage du run-book de déploiement VPS à partir du spec + de la réalité CI.

Le run-book ordonne le déploiement de TOUS les livrables gated du mandat en un
plan de phases. La liste des modules à déployer n'est PAS écrite à la main : elle
est dérivée de `.gitea/workflows/ci.yml` (via q4lib.registry.parse_ci, réutilisé)
puis mise en correspondance BIJECTIVE avec le `module_phase` du spec.

Fonctions PURES (aucune I/O, aucun horodatage) → sortie déterministe, diffable,
re-générable bit-à-bit. Le CLI leur injecte le spec chargé et le résultat de
`parse_ci()` ; les tests injectent des dictionnaires synthétiques (facile à
mettre en défaut → tests négatifs).

Anti-invention (#6) : aucun chiffre métier ; les paramètres non confirmés restent
des CONFIRMATIONS sourcées (owner + source), jamais une valeur fabriquée.
"""

from __future__ import annotations

from typing import Any


def gated_modules(ci: dict, self_module: str) -> list[str]:
    """Chemins de modules gated par le CI, hors le run-book lui-même (SoD)."""
    paths = set(ci.get("job_to_path", {}).values())
    paths.discard(self_module)
    return sorted(paths)


def _path_to_job(ci: dict) -> dict[str, str]:
    """Inverse job→path en path→job. Suppose l'unicité (vérifiée en invariant)."""
    out: dict[str, str] = {}
    for job, path in ci.get("job_to_path", {}).items():
        # En cas de collision on garde le job le plus petit (déterministe) ; la
        # collision elle-même est signalée par l'invariant INV9 du CLI.
        if path not in out or job < out[path]:
            out[path] = job
    return out


def build_runbook(spec: dict, ci: dict) -> dict[str, Any]:
    """Consolide spec + CI en run-book de phases + manifeste agrégé.

    Ne lève pas sur une incohérence de couverture (module manquant / en trop) :
    elle est REMONTÉE dans `manifest.coverage` pour que le CLI/les tests la
    signalent explicitement (un déploiement ne doit pas oublier silencieusement
    un livrable).
    """
    self_module = spec["self_module"]
    module_phase = spec["module_phase"]
    phases_meta = spec["phases"]
    catalogue = spec["confirmations"]

    gated = gated_modules(ci, self_module)
    gated_set = set(gated)
    mapped_set = set(module_phase)
    p2j = _path_to_job(ci)

    # Couverture bijective : les modules mappés == les modules gated (hors self).
    missing_in_map = sorted(gated_set - mapped_set)   # gated mais non planifié
    extra_in_map = sorted(mapped_set - gated_set)     # planifié mais non gated
    bijective = not missing_in_map and not extra_in_map

    phase_ids = [p["id"] for p in phases_meta]
    phase_id_set = set(phase_ids)

    # Modules dont la phase déclarée n'existe pas dans `phases` (spec incohérent).
    unknown_phase = sorted(
        m for m, ph in module_phase.items() if ph not in phase_id_set
    )

    # Assemblage du run-book : une phase = métadonnées + modules gated assignés +
    # confirmations résolues depuis le catalogue.
    runbook: list[dict[str, Any]] = []
    for p in phases_meta:
        mods = sorted(
            m for m, ph in module_phase.items()
            if ph == p["id"] and m in gated_set
        )
        modules = [{"module": m, "ci_job": p2j.get(m)} for m in mods]
        confs = [
            {
                "id": cid,
                "libelle": catalogue.get(cid, {}).get("libelle"),
                "owner": catalogue.get(cid, {}).get("owner"),
                "source": catalogue.get(cid, {}).get("source"),
            }
            for cid in p.get("confirmations", [])
        ]
        runbook.append({
            "order": p["order"],
            "id": p["id"],
            "titre": p["titre"],
            "responsable": p["responsable"],
            "rationale": p["rationale"],
            "depends_on": list(p.get("depends_on", [])),
            "modules": modules,
            "confirmations": confs,
        })

    # Confirmations effectivement référencées par ≥1 phase (détection d'orphelin).
    referenced: set[str] = set()
    for p in phases_meta:
        referenced.update(p.get("confirmations", []))
    orphan_confirmations = sorted(set(catalogue) - referenced)
    unknown_confirmations = sorted(referenced - set(catalogue))

    manifest = {
        "generated_from": f"deploy_spec.json v{spec.get('version')}",
        "reference_cadre": spec.get("reference_cadre"),
        "cible_portage": spec.get("cible_portage"),
        "self_module": self_module,
        "counts": {
            "phases": len(phases_meta),
            "modules": len(gated),
            "modules_mapped": len(mapped_set),
            "confirmations": len(catalogue),
            "confirmations_referenced": len(referenced),
        },
        "coverage": {
            "ci_modules_count": len(gated),
            "mapped_modules_count": len(mapped_set),
            "bijective": bijective,
            "missing_in_map": missing_in_map,
            "extra_in_map": extra_in_map,
            "unknown_phase": unknown_phase,
            "self_module_excluded": self_module,
        },
        "graph": {
            "phase_order": phase_ids,
            "orphan_confirmations": orphan_confirmations,
            "unknown_confirmations": unknown_confirmations,
        },
        "confirmations_ouvertes": sorted(catalogue),
    }

    return {"runbook": runbook, "manifest": manifest}
