#!/usr/bin/env python3
"""Objective mesh metrics + integrity manifest for the 2026-09-12 benchmark results.
Reads results.json (published object form or raw list), fetches each asset's current signed
URLs from GET /v1/assets/{id}, downloads model.glb (the engine's raw output) and preview.glb,
and records — without any subjective scoring —
  sha256 / bytes of both files, glTF: meshes, primitives, materials, images (count + pixel size of
  the largest), whether POSITION/NORMAL/TEXCOORD_0 exist, vertex & triangle totals (from accessors),
  bounding box (from POSITION accessor min/max across primitives, node transforms ignored),
  pivot: bbox centre offset from origin and whether the mesh stands on y=0 (min y within 1% of height).
Usage:
  PICOBERRY_API_KEY=... python3 mesh-metrics.py <results.json> <out-dir> [--keep]      # signed URLs from the API (account that owns the assets)
  python3 mesh-metrics.py <results.json> <out-dir> --cdn [<base-url>] [--keep]           # FREE: the published CDN copies, no key needed
     default base: https://cdn.umodeler.com/media/benchmarks/2026-09-12-image-to-3d/  (files <engine>__<input>__<n>.{model,preview}.glb)
Texture pixel sizes need Pillow (pip install pillow); without it the image list records an error and everything else still works."""
import json, os, sys, hashlib, struct, io, urllib.request
from concurrent.futures import ThreadPoolExecutor
BASE = os.environ.get('PICOBERRY_API_BASE', 'https://api.picoberry.ai').rstrip('/'); KEY = os.environ.get('PICOBERRY_API_KEY', '')
CDN_DEFAULT = 'https://cdn.umodeler.com/media/benchmarks/2026-09-12-image-to-3d/'

def api(path):
    r = urllib.request.Request(BASE + path, headers={'Authorization': f'Bearer {KEY}'})
    return json.load(urllib.request.urlopen(r, timeout=60))

def fetch(url, path):
    h = hashlib.sha256(); n = 0
    with urllib.request.urlopen(url, timeout=600) as res, open(path, 'wb') as f:
        while True:
            chunk = res.read(1 << 20)
            if not chunk: break
            f.write(chunk); h.update(chunk); n += len(chunk)
    return h.hexdigest(), n

def parse_glb(path):
    with open(path, 'rb') as f:
        magic, version, length = struct.unpack('<III', f.read(12))
        assert magic == 0x46546C67, 'not a GLB'
        chunks = {}
        while f.tell() < length:
            clen, ctype = struct.unpack('<II', f.read(8)); data = f.read(clen)
            chunks[ctype] = data
    g = json.loads(chunks[0x4E4F534A].decode('utf-8')); bin_ = chunks.get(0x004E4942, b'')
    acc = g.get('accessors', []); bvs = g.get('bufferViews', [])
    verts = tris = 0; has = {'POSITION': False, 'NORMAL': False, 'TEXCOORD_0': False, 'TANGENT': False}
    mn = [float('inf')] * 3; mx = [float('-inf')] * 3; prims = 0; modes = set()
    for m in g.get('meshes', []):
        for p in m.get('primitives', []):
            prims += 1; modes.add(p.get('mode', 4))
            a = p.get('attributes', {})
            for k in has:
                if k in a: has[k] = True
            if 'POSITION' in a:
                pa = acc[a['POSITION']]; verts += pa.get('count', 0)
                if pa.get('min') and pa.get('max'):
                    mn = [min(mn[i], pa['min'][i]) for i in range(3)]; mx = [max(mx[i], pa['max'][i]) for i in range(3)]
            if 'indices' in p: tris += acc[p['indices']].get('count', 0) // 3
            elif 'POSITION' in a: tris += acc[a['POSITION']].get('count', 0) // 3
    imgs = g.get('images', []); img_sizes = []
    try:
        from PIL import Image
        for im in imgs:
            if 'bufferView' in im:
                bv = bvs[im['bufferView']]; off = bv.get('byteOffset', 0); data = bin_[off:off + bv['byteLength']]
                with Image.open(io.BytesIO(data)) as pil: img_sizes.append([pil.size[0], pil.size[1], im.get('mimeType')])
    except Exception as e:
        img_sizes.append(['error', str(e)[:60]])
    size = [mx[i] - mn[i] for i in range(3)] if mn[0] != float('inf') else None
    centre = [(mx[i] + mn[i]) / 2 for i in range(3)] if size else None
    height = size[1] if size else 0
    return {
        'generator': g.get('asset', {}).get('generator'), 'meshes': len(g.get('meshes', [])), 'primitives': prims, 'primitiveModes': sorted(modes),
        'materials': len(g.get('materials', [])), 'images': len(imgs), 'largestImagePx': max((s[:2] for s in img_sizes if isinstance(s[0], int)), default=None),
        'imageSizes': img_sizes, 'vertices': verts, 'triangles': tris, 'attributes': has,
        'bboxMin': mn if size else None, 'bboxMax': mx if size else None, 'sizeXYZ': size, 'bboxCentre': centre,
        'standsOnGround': (abs(mn[1]) <= 0.01 * height) if size and height else None,
        'extensionsUsed': g.get('extensionsUsed', []), 'nodesWithTransform': sum(1 for n in g.get('nodes', []) if any(k in n for k in ('matrix', 'translation', 'rotation', 'scale'))),
    }

