"""Replay frozen t5 geometry and test per-arm discrimination without a live game.
The far-separated t5 policy result is a reference association, not native parent proof.
"""
from pathlib import Path
import json,math,random,copy,hashlib
from lupa.luajit21 import LuaRuntime
R=Path(__file__).resolve().parents[1];L=LuaRuntime(unpack_returned_tuples=True)
P=L.execute((R/'src/spatial_policy.lua').read_text());old=L.execute((R/'tests/t5_spatial_policy.lua').read_text());Z=L.execute((R/'src/zones.lua').read_text())
checks=0;calls=0

def C(v,msg='check failed'):
 global checks
 assert v,msg
 checks+=1

def lua(v):
 if isinstance(v,dict):return L.table_from({k:lua(x)for k,x in v.items()})
 if isinstance(v,list):return L.table_from([lua(x)for x in v])
 return v

def choose(policy,current,bodies,arms):
 global calls
 calls+=1
 return policy['select'](lua(current),lua(bodies),lua(arms),Z['side_by_resource'])

def ids(selected):return sorted(d['entity']for _,d in selected.items())if selected is not None else []

def position_rows(row):
 positions={p['entity']:p['position']for p in row['positions']}
 return ([{'descriptor':d,'position':positions[d['entity']]}for d in row['mechs']], [{'descriptor':d,'position':positions[d['entity']]}for d in row['arms']])

fixture=json.loads((R/'tests/t5-close-geometry.json').read_text());rows=fixture['rows'];C(len(rows)==6)
b0,a0=position_rows(rows[0]);reference={}
for b in b0:
 selected,why,_=choose(old,b['descriptor'],b0,a0);C(why=='SPATIAL_HEURISTIC'and len(ids(selected))==2)
 reference[b['descriptor']['entity']]=ids(selected)
replayed=[];rng=random.Random(6)
for row in rows:
 bodies,arms=position_rows(row);dist=math.dist(bodies[0]['position'],bodies[1]['position'])
 for current in [b['descriptor']for b in bodies]:
  before,reason,_=choose(old,current,bodies,arms)
  selected,why,meta=choose(P,current,bodies,arms)
  C(why=='SPATIAL_HEURISTIC'and ids(selected)==reference[current['entity']])
  C(meta['separation_gate']is False and abs(meta['nearest_mech_distance']-dist)<1e-9)
  if dist<=12:C(before is None and reason=='SPATIAL_MECHS_TOO_CLOSE')
  else:C(ids(before)==ids(selected))
  for _ in range(10):
   bs=copy.deepcopy(bodies);aa=copy.deepcopy(arms);rng.shuffle(bs);rng.shuffle(aa)
   result,why2,_=choose(P,current,bs,aa);C(ids(result)==ids(selected)and why2==why,'order dependence')
  replayed.append({'file':row['file'],'current_entity':current['entity'],'body_root_distance':dist,'old_status':reason,'new_status':why,'selected_entities':ids(selected)})

