#!/usr/bin/env python3
"""Paired native pressure, blue-line decisions, relay and one-timer execution.
Only nine setup frames; no forced actions, movement resets or score injection.
"""
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import argparse,json,os,subprocess,statistics
ap=argparse.ArgumentParser(description=__doc__);ap.add_argument('rom',type=Path);ap.add_argument('output',type=Path);ap.add_argument('--baseline',type=Path,required=True);ap.add_argument('--state',type=Path,required=True);a=ap.parse_args();h=Path(__file__).resolve().parent;o=a.output.resolve();o.mkdir(parents=True,exist_ok=True)
specs=[dict(kind=k,team=t,direction=d,difficulty=1) for k in ['pressure','point','relay','finish'] for t in [0,1] for d in [-1,1]]
specs += [dict(kind=k,team=0,direction=d,difficulty=level) for k in ['pressure','point','relay'] for d in [-1,1] for level in [0,2]]
specs += [dict(kind='point',team=t,direction=d,difficulty=1,blocked=True) for t in [0,1] for d in [-1,1]]
# Exercise two additional relay lanes outside the initial marker's reach.
# Keep all original32 scenarios and every existing strict success criterion.
specs += [dict(kind='relay',team=t,direction=d,difficulty=1,relay_x=x) for t in [0,1] for d in [-1,1] for x in [100,110]]
def run(profile,rom,index,s):
 p=o/(profile+'-'+str(index));p.mkdir(exist_ok=True);(p/'spec.json').write_text(json.dumps(s));(p/'commands.txt').write_text(f'100 load {a.state.resolve()}\n148 snap\n188 snap\n248 snap\n708 snap\n709 exit\n');env={k:v for k,v in os.environ.items() if not k.startswith('GPGX_')};env.update(GPGX_DEV='513',GPGX_HOOK=str(h/'ai_playbook_gameplay_hook.py'))
 with (p/'log.txt').open('w') as log:subprocess.run(['nice','-n','19','python3',str(h/'test_core.py'),str(rom.resolve()),str(p/'commands.txt'),str(p)],env=env,stdout=log,stderr=log,check=True,timeout=30)
 r=json.loads((p/'trajectory.json').read_text());assert len(r)==600 and 0<r[-1]['clock']<r[0]['clock'] and all(x['sprite_bytes']<=0x280 for x in r),('stalled play/sprite overflow',s)
 assert all(x['controller_teams']==r[0]['controller_teams'] for x in r[:120]),('controller team stolen',s)
 c=r[0]['carrier'];own=s['team']*6;passes=[];shots=[]
 previous=None
 for row in r:
  if row['owner']>11 and own<=row['passer']<own+5 and own<=row['target']<own+5 and (not passes or passes[-1]['passer']!=row['passer'] or passes[-1]['target']!=row['target']):
   passes.append(dict(frame=row['frame'],passer=row['passer'],target=row['target']))
  if own<=row['shooter']<own+5 and row['owner']>11 and (not shots or shots[-1]['shooter']!=row['shooter']):
   i=row['shooter'];buffered=previous is not None and previous['cpu_pending']==i+1 and row['cpu_pending']==0 and row['animations'][i]==0x7fc
   shots.append(dict(frame=row['frame'],shooter=i,one_timer=row['one_timer'][i] or buffered,buffered=buffered,power=row['shot_power']))
  previous=row
 windup=next((x for x in r if x['jobs'][c]==18),None)
 # A real relay catch must precede its second pass and native one-timer shot.
 chain=None
 for first,second in zip(passes,passes[1:]):
  caught=any(x['owner']==first['target'] for x in r if first['frame']<x['frame']<second['frame'])
  final=next((x for x in shots if x['frame']>second['frame'] and x['shooter']==second['target'] and x['one_timer']),None)
  if caught and second['passer']==first['target'] and final:chain=dict(first=first,second=second,shot=final);break
 return dict(gap60=r[59]['nearest'],gap_mean=statistics.mean(x['nearest'] for x in r[29:60]),windup_depth=windup['positions'][c][1] if windup else None,passes=passes,shots=shots,chain=chain,positions60=r[59]['positions'],first_catch=min([x['frame'] for x in r if x['owner']==own]+[x['frame'] for x in shots if x['shooter']==own and x['buffered']],default=None))

specs=[dict(kind='finish',team=t,direction=d,difficulty=1) for t in [0,1] for d in [-1,1]]
specs += [dict(kind='finish',team=0,direction=d,difficulty=level) for d in [-1,1] for level in [0,2]]
results=[]
for i,spec in enumerate(specs):
 old=run('old',a.baseline,i,spec);new=run('new',a.rom,i,spec);metrics={}
 for profile in ['old','new']:
  rr=json.loads((o/(profile+'-'+str(i))/'trajectory.json').read_text());receiver=6*spec['team'];window=[x for x in rr if x['frame']<=60 and x['cpu_pending']==receiver+1 and x['target']==receiver and x['owner']>11]
  metrics[profile]=dict(armed_samples=len(window),mean_slot_distance=statistics.mean(__import__('math').hypot(x['positions'][receiver][0],x['positions'][receiver][1]-220) for x in window) if window else None,first_catch=(old if profile=='old' else new)['first_catch'],native_early_one_timer=any(x['shooter']==receiver and x['one_timer'] and x['frame']<=60 for x in (old if profile=='old' else new)['shots']))
 if spec['difficulty']!=1:assert (o/('old-'+str(i))/'trajectory.json').read_bytes()==(o/('new-'+str(i))/'trajectory.json').read_bytes(),('Normal/Easy native behavior changed',spec)
 results.append(dict(spec=spec,baseline=old,fixed=new,metrics=metrics))
hard=results[:4];pairs=[r for r in hard if r['metrics']['old']['mean_slot_distance'] is not None and r['metrics']['new']['mean_slot_distance'] is not None]
summary=dict(scenarios=len(results),comparable_armed_passes=len(pairs),old_slot_distance=statistics.mean(r['metrics']['old']['mean_slot_distance'] for r in pairs),new_slot_distance=statistics.mean(r['metrics']['new']['mean_slot_distance'] for r in pairs),old_early_one_timers=sum(r['metrics']['old']['native_early_one_timer'] for r in hard),new_early_one_timers=sum(r['metrics']['new']['native_early_one_timer'] for r in hard),unchanged_normal_easy=4)
(o/'results.json').write_text(json.dumps(dict(summary=summary,results=results),indent=2)+'\n');print(json.dumps(summary),flush=True)
# A behavior measurement, not an assumption that holding a spot guarantees goals.
assert len(pairs)>=3 and summary['new_slot_distance']<summary['old_slot_distance'],summary
assert summary['new_early_one_timers']>=summary['old_early_one_timers'],summary
print('PASS native finisher geometry and retained catch/release with exact Normal/Easy controls')