def one(run, out_dir, keep, cdn=None):
    if cdn:
        stem = f"{run['engine']}__{run['input']}__{run['repeat']}"
        files = {'model': f"{cdn}{stem}.model.glb", 'preview': f"{cdn}{stem}.preview.glb"}
    else:
        a = api(f"/v1/assets/{run['assetId']}")['data']; files = a.get('files') or {}
    rec = {k: run.get(k) for k in ('engine', 'input', 'repeat', 'assetId')}; rec['files'] = {}
    for kind in ('model', 'preview'):
        url = files.get(kind)
        if not url: rec['files'][kind] = None; continue
        path = os.path.join(out_dir, f"{run['engine']}__{run['input']}__{run['repeat']}.{kind}.glb")
        sha, n = fetch(url, path)
        entry = {'sha256': sha, 'bytes': n}
        if kind == 'model':
            try: entry['gltf'] = parse_glb(path)
            except Exception as e: entry['gltfError'] = str(e)[:120]
        if not keep and kind == 'model': os.remove(path)
        if not keep and kind == 'preview': os.remove(path)
        rec['files'][kind] = entry
    if files.get('stats'):
        try: rec['stats'] = json.load(urllib.request.urlopen(files['stats'], timeout=60))
        except Exception: pass
    elif run.get('stats'):
        rec['stats'] = run['stats']  # CDN mode: the pipeline stats as recorded in results.json
    print(f"{rec['engine']:17s} {rec['input']:14s} #{rec['repeat']} model {rec['files'].get('model',{}).get('bytes',0)/1e6:6.1f}MB tris={rec['files'].get('model',{}).get('gltf',{}).get('triangles')} uv={rec['files'].get('model',{}).get('gltf',{}).get('attributes',{}).get('TEXCOORD_0')} img={rec['files'].get('model',{}).get('gltf',{}).get('largestImagePx')}", flush=True)
    return rec

if __name__ == '__main__':
    src, out_dir = sys.argv[1], sys.argv[2]; keep = '--keep' in sys.argv
    cdn = None
    if '--cdn' in sys.argv:
        i = sys.argv.index('--cdn'); nxt = sys.argv[i + 1] if len(sys.argv) > i + 1 else ''
        cdn = (nxt if nxt.startswith('http') else CDN_DEFAULT).rstrip('/') + '/'
    elif not KEY:
        raise SystemExit('PICOBERRY_API_KEY is not set. Use --cdn to read the published CDN copies without a key.')
    os.makedirs(out_dir, exist_ok=True)
    data = json.load(open(src)); runs = data['runs'] if isinstance(data, dict) else data
    runs = [r for r in runs if r.get('status') == 'succeeded']
    with ThreadPoolExecutor(max_workers=4) as ex: recs = list(ex.map(lambda r: one(r, out_dir, keep, cdn), runs))
    recs.sort(key=lambda r: (r['engine'], r['input'], r['repeat']))
    json.dump({'generatedAt': __import__('datetime').datetime.now(__import__('datetime').timezone.utc).isoformat(timespec='seconds').replace('+00:00', 'Z'), 'note': 'Objective glTF metrics + SHA-256 manifest of each run\'s model.glb (raw engine output) and preview.glb. No visual scoring.', 'runs': recs},
              open(os.path.join(out_dir, 'mesh-metrics.json'), 'w'), indent=1)
    print('wrote', os.path.join(out_dir, 'mesh-metrics.json'))
