#!/usr/bin/env python3
"""Test goalie hold/B-to-requester through real controller input and native passes.

Usage: verify_goalie_request.py ROM /private/output --fixtures-root /private/work
Requires the three historical gameplay captures listed in EXPERIMENTAL_BUILD.md.
Only output files/private core states are touched; no visible window or live SRAM.
"""
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import os,json,subprocess
import argparse
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('rom',type=Path)
parser.add_argument('output',type=Path)
parser.add_argument('--fixtures-root',type=Path,required=True,help='Private work directory containing the documented captured gameplay states')
parser.add_argument('--tap-frames',type=int,default=1,choices=[1,2,3],help='frontend-frame length of the short tap; one can fall wholly in a native lag frame')
parser.add_argument('--switch-on-b',action='store_true',help='User policy: goalie B switches first; native pass goes to that pad’s new controlled skater')
args=parser.parse_args()
w=args.output.resolve();w.mkdir(parents=True,exist_ok=True)
rom=args.rom.resolve();root=args.fixtures_root.resolve();tools=Path(__file__).resolve().parent
old=root/'goalie-outlet-20261024-173340'
cases=[]
for side in [5,11]:
 for kind,pad,delay in [('single',1,-1),('coop',1,-1),('single',1,10),('coop',1,10),('coop',2,10),('p1only',1,10),('p2only',2,10),('autogk',1,10),('cpu',1,-1),('manual',1,10)]:cases.append((side,kind,pad,delay,'clear',5))
 for pad in [1,2]:cases.append((side,'coop',pad,1,'dive',5))
 cases.append((side,'coop',1,10,'clear',25))
 cases.append((side,'coop',1,10,'clear',args.tap_frames))
 cases.append((side,'coop',2,10,'clear',args.tap_frames))
 cases.append((side,'coop',1,-1,'dive',5))
for side in [5,11]:
 for pad in [1,2]:cases.append((side,'coop',pad,1,'duel',5))
cases += [(5,'coop',1,1,'pressure',5),(5,'coop',2,1,'pressure',5)]
# A real B switch immediately before a native catch used to consume the held
# press, so the new goalie possession never received its outlet request.
cases += [(5,'single',1,-1,'early',5),(5,'coop',1,-1,'early',5),(5,'coop',2,-1,'early',5)]
def run(c):
 side,kind,pad,delay,fixture_kind,duration=c;name=f'{fixture_kind}-{side}-{kind}-p{pad}-d{delay}-hold{duration}';case=w/name;case.mkdir(exist_ok=True)
 fixture=old/f'capture/gk{side}-clear.state' if fixture_kind=='clear' else root/f'goalie-recovery-20261024-145816/scenario-{ "home" if side==5 else "away"}/before-dive.state' if fixture_kind in ['dive','duel'] else root/'goalie-auto-20261023-142924/native6-coop-baseline/before-catch.state'
 cmd=case/'commands.txt';cmd.write_text(f'100 load {fixture}\n400 exit\n');env=dict(os.environ,GPGX_DEV='513',GPGX_HOOK=str(tools/'goalie_request_hook.py'),PRECAPTURE='1' if fixture_kind=='early' else '',SECOND='1' if fixture_kind=='duel' else '',SIDE=str(side),KIND=kind,PAD=str(pad),PRESS=str(delay),DURATION=str(duration))
 with (case/'log.txt').open('w') as out:subprocess.run(['python3',str(tools/'test_core.py'),str(rom),str(cmd),str(case)],env=env,check=True,stdout=out,stderr=out)
 rows=[json.loads(s) for s in (case/'trace.jsonl').read_text().splitlines()];initial=rows[0][1]['controls'];target=initial[pad-1];passed=next(((f,r) for f,r in rows if f>100 and r['owner']!=side and r['passer']==side and not(r['whistle']&0x100) and not any(r['request']) and not any(r['pending'])),None)
 if fixture_kind=='early':
  caught=next((f,r) for f,r in rows if r['owner']==side);initial=caught[1]['controls'];target=initial[pad-1]
 if delay<0 and kind!='cpu' and fixture_kind!='early':assert passed is None,(name,'unrequested pass',passed)
 elif delay>=0 or fixture_kind=='early':
  assert passed,(name,'no requested pass')
  assert passed[1]['pressed_at'] is not None and passed[0]>passed[1]['pressed_at'],(name,'passed before button')
  if kind!='manual':
   if args.switch_on_b:target=passed[1]['controls'][pad-1]
   assert passed[1]['target']==target,(name,'wrong pad receiver',target,passed)
   if args.switch_on_b:
    assert target<12 and target//6==side//6,(name,'wrong team after B',target)
    assert all(r['controls'][i]==initial[i] for f,r in rows if passed[1]['pressed_at']<=f<=passed[0] for i in range(2) if i!=pad-1 and fixture_kind!='duel'),(name,'stole another controller')
   else:
    assert all(r['controls']==initial for f,r in rows if (caught[0] if fixture_kind=='early' else passed[1]['pressed_at'])<=f<=passed[0]),(name,'B changed controlled skater')
   assert all(v==0 for v in passed[1]['request']+passed[1]['pending']),(name,'request leaked')
   assert not passed[1]['flags']&2,(name,'passed during recovery')
 if kind=='cpu':assert passed,(name,'CPU goalie stopped passing')
 return dict(case=name,pass_frame=None if passed is None else passed[0],target=None if passed is None else passed[1]['target'],requested_target=target,pressed_at=None if passed is None else passed[1]['pressed_at'])
with ThreadPoolExecutor(max_workers=3) as pool:results=list(pool.map(run,cases))
(w/'request-results.json').write_text(json.dumps(results,indent=2)+'\n');print(json.dumps(results,indent=2));print('PASS',len(results),'hold/request/side/pad/recovery cases')
