#!/usr/bin/env python3
"""Contrôle hors ligne du dossier RenderParcel v1, Python 3.10+, bibliothèque standard.

Ce profil strict contrôle le JSON UI 0.4, les liens et les paramètres de ce cas.
Il ne lance pas ComfyUI et ne remplace pas un validateur JSON Schema universel.
Licence MIT, voir LICENSE.md.
"""
import argparse
from collections import Counter, deque
import hashlib
import json
from pathlib import Path
import sys


class ValidationError(ValueError):
    pass


def require(condition, message):
    if not condition:
        raise ValidationError(message)


def integer(value):
    return isinstance(value, int) and not isinstance(value, bool)


def number(value):
    return isinstance(value, (int, float)) and not isinstance(value, bool)


SOCKETS = {
    "CheckpointLoaderSimple": ([], [("MODEL", "MODEL"), ("CLIP", "CLIP"), ("VAE", "VAE")]),
    "EmptyLatentImage": ([], [("LATENT", "LATENT")]),
    "CLIPTextEncode": ([("clip", "CLIP")], [("CONDITIONING", "CONDITIONING")]),
    "KSampler": ([("model", "MODEL"), ("positive", "CONDITIONING"), ("negative", "CONDITIONING"), ("latent_image", "LATENT")], [("LATENT", "LATENT")]),
    "VAEDecode": ([("samples", "LATENT"), ("vae", "VAE")], [("IMAGE", "IMAGE")]),
    "SaveImage": ([("images", "IMAGE")], [("images", "IMAGE")]),
}


