"""ALE local rubric v1. Geometry and trajectories, invariant to particle ordering.
This is a task-specific evaluator, not the official 4DCodeBench scorer.
Run in a trusted evaluator environment after candidate execution in an isolated sandbox.
"""
import argparse,json
from pathlib import Path
import numpy as np
from scipy.spatial import cKDTree
from scipy.optimize import linear_sum_assignment
import imageio.v2 as iio
from functools import lru_cache

F=49; FPS=32; SIZE=(640,400)

def video_info(path):
    reader=iio.get_reader(path,input_params=['-threads','1']);meta=reader.get_meta_data();n=sum(1 for _ in reader);reader.close()
    return n,float(meta['fps']),tuple(meta['size'])

@lru_cache(maxsize=2)
def validate(root):
    n,fps,size=video_info(root/'render.mp4')
    assert (n,size)==(F,SIZE) and abs(fps-FPS)<.001,'Video must be 49 frames, 32 fps, 640x400.'
    cam=json.loads((root/'camera.json').read_text());K=np.array(cam['intrinsics']);E=np.array(cam['extrinsic'])
    assert K.shape==(3,3) and E.shape==(4,4) and np.isfinite(K).all() and np.isfinite(E).all(),'Invalid camera'
    assert np.allclose(E[3],[0,0,0,1]) and np.allclose(E[:3,:3].T@E[:3,:3],np.eye(3),atol=1e-4) and np.linalg.det(E[:3,:3])>0,'Invalid camera pose'
    mats={};moving=set()
    for name in ['water','sand']:
        with np.load(root/'dynamics'/f'{name}.npz',allow_pickle=False) as z:
            p=z['pos'];ids=z['ids'];assert p.dtype==np.float32 and p.ndim==3 and p.shape[0]==F and p.shape[2]==3 and 128<=p.shape[1]<=100000,'Invalid persistent trajectories'
            assert np.isfinite(p).all(),'Finite passive particles must exist throughout'
            assert ids.dtype==np.uint16 and len(ids)>0,'Invalid mesh IDs'
            mats[name]=p.copy();moving.update(int(x) for x in ids)
    found=set()
    for file in (root/'meshes').glob('*.npz'):
        mid=int(file.stem);found.add(mid)
        with np.load(file,allow_pickle=False) as z:
            for t in range(F):
                v=z[f'vertices_{t:04d}'];f=z[f'faces_{t:04d}']
                assert v.dtype==np.float32 and v.ndim==2 and v.shape[1]==3 and np.isfinite(v).all(),'Invalid vertices'
                assert f.dtype==np.int32 and f.ndim==2 and f.shape[1]==3 and len(f)>0 and f.min()>=0 and f.max()<len(v),'Invalid triangles'
                if mid not in moving and t:
                    assert np.array_equal(v,z['vertices_0000']) and np.array_equal(f,z['faces_0000']),'Untracked moving geometry'
    assert moving<=found and len(found)>=4,'Missing geometry'
    return mats,cam

def chamfer(a,b):
    return float((cKDTree(a).query(b)[0].mean()+cKDTree(b).query(a)[0].mean())/2)

def main(ref,candidate):
    rp,rc=validate(ref);cp,cc=validate(candidate)
    # Camera is provided as calibration; no hidden similarity registration.
    assert np.allclose(rc['intrinsics'],cc['intrinsics'],atol=.01) and np.allclose(rc['extrinsic'],cc['extrinsic'],atol=1e-4),'Use the supplied calibrated camera'
    metrics={}; points={}; rng=np.random.default_rng(84)
    for name in ['water','sand']:
        r=rp[name];c=cp[name]
        shape=np.mean([chamfer(r[t],c[t]) for t in range(F)])
        # Stable world-space distribution of displacement; sample quantiles on 24 directions.
        dirs=rng.normal(size=(24,3));dirs/=np.linalg.norm(dirs,axis=1)[:,None]
        quant=np.linspace(.005,.995,128)
        rv=np.diff(r,axis=0);cv=np.diff(c,axis=0)
        emd=np.mean([np.abs(np.quantile(rv[t]@dirs.T,quant,axis=0)-np.quantile(cv[t]@dirs.T,quant,axis=0)).mean() for t in range(F-1)])
        # One global one-to-one assignment based on full paths. No per-frame relabelling.
        nr=min(192,r.shape[1],c.shape[1])
        ri=np.lexsort(r[0].T[::-1])[np.linspace(0,r.shape[1]-1,nr,dtype=int)]
        ci=np.lexsort(c[0].T[::-1])[np.linspace(0,c.shape[1]-1,nr,dtype=int)]
        cost=np.zeros((nr,nr))
        for t in range(0,F,3):cost+=np.linalg.norm(r[t,ri,None,:]-c[t,None,ci,:],axis=-1)
        cost/=len(range(0,F,3));a,b=linear_sum_assignment(cost);trajectory=float(cost[a,b].mean())
        centroid=float(np.linalg.norm(r.mean(1)-c.mean(1),axis=1).mean())
        spread=float(np.abs(r.std(1)-c.std(1)).mean())
        metrics[name]={'chamfer_m':float(shape),'trajectory_m':trajectory,'displacement_SW_m':float(emd),'centroid_m':centroid,'spread_m':spread}
        # Zero error earns full score; explicit tolerances rather than learned judges.
        points[name]={'shape':max(0,1-shape/.06)*15,'trajectories':max(0,1-trajectory/.14)*10,'velocity':max(0,1-emd/.015)*10,'centroid':max(0,1-centroid/.08)*5,'spread':max(0,1-spread/.05)*5}
    # Dynamic silhouettes in three orthogonal views, independent of appearance.
    def occupancy(p,axes):
        x=np.floor(p[:,axes]/.025).astype(int);return set(map(tuple,x))
    ious=[]
    for name in rp:
        for t in range(F):
            for axes in [[0,1],[0,2],[1,2]]:
                a=occupancy(rp[name][t],axes);b=occupancy(cp[name][t],axes);ious.append(len(a&b)/len(a|b))
    iou=float(np.mean(ious));score=sum(sum(v.values()) for v in points.values())+10*iou
    # Hard physical and geometry-fidelity limits prevent compensation across metrics.
    hard=all(m['chamfer_m']<=.025 and m['trajectory_m']<=.06 and m['displacement_SW_m']<=.005 for m in metrics.values())
    # Manual review gates cannot be silently passed by this numeric tool.
    return {'numeric_score':score,'numeric_pass':bool(score>=80 and hard),'pass_requires_manual_gates':True,'metrics':metrics,'points':points,'three_view_occupancy_iou':iou,'manual_gates':['Clean offline rebuild from source without reference video, baked trajectories, network, or evaluator access.','Blender render depicts the exported geometry through the exported camera at frames 0,8,16,24,32,48; inspect novel view as well.','Both distinct material appearances and static pipes/floor match reference. No billboards, copied pixels, or hidden duplicate geometry.'],'official_difficulty_estimate':None}
if __name__=='__main__':
    p=argparse.ArgumentParser();p.add_argument('reference');p.add_argument('candidate');p.add_argument('--out');a=p.parse_args()
    try:r=main(Path(a.reference),Path(a.candidate))
    except Exception as e:r={'numeric_score':0,'numeric_pass':False,'error':str(e)}
    s=json.dumps(r,indent=2);print(s)
    if a.out:Path(a.out).write_text(s)
