#!/usr/bin/env python3
"""AI ATLAS: read-only measurements for this guide's uncompressed GLB files.
Python3.9+ standard library. No network, Blender, installed packages, or writes.
Supported subset: glTF2, one embedded BIN buffer, FLOAT VEC3 positions/normals,
triangle primitives, optional unsigned integer indices, no sparse/compressed data.
Local mesh bounds are NOT world bounds. This is not the Khronos glTF Validator.
"""
import argparse
import json
import math
from pathlib import Path
import struct
import sys

def need(condition,message):
    if not condition:raise ValueError(message)

def read_model(path):
    raw=Path(path).read_bytes()
    need(len(raw)>=20,'GLB header is truncated')
    magic,version,total=struct.unpack_from('<4sII',raw)
    need(magic==b'glTF' and version==2 and total==len(raw),'GLB magic/version/length mismatch')
    chunks=[];offset=12
    while offset<len(raw):
        need(offset+8<=len(raw),'Truncated chunk header')
        size,kind=struct.unpack_from('<II',raw,offset)
        need(size%4==0 and offset+8+size<=len(raw),'Chunk alignment/length mismatch')
        chunks.append((kind,raw[offset+8:offset+8+size]));offset+=8+size
    need(len(chunks)==2 and [c[0] for c in chunks]==[0x4e4f534a,0x004e4942],'Expected JSON then BIN chunks')
    def no_constants(value):raise ValueError('Non-finite JSON constant: '+value)
    model=json.loads(chunks[0][1].decode('utf-8'),parse_constant=no_constants)
    need(model.get('asset',{}).get('version')=='2.0','Expected glTF2.0')
    need(not model.get('extensionsRequired'),'Required extensions are outside this small checker')
    buffers=model.get('buffers',[])
    need(len(buffers)==1 and 'uri' not in buffers[0],'Expected one embedded buffer')
    size=buffers[0]['byteLength'];binary=chunks[1][1]
    need(isinstance(size,int) and 0<=len(binary)-size<=3,'Buffer length/padding mismatch')
    return model,binary[:size],len(raw)

def accessor_values(model,binary,index):
    accessors=model.get('accessors',[])
    need(isinstance(index,int) and 0<=index<len(accessors),'Invalid accessor index')
    acc=accessors[index]
    need('sparse' not in acc and 'bufferView' in acc,'Sparse/missing buffer views are unsupported')
    views=model.get('bufferViews',[]);vi=acc['bufferView']
    need(isinstance(vi,int) and 0<=vi<len(views),'Invalid bufferView index')
    view=views[vi]
    need(not view.get('extensions'),'Compressed bufferView is unsupported')
    need(view.get('buffer',0)==0,'Unexpected buffer number')
    formats={5121:('B',1),5123:('H',2),5125:('I',4),5126:('f',4)}
    components={'SCALAR':1,'VEC2':2,'VEC3':3,'VEC4':4}
    need(acc.get('componentType') in formats and acc.get('type') in components,'Unsupported accessor type')
    fmt,width=formats[acc['componentType']];count=acc.get('count');size=width*components[acc['type']]
    start=view.get('byteOffset',0);length=view.get('byteLength');within=acc.get('byteOffset',0);stride=view.get('byteStride',size)
    need(all(isinstance(v,int) for v in (start,length,within,stride,count)),'Accessor bounds must be integers')
    need(count>0 and start>=0 and length>0 and start+length<=len(binary),'bufferView outside BIN')
    need(within>=0 and stride>=size and stride%width==0 and (start+within)%width==0,'Misaligned accessor or stride')
    need(within+(count-1)*stride+size<=length,'Accessor outside bufferView')
    values=[struct.unpack_from('<'+fmt*components[acc['type']],binary,start+within+i*stride) for i in range(count)]
    need(all(math.isfinite(v) for row in values for v in row),'Non-finite accessor value')
    return values

