#!/usr/bin/env python3
"""Import a photo-made prop. Source rights must be stated, never inferred from possession."""
import argparse, hashlib, json, math, sys, struct, io, datetime, subprocess, base64
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'lib'))
import card, glb
from PIL import Image


def pack(doc, binary):
    j = json.dumps(doc, separators=(',', ':')).encode(); j += b' ' * (-len(j) % 4)
    binary += b'\0' * (-len(binary) % 4)
    return struct.pack('<4sII', b'glTF', 2, 28 + len(j) + len(binary)) + struct.pack('<I4s',len(j),b'JSON') + j + struct.pack('<I4s',len(binary),b'BIN\0') + binary


def compact(doc, binary):
    """Drop unreachable image buffers, preserving every referenced accessor and compressed primitive."""
    # The room viewer keeps material/mesh records in userData. They are not asset metadata;
    # serialising them leaks whole extra texture copies as JSON data URLs.
    for node in doc.get('nodes',[]):
        extras=node.get('extras',{})
        for k in ('rec','def'):extras.pop(k,None)
        if 'extras' in node and not extras:node.pop('extras')
    if 'EXT_meshopt_compression' in doc.get('extensionsUsed',[]):return doc,bytes(binary)
    used=set()
    def refs(o, mapping=None):
        if isinstance(o,dict):
            for k,v in o.items():
                if k=='bufferView':
                    if mapping is None:used.add(v)
                    else:o[k]=mapping[v]
                else:refs(v,mapping)
        elif isinstance(o,list):
            for v in o:refs(v,mapping)
    refs(doc);views=doc.get('bufferViews',[]);mapping={old:new for new,old in enumerate(sorted(used))};out=bytearray();newviews=[]
    for old in sorted(used):
        view=dict(views[old]);off=view.get('byteOffset',0);blob=binary[off:off+view['byteLength']];out.extend(b'\0'*(-len(out)%4));view['byteOffset']=len(out);view['buffer']=0;out.extend(blob);newviews.append(view)
    refs(doc,mapping);doc['bufferViews']=newviews;doc['buffers']=[{'byteLength':len(out)}];return doc,bytes(out)


def normalize(src, scale=1, yaw=0):
    d,b,_ = glb.read(src)
    if any('uri' in x for x in d.get('buffers',[])+d.get('images',[])):
        raise ValueError('GLB must embed all buffers and textures')
    # Cap ordinary embedded textures without touching geometry buffers or old releases.
    b=bytearray(b)
    for im in d.get('images',[]):
        bv=d['bufferViews'][im['bufferView']]; chunk=bytes(b[bv.get('byteOffset',0):bv.get('byteOffset',0)+bv['byteLength']])
        try:
            pic=Image.open(io.BytesIO(chunk)); pic.thumbnail((1024,1024)); out=io.BytesIO(); pic.save(out,format='PNG'); blob=out.getvalue()
        except Exception as e:
            dims=glb._img_size(d,bytes(b),im)
            if not dims[0] or max(dims[:2])>1024: raise ValueError('Unsupported or oversized embedded texture') from e
            continue
        b.extend(b'\0'*(-len(b)%4)); off=len(b); b.extend(blob)
        im['bufferView']=len(d.setdefault('bufferViews',[])); im['mimeType']='image/png'; d['bufferViews'].append({'buffer':0,'byteOffset':off,'byteLength':len(blob)})
    m=glb.measure(src); lo,hi=m['bounds']['min'],m['bounds']['max']
    cx,cz=(lo[0]+hi[0])/2,(lo[2]+hi[2])/2
    a=math.radians(yaw); co,si=math.cos(a),math.sin(a)
    # Matrix rotates the stated front to +Z and puts the bottom at Y=0, units metres.
    mat=[scale*co,0,-scale*si,0, 0,scale,0,0, scale*si,0,scale*co,0,
         -scale*(co*cx+si*cz),-scale*lo[1],-scale*(-si*cx+co*cz),1]
    sc=d['scenes'][d.get('scene',0)]; idx=len(d.setdefault('nodes',[])); d['nodes'].append({'name':'library-props-frame','matrix':mat,'children':sc['nodes']}); sc['nodes']=[idx]
    d['buffers'][0]['byteLength']=len(b)
    d,b=compact(d,bytes(b))
    return pack(d,b)