def validate(workflow, manifest):
    require(isinstance(workflow, dict), "Le workflow doit être un objet JSON UI, pas un tableau.")
    required = {"last_node_id", "last_link_id", "nodes", "links", "groups", "config", "extra", "version"}
    require(required <= workflow.keys(), "Champs racine du workflow UI manquants.")
    require(workflow["version"] == 0.4, "Ce validateur attend le format UI 0.4, pas un payload API.")
    nodes, links = workflow["nodes"], workflow["links"]
    require(isinstance(nodes, list) and len(nodes) == 15, "Le dossier v1 contient exactement 15 nœuds.")
    require(isinstance(links, list) and len(links) == 25, "Le dossier v1 contient exactement 25 liens.")
    require(isinstance(workflow["config"], dict) and isinstance(workflow["extra"], dict), "Config/extra invalides.")
    require(isinstance(workflow["groups"], list), "Liste de groupes invalide.")
    for group in workflow["groups"]:
        require(isinstance(group.get("title"), str), "Titre de groupe absent.")
        require(len(group.get("bounding", [])) == 4 and all(number(x) for x in group["bounding"]), "Position de groupe invalide.")
    by_id = {}
    for node in nodes:
        require(isinstance(node, dict), "Nœud non objet.")
        require({"id", "type", "pos", "size", "flags", "order", "mode", "properties", "inputs", "outputs", "widgets_values"} <= node.keys(), "Champs de nœud manquants.")
        key = node["id"]
        require(integer(key) and key > 0 and key not in by_id, "Identifiant de nœud invalide ou doublonné.")
        require(node["type"] in SOCKETS, "Nœud absent du profil core : " + str(node["type"]))
        require(node["mode"] == 0, "Un nœud du dossier est désactivé ou contourné.")
        require(integer(node["order"]) and node["order"] >= 0, "Ordre de nœud invalide.")
        require(isinstance(node["flags"], dict) and isinstance(node["properties"], dict), "Flags/propriétés invalides.")
        for field in ("pos", "size"):
            require(isinstance(node[field], list) and len(node[field]) == 2 and all(number(x) for x in node[field]), "Coordonnées de nœud invalides.")
        expected_inputs, expected_outputs = SOCKETS[node["type"]]
        for field, expected in (("inputs", expected_inputs), ("outputs", expected_outputs)):
            require(isinstance(node[field], list), "Liste de prises invalide.")
            require([(socket.get("name"), socket.get("type")) for socket in node[field]] == expected, "Prises incompatibles pour " + node["type"])
        require(isinstance(node["widgets_values"], list), "Les valeurs des widgets doivent former une liste.")
        by_id[key] = node
    require(set(by_id) == set(range(1, 16)), "Les identifiants de ce dossier doivent aller de 1 à 15.")
    expected_counts = {"CheckpointLoaderSimple": 1, "EmptyLatentImage": 1, "CLIPTextEncode": 4, "KSampler": 3, "VAEDecode": 3, "SaveImage": 3}
    require(Counter(n["type"] for n in nodes) == expected_counts, "Composition des trois branches incorrecte.")
    require(workflow["last_node_id"] == max(by_id), "last_node_id ne correspond pas aux nœuds.")
    link_map, input_use, output_use = {}, {}, {}
    incoming, adjacent = {key: 0 for key in by_id}, {key: [] for key in by_id}
    for link in links:
        require(isinstance(link, list) and len(link) == 6, "Un lien UI doit contenir six éléments.")
        key, origin, origin_slot, target, target_slot, type_name = link
        require(all(integer(v) for v in (key, origin, origin_slot, target, target_slot)), "Identifiants/slots de lien invalides.")
        require(key > 0 and key not in link_map, "Identifiant de lien invalide ou doublonné.")
        require(origin in by_id and target in by_id, "Un lien référence un nœud absent.")
        require(0 <= origin_slot < len(by_id[origin]["outputs"]) and 0 <= target_slot < len(by_id[target]["inputs"]), "Un lien référence une prise absente.")
        source, dest = by_id[origin]["outputs"][origin_slot], by_id[target]["inputs"][target_slot]
        require(type_name == source["type"] == dest["type"], "Type de lien incompatible.")
        require((target, target_slot) not in input_use, "Deux liens alimentent la même entrée.")
        require(dest.get("link") == key, "Lien global différent du lien enregistré dans l’entrée.")
        input_use[target, target_slot] = key
        output_use.setdefault((origin, origin_slot), []).append(key)
        link_map[key] = link
        incoming[target] += 1
        adjacent[origin].append(target)
    require(set(link_map) == set(range(1, 26)) and workflow["last_link_id"] == 25, "Numérotation des liens incohérente.")
    for node in nodes:
        for slot, socket in enumerate(node["inputs"]):
            require((node["id"], slot) in input_use, "Une entrée requise n’est pas connectée.")
        for slot, socket in enumerate(node["outputs"]):
            saved = socket.get("links") or []
            require(isinstance(saved, list) and sorted(saved) == sorted(output_use.get((node["id"], slot), [])), "Références des liens de sortie incohérentes.")
    queue = deque(key for key, count in incoming.items() if count == 0)
    visited = []
    while queue:
        key = queue.popleft()
        visited.append(key)
        for target in adjacent[key]:
            incoming[target] -= 1
            if incoming[target] == 0:
                queue.append(target)
    require(len(visited) == len(nodes), "Le graphe contient un cycle.")
    require(by_id[1]["type"] == "CheckpointLoaderSimple" and by_id[2]["type"] == "EmptyLatentImage" and by_id[3]["type"] == "CLIPTextEncode", "Nœuds communs déplacés ou remplacés.")
    require(by_id[1]["widgets_values"] == [manifest["models"][0]["filename"]], "Checkpoint différent du manifeste.")
    params = manifest["parameters"]
    require(by_id[2]["widgets_values"] == [params["width"], params["height"], params["batch_size"]], "Dimensions/lot différents du manifeste.")
    require(params["width"] == params["height"] == 1024 and params["batch_size"] == 1, "Le profil v1 attend 1024 × 1024, lot 1.")
    require(params["control_after_generate"] == "fixed", "La graine doit rester fixe pour cette comparaison.")
    require(integer(params["seed"]) and 0 <= params["seed"] <= 2**53 - 1, "Graine hors plage entière exacte du format JSON/JavaScript.")
    require(by_id[3]["widgets_values"] == [manifest["negative_prompt"]], "Consigne négative différente du manifeste.")
    sampler_widgets = [params[name] for name in ("seed", "control_after_generate", "steps", "cfg", "sampler_name", "scheduler", "denoise")]
    require(sampler_widgets[2:] == [24, 6.5, "euler", "normal", 1.0], "Paramètres hors profil v1.")
    require(len(manifest["variants"]) == 3, "Trois variantes attendues.")
    used_variant_nodes, prefixes = set(), set()
    for variant in manifest["variants"]:
        positive, sampler, decode, save = [variant[name] for name in ("positive_node", "sampler_node", "decode_node", "save_node")]
        require(not used_variant_nodes.intersection((positive, sampler, decode, save)), "Un nœud appartient à plusieurs variantes.")
        used_variant_nodes.update((positive, sampler, decode, save))
        require([by_id[k]["type"] for k in (positive, sampler, decode, save)] == ["CLIPTextEncode", "KSampler", "VAEDecode", "SaveImage"], "Chaîne de variante incorrecte.")
        require(variant["prompt"] == manifest["common_positive_prompt"] + " " + variant["light"], "La variante doit changer uniquement la phrase de lumière.")
        require(by_id[positive]["widgets_values"] == [variant["prompt"]], "Prompt différent du manifeste.")
        require(by_id[sampler]["widgets_values"] == sampler_widgets, "Widgets KSampler différents du manifeste ou graine non fixe.")
        require(by_id[decode]["widgets_values"] == [], "VAE Decode ne doit pas avoir de widget dans ce profil.")
        prefix = variant["output_prefix"]
        require(prefix.startswith(("RenderParcel/ceramique-v1/", "Prismora/ceramique-v1/")) and ".." not in prefix and "\\" not in prefix and prefix not in prefixes, "Préfixe de sortie non portable ou doublonné.")
        prefixes.add(prefix)
        # Alias limite au namespace : le nom de variante doit rester identique.
        suffix = prefix.split("/", 1)[1]
        accepted_prefixes = [["RenderParcel/" + suffix], ["Prismora/" + suffix]]
        require(by_id[save]["widgets_values"] in accepted_prefixes, "Préfixe SaveImage différent du manifeste.")
        edges = [(1, 1, positive, 0), (1, 0, sampler, 0), (positive, 0, sampler, 1), (3, 0, sampler, 2), (2, 0, sampler, 3), (sampler, 0, decode, 0), (1, 2, decode, 1), (decode, 0, save, 0)]
        require(all(any(link[1:5] == list(edge) for link in links) for edge in edges), "Une branche ne reprend pas la chaîne prévue.")
    require(used_variant_nodes == set(range(4, 16)), "Nœud de variante orphelin.")
    return {"nodes": len(nodes), "links": len(links), "core_node_types": sorted(SOCKETS), "variants": len(prefixes)}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    base = Path(__file__).resolve().parent
    parser.add_argument("--workflow", type=Path, default=base / "workflow-ui.json")
    parser.add_argument("--manifest", type=Path, default=base / "manifest.json")
    parser.add_argument("--checkpoint", type=Path, help="Optionnel : vérifie taille et SHA-256 du fichier déjà présent, sans le charger.")
    args = parser.parse_args()
    try:
        workflow = json.loads(args.workflow.read_text(encoding="utf-8"))
        manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
        result = validate(workflow, manifest)
        if args.checkpoint:
            model = manifest["models"][0]
            require(args.checkpoint.stat().st_size == model["expected_bytes"], "Taille du checkpoint différente de la référence de l’éditeur.")
            digest = hashlib.sha256()
            with args.checkpoint.open("rb") as source:
                for block in iter(lambda: source.read(8 * 1024 * 1024), b""):
                    digest.update(block)
            require(digest.hexdigest() == model["expected_sha256"], "Empreinte du checkpoint différente de la référence de l’éditeur.")
        print(json.dumps({"ok": True, "scope": "structure et graphe du dossier RenderParcel v1", **result, "checkpoint_verified": bool(args.checkpoint), "comfyui_executed": False, "image_generated": False}, ensure_ascii=False, indent=2))
        return 0
    except (OSError, ValueError, KeyError, TypeError, IndexError, AttributeError) as error:
        print(json.dumps({"ok": False, "error": str(error), "comfyui_executed": False, "image_generated": False}, ensure_ascii=False, indent=2))
        return 1


if __name__ == "__main__":
    sys.exit(main())