body_hash='c2d449ecf7facab1';left='e5f64dcc3bfe9dd1';right='ff9878576a4c543b'
def desc(e,res):return {'entity':e,'goid':e+100,'unit':e+4194000,'resource':res}
bodies=[{'descriptor':desc(1,body_hash),'position':[0,0,0]},{'descriptor':desc(2,body_hash),'position':[8,0,0]}]
arm_desc=[desc(11,left),desc(12,right),desc(21,left),desc(22,right)]
arms=[{'descriptor':d,'position':p}for d,p in zip(arm_desc,[[-1,0,0],[1,0,0],[6,0,0],[5.5,0,0]])]
selected,why,meta=choose(P,bodies[0]['descriptor'],bodies,arms);C(ids(selected)==[11,12]and why=='SPATIAL_HEURISTIC')
C([m['decision']for _,m in meta['distances'].items()].count('EXCLUDED_CLOSER_TO_OTHER')==2)
selected,_,_=choose(P,bodies[0]['descriptor'],bodies,arms[1:]);C(ids(selected)==[12],'must not fill missing left arm with a clear foreign candidate')
# An unresolved candidate in range still blocks, even if two other candidates look good.
ambiguous=copy.deepcopy(arms);ambiguous[2]['position']=[5,0,0]
selected,why,_=choose(P,bodies[0]['descriptor'],bodies,ambiguous);C(selected is None and why=='SPATIAL_COMPETING_MECH')
duplicate=arms+[{'descriptor':desc(13,left),'position':[0,1,0]}]
selected,why,_=choose(P,bodies[0]['descriptor'],bodies,duplicate);C(selected is None and why=='SPATIAL_DUPLICATE_SIDE')
for invalid in [float('nan'),float('inf'),1000001]:
 bad=copy.deepcopy(arms);bad[0]['position'][0]=invalid
 selected,why,_=choose(P,bodies[0]['descriptor'],bodies,bad);C(selected is None and why=='SPATIAL_INVALID_ARM')

# Synthetic attachments use small lateral offsets and ~3m root height, not HP or order.
# This bounded grid is not proof for all physically possible attachment configurations.
grid_cases=0;displayed=0;hidden=0
for sep in [0,1,2,3,4,5,6,7,8,10,11.9,12,12.1,16,20]:
 for height in [-3,-1,0,1,3]:
  bodies[1]['position']=[sep,0,height]
  for h1 in range(0,360,45):
   for h2 in range(0,360,45):
    arms=[]
    for owner,h in enumerate([h1,h2]):
     x,y,z=bodies[owner]['position'];angle=math.radians(h)
     for side,offset in enumerate([-1.2,1.2]):
      arms.append({'descriptor':arm_desc[owner*2+side],'position':[x+offset*math.cos(angle),y+offset*math.sin(angle),z+3]})
    for owner in [0,1]:
     selected,why,meta=choose(P,bodies[owner]['descriptor'],bodies,arms);grid_cases+=1
     if selected is None:
      hidden+=1;C(why in ['SPATIAL_COMPETING_MECH','SPATIAL_NO_CLOSE_ARM','SPATIAL_DUPLICATE_SIDE'])
     else:
      displayed+=1;C(set(ids(selected)).issubset({11,12}if owner==0 else{21,22}),'synthetic cross-owner selection')
      for _,d in selected.items():
       a=next(a for a in arms if a['descriptor']['entity']==d['entity']);near=math.dist(a['position'],bodies[owner]['position']);other=math.dist(a['position'],bodies[1-owner]['position'])
       C(near<=6 and other-near>=3-1e-10,'selection weakened radius or advantage')
     if sep==0 and height==0:C(selected is None,'coincident bodies must not be assigned by order')

# Inputs are always resampled: no learned/sticky pair survives a new ambiguity.
C(P['separation']is None and P['radius']==6 and P['margin']==3)
report={'checks':checks,'policy_calls':calls,'live_fixture_frames':len(rows),'live_fixture_driver_perspectives':len(replayed),'formerly_blanket_blocked_cases':sum(r['old_status']=='SPATIAL_MECHS_TOO_CLOSE'for r in replayed),'replayed':replayed,'synthetic_grid_cases':grid_cases,'synthetic_displayed_cases':displayed,'synthetic_hidden_cases':hidden,'four_model_runtime_tests_separate':True,'blanket_separation_gate_removed':True,'radius_6_and_margin_3_preserved':True,'clear_foreign_candidates_excluded':True,'unresolved_candidates_still_refused':True,'native_parent_ownership_proven':False,'real_game_calls':0,'t6_live_display_verified':False,'fixture_sha256':hashlib.sha256((R/'tests/t5-close-geometry.json').read_bytes()).hexdigest()}
(R/'validation/close-range-tests.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n');print(json.dumps(report,ensure_ascii=False,indent=2))
