#!/usr/bin/env python3
"""Read only the JSON chunk of two public gallery GLBs. No mesh decoding.

MIT License. Copyright (c) 2026 colinkoko.
Permission is granted, free of charge, to use, copy, modify, and distribute
this original script, including commercially, with this notice retained.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
Third-party gallery assets are not included or licensed by this script.
"""
import argparse
import hashlib
import json
from pathlib import Path
import struct

SPECIMENS = [
    {
        "id": "chair", "label": "Dining chair",
        "sourceUrl": "https://alaya-lab.github.io/KaiNinja/assets/models/results/chair.glb?v=zup30k-s5-skin-20260915",
        "expectedSha256": "4a9265e7e3270bd12bdcc5b1986115606fef7680b064643bbfc55cd82be5806e",
        "galleryDeclaredParts": 15,
    },
    {
        "id": "ceramic-teapot", "label": "Ceramic teapot",
        "sourceUrl": "https://alaya-lab.github.io/KaiNinja/assets/models/results/ceramic-teapot.glb?v=balanced-colors-20260911",
        "expectedSha256": "7bb189d3855699b509fc3ffc10fb5b5070bb7465e1554e7a9840558374b2483f",
        "galleryDeclaredParts": 5,
    },
]


def inspect(path, provenance):
    data = Path(path).read_bytes()
    digest = hashlib.sha256(data).hexdigest()
    if digest != provenance["expectedSha256"]:
        raise ValueError("Specimen bytes changed; do not reuse this snapshot's findings")
    if len(data) < 20:
        raise ValueError("Truncated GLB")
    magic, version, declared_length = struct.unpack_from("<4sII", data)
    if (magic, version, declared_length) != (b"glTF", 2, len(data)):
        raise ValueError("Invalid GLB 2 header or length")
    offset, chunks = 12, []
    while offset < len(data):
        if offset + 8 > len(data):
            raise ValueError("Truncated chunk header")
        length, kind = struct.unpack_from("<II", data, offset)
        start, end = offset + 8, offset + 8 + length
        if length % 4 or end > len(data):
            raise ValueError("Invalid chunk boundary")
        chunks.append((kind, data[start:end]))
        offset = end
    if not chunks or chunks[0][0] != 0x4E4F534A:
        raise ValueError("Expected JSON as first chunk")
    document = json.loads(chunks[0][1])
    nodes, meshes = document.get("nodes", []), document.get("meshes", [])
    scene_index = document.get("scene", 0)
    roots = document["scenes"][scene_index].get("nodes", [])
    reached = set()

    def visit(index):
        if index in reached:
            return
        reached.add(index)
        for child in nodes[index].get("children", []):
            visit(child)

    for root in roots:
        visit(root)
    parts = []
    for index in sorted(reached):
        node = nodes[index]
        if "mesh" not in node:
            continue
        mesh_index = node["mesh"]
        primitives = meshes[mesh_index].get("primitives", [])
        parts.append({
            "nodeIndex": index, "nodeName": node.get("name"),
            "meshIndex": mesh_index, "primitiveCount": len(primitives),
            "materialIndices": [p.get("material") for p in primitives],
            "volume": node.get("extras", {}).get("volume"),
            "partId": node.get("extras", {}).get("part_id"),
        })
    return {
        **provenance, "sha256": digest, "fileBytes": len(data),
        "glbVersion": version, "gltfVersion": document["asset"]["version"],
        "totalNodes": len(nodes), "defaultSceneMeshNodes": len(parts),
        "distinctReferencedMeshes": len({p["meshIndex"] for p in parts}),
        "totalMeshes": len(meshes),
        "meshPrimitives": sum(len(m.get("primitives", [])) for m in meshes),
        "materials": len(document.get("materials", [])),
        "skins": len(document.get("skins", [])),
        "animations": len(document.get("animations", [])),
        "nodesReferencingSkin": sum("skin" in n for n in nodes),
        "primitivesWithJointOrWeightAttributes": sum(
            any(k.startswith(("JOINTS_", "WEIGHTS_")) for k in p.get("attributes", {}))
            for m in meshes for p in m.get("primitives", [])
        ),
        "extensionsRequired": document.get("extensionsRequired", []),
        "parts": parts,
    }


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("chair")
    parser.add_argument("teapot")
    parser.add_argument("--accessed-at", required=True, help="UTC timestamp of the source-file access; reuse the recorded value for comparison")
    args = parser.parse_args()
    results = [inspect(p, spec) for p, spec in zip([args.chair, args.teapot], SPECIMENS)]
    print(json.dumps({
        "schemaVersion": 1,
        "accessedAt": args.accessed_at,
        "method": "GLB header and JSON declarations only; default-scene node traversal, mesh references, skin/animation presence, and SHA-256. No Draco geometry decoding.",
        "sourcePublisher": "Alaya Lab / KaiNinja research team",
        "galleryUrl": "https://alaya-lab.github.io/KaiNinja/",
        "specificationUrl": "https://raw.githubusercontent.com/KhronosGroup/glTF/8e691206fa2e981fc3b8c8bc57e671595576c971/specification/2.0/Specification.adoc",
        "specimens": results,
        "limitations": [
            "Two curated gallery files are not a representative sample or an inference benchmark.",
            "A mesh-node count does not establish connected components, semantic part labels, contact-surface quality, watertightness, or topology quality.",
            "Absence of skins does not prevent rigid node animation; neither file declares animation clips.",
            "No model, decoder, DCC importer, game engine, or runtime performance test was run.",
            "No third-party geometry, texture, or source imagery is redistributed. Public access does not establish reuse permission.",
        ],
    }, indent=2))


if __name__ == "__main__":
    main()