def ingest(args):
    root=Path(args.root); source=Path(args.glb); provenance=json.loads(Path(args.provenance).read_text())
    provenance.setdefault('source_sha256',hashlib.sha256(source.read_bytes()).hexdigest())
    if args.metres_per_unit<=0 or not math.isfinite(args.metres_per_unit) or not math.isfinite(args.yaw):raise ValueError('scale must be positive and scale/yaw finite')
    for k in ['method','models','source','date','licence_evidence']:
        if not provenance.get(k): raise ValueError('provenance requires '+k)
    cls=args.asset_class; kind,group,leaf=cls.split('.')
    tree=json.loads((root/'taxonomy/tree.json').read_text())
    if leaf not in tree.get(kind,{}).get('groups',{}).get(group,{}).get('classes',[]): raise ValueError('class absent from taxonomy: '+cls)
    existing_id=getattr(args,'existing_id',None)
    existing=card.active(card.load_all(root)[existing_id]) if existing_id else None
    binary=source.read_bytes() if existing else normalize(source,args.metres_per_unit,args.yaw); sha=hashlib.sha256(binary).hexdigest()
    if existing and existing['files']['glb'] != 'sha256:'+sha:raise ValueError('existing-id GLB does not match the pinned blob')
    dest=root/'store/blobs'/f'{sha}.glb';dest.parent.mkdir(parents=True,exist_ok=True)
    if not dest.exists():dest.write_bytes(binary)
    m=glb.measure(dest); cid=existing_id or cls+'/'+args.slug
    versions=card.load_all(root).get(cid,[])
    # Every import sees the actual model thumbnail, never its generation prompt.
    from enrich_library import vision
    image_hash=hashlib.sha256(Path(args.thumbnail).read_bytes()).hexdigest()
    if versions and versions[-1].get('visual',{}).get('image_sha256')==image_hash:
        visual=versions[-1]['visual']
    elif existing and (root/'embeddings.json').exists() and (vk:=hashlib.sha256((existing['id']+'@'+existing['release']).encode()).hexdigest()) in (vs:=json.loads((root/'embeddings.json').read_text())['assets']):
        visual=vs[vk]
    else:
        visual=vision([{'thumbnail':str(Path(args.thumbnail).resolve())}])[0][1]
    from visual_safety import cached
    safety=cached(visual['description'],root)
    visual['safety']=safety
    hazard=provenance.get('third_party_artwork') or provenance.get('person') or safety['blocked']
    reason='third-party artwork or person' if hazard else ('explicit opt-out' if args.no_reuse else '')
    reuse={'omg':not bool(reason),'omu':not bool(reason),'reason':reason}
    same = bool(versions and versions[-1]['files']['glb']=='sha256:'+sha and versions[-1].get('reuse')==reuse)
    media=root/'media'/sha;media.mkdir(parents=True,exist_ok=True)
    def jpg(src,name):
        im=Image.open(src).convert('RGB');im.thumbnail((1024,1024)); buf=io.BytesIO();im.save(buf,format='JPEG',quality=88); content=buf.getvalue();p=media/(Path(name).stem+'-'+hashlib.sha256(content).hexdigest()[:16]+'.jpg');
        if not p.exists():p.write_bytes(content)
        return str(p.relative_to(root))
    thumb=jpg(args.thumbnail,'thumbnail.jpg');photos=[jpg(p,f'source-{i+1}.jpg') for i,p in enumerate(args.photo)]
    visual['thumbnail_sha256']=hashlib.sha256((root/thumb).read_bytes()).hexdigest()
    if same or existing:
        c=existing or versions[-1]
        if existing and 'reuse' in c and not all(c['reuse'].get(k,False) for k in ['omg','omu']):reuse=c['reuse']
        c.update(thumbnail=thumb,source_photos=photos,provenance={**c['provenance'],**provenance},reuse=reuse,visual=dict(visual,model='forge:build-gemini-4',image_sha256=image_hash))
        card.write(root,c);return c
    c={'id':cid,'version':len(versions)+1,'name':args.name,'summary':args.name+' rebuilt from photographs.', 'tags':['photo-made',group,leaf], 'style':'realistic','status':'legacy',
       'frame':{'contract':'props/+Z-front-ground','unit':'m','source':'authored','up':'+Y','front':'+Z','origin':'ground-centre','units':'metres'},
       'size':{'source':'measured','measured_m':{'w':m['size_xyz'][0],'h':m['size_xyz'][1],'l':m['size_xyz'][2]},'scale_to_class':1},
       'bounds':dict(m['bounds'],source='measured',footprint={'source':'measured','minX':m['bounds']['min'][0],'maxX':m['bounds']['max'][0],'minZ':m['bounds']['min'][2],'maxZ':m['bounds']['max'][2]},collider={'source':'measured','parts':[{'type':'box','x':0,'y':m['size_xyz'][1]/2,'z':0,'hx':m['size_xyz'][0]/2,'hy':m['size_xyz'][1]/2,'hz':m['size_xyz'][2]/2}]}),'render':dict(m,source='measured'), 'provenance':provenance,
       'licence':{'kind':provenance.get('licence','source-attested'),'evidence':provenance['licence_evidence'],'game_use':not bool(reason)},
       'reuse':reuse,'visual':dict(visual,model='forge:build-gemini-4',image_sha256=hashlib.sha256(Path(args.thumbnail).read_bytes()).hexdigest()),'placement':{'role':'prop','source':'authored'},'thumbnail':thumb,'source_photos':photos,'quality':{'gates':'legacy','note':'No game visual approval claimed'},'files':{'glb':'sha256:'+sha},'deps':[]}
    c['release']=card.release_hash(c);card.write(root,c);return c


def parser():
    p=argparse.ArgumentParser(description=__doc__);p.add_argument('glb');p.add_argument('--root',default=str(ROOT));p.add_argument('--photo',action='append',required=True);p.add_argument('--thumbnail',required=True);p.add_argument('--provenance',required=True);p.add_argument('--class',dest='asset_class',required=True);p.add_argument('--slug',required=True);p.add_argument('--name',required=True);p.add_argument('--metres-per-unit',type=float,default=1);p.add_argument('--yaw',type=float,default=0,help='degrees around Y to map input front to +Z');p.add_argument('--no-reuse',action='store_true');p.add_argument('--existing-id',help='attach photo provenance to a pinned, already framed library GLB without changing its parts, scale or release');return p
if __name__=='__main__':
    a=parser().parse_args();c=ingest(a);print(json.dumps({'id':c['id'],'release':c['release'],'reuse':c['reuse']}))