def inspect(path):
    model,binary,byte_count=read_model(path)
    rows=[];total_triangles=0;total_degenerate=0;total_bad_normals=0
    for mi,mesh in enumerate(model.get('meshes',[])):
        points=[];triangles=0;degenerate=0;bad_normals=0
        for primitive in mesh.get('primitives',[]):
            need(not primitive.get('extensions'),'Compressed/extended primitive is unsupported')
            need(primitive.get('mode',4)==4,'Only triangle primitives are supported')
            pi=primitive.get('attributes',{}).get('POSITION')
            position=accessor_values(model,binary,pi)
            acc=model['accessors'][pi]
            need(acc['type']=='VEC3' and acc['componentType']==5126 and not acc.get('normalized'),'Expected FLOAT VEC3 positions')
            actual_min=[min(p[k] for p in position) for k in range(3)]
            actual_max=[max(p[k] for p in position) for k in range(3)]
            for key,actual in [('min',actual_min),('max',actual_max)]:
                need(key in acc and len(acc[key])==3 and all(abs(a-b)<=1e-5 for a,b in zip(acc[key],actual)),'Position min/max does not match data')
            if 'indices' in primitive:
                ii=primitive['indices'];ia=model['accessors'][ii]
                need(ia['type']=='SCALAR' and ia['componentType'] in (5121,5123,5125),'Expected unsigned scalar indices')
                indices=[v[0] for v in accessor_values(model,binary,ii)]
            else:indices=list(range(len(position)))
            need(len(indices)%3==0 and all(0<=i<len(position) for i in indices),'Invalid triangle index range/count')
            triangles+=len(indices)//3
            for t in range(0,len(indices),3):
                a,b,c=[position[indices[t+k]] for k in range(3)]
                u=[b[k]-a[k] for k in range(3)];v=[c[k]-a[k] for k in range(3)]
                cross=(u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0])
                if sum(x*x for x in cross)<=1e-20:degenerate+=1
            ni=primitive.get('attributes',{}).get('NORMAL')
            if ni is not None:
                normals=accessor_values(model,binary,ni)
                need(model['accessors'][ni]['type']=='VEC3' and model['accessors'][ni]['componentType']==5126 and len(normals)==len(position),'Normal format/count mismatch')
                bad_normals+=sum(abs(math.sqrt(sum(x*x for x in n))-1)>1e-4 for n in normals)
            points.extend(position)
        need(bool(points),'Empty mesh')
        lo=[min(p[k] for p in points) for k in range(3)];hi=[max(p[k] for p in points) for k in range(3)]
        rows.append({'mesh':mi,'name':mesh.get('name',''),'verticesInPrimitives':len(points),'triangles':triangles,
                     'localMin':lo,'localMax':hi,'localDimensions':[hi[k]-lo[k] for k in range(3)],
                     'degenerateTriangles':degenerate,'nonUnitNormals':bad_normals})
        total_triangles+=triangles;total_degenerate+=degenerate;total_bad_normals+=bad_normals
    nodes=[]
    for ni,node in enumerate(model.get('nodes',[])):
        if 'mesh' not in node:continue
        need(isinstance(node['mesh'],int) and 0<=node['mesh']<len(rows),'Invalid node mesh reference')
        nodes.append({'node':ni,'name':node.get('name',''),'mesh':node['mesh'],
                      'translation':node.get('translation',[0,0,0]),'scale':node.get('scale',[1,1,1]),
                      'rotation':node.get('rotation',[0,0,0,1]),'matrix':node.get('matrix')})
    report={'file':Path(path).name,'bytes':byte_count,'meshDefinitions':len(rows),'meshNodes':len(nodes),
            'trianglesInMeshDefinitions':total_triangles,'materials':len(model.get('materials',[])),
            'images':len(model.get('images',[])),'externalImageUris':[x['uri'] for x in model.get('images',[]) if 'uri' in x],
            'degenerateTriangles':total_degenerate,'nonUnitNormals':total_bad_normals,'meshes':rows,'nodes':nodes,
            'scope':'Educational subset inspection only. Mesh-local dimensions use glTF Y-up. No world-transform accumulation, manifold/intersection test, outward-normal proof, texture rendering, engine performance or full glTF conformance test.'}
    return report

def expect_reading_corner(report):
    need((report['meshDefinitions'],report['meshNodes'],report['trianglesInMeshDefinitions'])==(46,46,792),'Expected46 meshes/nodes and792 triangles')
    need(report['images']==0 and report['degenerateTriangles']==0 and report['nonUnitNormals']==0,'Unexpected image or geometry warning')
    matches=[n for n in report['nodes'] if n['name']=='Floor_3m_x_2m']
    need(len(matches)==1,'Expected one named floor')
    floor=matches[0]
    need(floor['scale']==[1,1,1] and floor['rotation']==[0,0,0,1] and floor['matrix'] is None,'Floor dimensions require an unscaled/unrotated node in this fixture')
    dimensions=report['meshes'][floor['mesh']]['localDimensions']
    need(all(abs(a-b)<1e-5 for a,b in zip(dimensions,(3,.12,2))),'Floor must measure3×.12×2m in glTF Y-up')
    return True

def main():
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('input',type=Path)
    parser.add_argument('--expect-reading-corner',action='store_true',help='Assert the fixed dimensions/counts of the original guide model')
    args=parser.parse_args();report=inspect(args.input)
    if args.expect_reading_corner:report['readingCornerChecksPassed']=expect_reading_corner(report)
    print(json.dumps(report,ensure_ascii=False,indent=2,allow_nan=False))
if __name__=='__main__':
    try:main()
    except (ValueError,OSError,KeyError,IndexError,TypeError,struct.error) as error:
        print('ERROR: '+str(error),file=sys.stderr);sys.exit(1)
