#!/usr/bin/env python3
"""Native completed-slot review, restart/cancel and first non-mirrored new game.

Uses private copies of an explicit64KB save, never installed user files.
Full/Light/Mini x eliminated/champion/missed, both pads in the results/actions
UI (r6: the actions menu comes first, See Results opens the browser; r11: Start returns
to it, Scoring Leaders is the second choice), all original saved banks retained until the selected restart is confirmed.
"""
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import argparse,json,os,struct,subprocess
ap=argparse.ArgumentParser(description=__doc__);ap.add_argument('rom',type=Path);ap.add_argument('output',type=Path);ap.add_argument('--seed',type=Path,required=True);a=ap.parse_args()
out=a.output.resolve();out.mkdir(parents=True,exist_ok=True);here=Path(__file__).resolve().parent
hook=out/'hook.py';hook.write_text('''from pathlib import Path
import json,binascii,struct
p=Path(outdir);spec=json.loads((p/'spec.json').read_text());seed=(p/'seed.srm').read_bytes();before=seed[1::2];results=[]
def rw(a):return (wram[ram(a)]<<8)|wram[ram(a+1)]
def rb(a,n):return bytes(wram[ram(a+i)] for i in range(n))
def test_tick(f):
 if f not in [1300,1800,2100,2400,2700,3100,3600,4000,4400,5000,7180,9000]:return
 live=bytes(sram)[:65536];logical=live[1::2]
 if f<=4400:assert live==seed,('review/cancel changed battery data',spec,f)
 if f==1300:
  assert rw(0xe3f0)==4 and rw(0xd04c)==8 and rw(0xf06a)==spec['slot']
  assert rb(0xe3a2,20)==b'Season finished     '
 if f in [1800,2100,2400]:
  assert rw(0xe532)==8 and rw(0xe6fe)==0 and rw(0xe6fc)==(8 if f==1800 else 9),('not the completed statistics browser',spec,f)
  if f==2400:assert rw(0xe6f6)==1
 if f==2700:assert rw(0xe6fe)==1
 if f==3100:assert rw(0xe3fc)==1
 if f==4000:assert rw(0xe3fc)==2
 if f>=5000:
  # Charged-pass preferences live in Light's deliberately excluded shared gap;
  # a selected restart resets only its own2byte record and that bank's checksum.
  # Every other saved byte, including all other preferences, stays exact.
  charged=romdata[0xb760:0xb766]==bytes.fromhex('4eb9002e8200')
  allowed={2*(0x4d22+2*spec['slot'])+1,2*(0x4d23+2*spec['slot'])+1,2*0x5ffe+1,2*0x5fff+1} if charged else set()
  pickup=romdata[0x220000:0x220002]!=bytes.fromhex('ffff')
  alt=romdata[0x15288:0x15290]==bytes.fromhex('4eb9002210244e71')
  strict=romdata[0x222000:0x222002]!=bytes.fromhex('ffff')
  plan=romdata[0x223000:0x223002]!=bytes.fromhex('ffff')
  dots=romdata[0x1255a:0x12562]==bytes.fromhex('4eb9002240004e71')
  momentum=romdata[0x226000:0x226002]!=bytes.fromhex('ffff')
  if momentum:allowed|={2*(0x4d52+2*spec['slot'])+1,2*(0x4d53+2*spec['slot'])+1}
  if dots:allowed|={2*(0x4d4a+2*spec['slot'])+1,2*(0x4d4b+2*spec['slot'])+1}
  if plan:allowed|={2*(0x4d42+2*spec['slot'])+1,2*(0x4d43+2*spec['slot'])+1}
  if strict:allowed|={2*(0x4d3a+2*spec['slot'])+1,2*(0x4d3b+2*spec['slot'])+1}
  if alt:allowed|={2*(0x4d32+2*spec['slot'])+1,2*(0x4d33+2*spec['slot'])+1}
  if pickup:allowed|={2*(0x4d2a+2*spec['slot'])+1,2*(0x4d2b+2*spec['slot'])+1}
  transfers=romdata[0x1f9a40:0x1f9a48]==bytes.fromhex('4eb9002560004e71')
  if transfers:
   # New explicit persistent reset journal; bind every allowed byte to the
   # exact pre-reset active snapshot/header, not a broad unverified allowance.
   slot=spec['slot'];at=0x4000+0x2000*(slot-1);pref=0xfa00+8*(slot-1)
   expected_payload=seed[1:0x4000:2]
   journal_crc=binascii.crc_hqx(expected_payload+bytes([slot]),0xffff)
   expected_header=bytes.fromhex('004a4e4c')+bytes([1,slot,slot^255,0])+struct.pack('>HH',journal_crc,journal_crc^65535)+b'CLR1'
   assert live[0xa000:0xe000:2]==expected_payload,('reset snapshot differs from pre-reset season',spec,f)
   assert live[0xe000:0xe020:2]==expected_header,('reset journal not correctly finalized',spec,f)
   assert live[at]==live[at+0x1000]==0,('selected transfer records survive confirmed reset',spec,f)
   assert live[pref:pref+8:2]==bytes.fromhex('544600ff'),('selected transfer preference not resetOff',spec,f)
   allowed|=set(range(0xa000,0xe000,2))|set(range(0xe000,0xe020,2))|{at,at+0x1000}|set(range(pref,pref+8,2))
  refinements=romdata[0x258000:0x258002]==bytes.fromhex('6000')
  if refinements:
   pref=0xfa20+4*spec['slot']
   assert live[pref:pref+4:2]==bytes.fromhex('a45b'),('selected shot-unit/glass defaults not reset',spec,f)
   allowed|={pref,pref+2}
  changed=[i for i in range(16384,len(seed)) if live[i]!=seed[i]]
  assert set(changed)<=allowed,('other season bytes changed outside selected preference and checksum',spec,f,changed)
  if charged:
   at=0x4d22+2*spec['slot']
   if momentum:assert logical[0x4d52+2*spec['slot']:0x4d54+2*spec['slot']]==bytes.fromhex('7a85'),('selected restart Momentum not On',spec,f)
   if dots:assert logical[0x4d4a+2*spec['slot']:0x4d4c+2*spec['slot']]==bytes.fromhex('7986' if refinements else '7887'),('selected restart Faceoff Dots not permanent On',spec,f)
   if plan:assert logical[0x4d42+2*spec['slot']:0x4d44+2*spec['slot']]==bytes.fromhex('7788' if refinements else '7689'),('selected restart AI Shot Plan default incorrect',spec,f)
   if strict:assert logical[0x4d3a+2*spec['slot']:0x4d3c+2*spec['slot']]==bytes.fromhex('758a' if refinements else '748b'),('selected restart OT SO permanent On incorrect',spec,f)
   if alt:assert logical[0x4d32+2*spec['slot']:0x4d34+2*spec['slot']]==bytes.fromhex('738c' if refinements else '728d'),('selected restart alternate keeper default incorrect',spec,f)
   if pickup:assert logical[0x4d2a+2*spec['slot']:0x4d2c+2*spec['slot']]==bytes.fromhex('708f'),('selected restart pickup preference not Off',spec,f)
   assert logical[at:at+2]==bytes.fromhex('609f'),('selected restart charge preference not Off',spec,f)
   assert logical[0x5fff]==sum(logical[0x4000:0x5ffe])%256 and logical[0x5ffe]==(logical[0x5fff]^255),('Light preference checksum',spec,f)

  assert logical[0x1fff]==sum(logical[:0x1ffe])%256 and logical[0x1ffe]==(logical[0x1fff]^255)
  assert struct.unpack_from('>6H',logical,0x1004)==(0,0,0,spec['length'],spec['slot'],3),('new slot not reset',spec,f)
  assert logical[0x1210:0x1212]==b'\\0\\0'
 if f==7180:assert rw(0xd04c)==31 and rw(0xd04e)!=31,('new Winnipeg fixture mirrors',spec)
 if f==9000:
  assert wram[ram(0xc331)]==31 and wram[ram(0xc333)]!=31 and rw(0xc2e8)>8,('native first game did not start with distinct clubs',spec,rw(0xc2e8),rb(0xc331,3).hex())
 results.append(dict(frame=f,slot=spec['slot'],flag=spec['flag']))
 if f==9000:(p/'results.json').write_text(json.dumps(results,indent=2)+'\\n')
''')
cases=[dict(slot=slot,length=length,flag=flag,pad=1+(flag%2)) for slot,length in [(1,84),(2,31),(3,7)] for flag in [3,4,5]]
def run(spec):
 p=out/f"slot{spec['slot']}-flag{spec['flag']}";p.mkdir(exist_ok=True);raw=bytearray(a.seed.read_bytes());assert len(raw)==65536;logical=bytearray(raw[1::2]);struct.pack_into('>4s6H',logical,0x1000,b'SESN',spec['length'],9,spec['length'],spec['length'],0,3);struct.pack_into('>H',logical,0x1210,spec['flag']);struct.pack_into('>4s4H',logical,0x1ea0,b'S3SV',1,spec['slot'],7,1);logical[0x1fff]=sum(logical[:0x1ffe])&255;logical[0x1ffe]=logical[0x1fff]^255;raw[1::2]=logical;(p/'seed.srm').write_bytes(raw);(p/'battery.srm').write_bytes(raw);(p/'spec.json').write_text(json.dumps(spec,indent=2)+'\n');pad=spec['pad']
 cmd=['1300 snap','1400 press 1 Start 5',f'1500 press {pad} C 5',f'1900 press {pad} Right 5',f'2200 press {pad} C 5',f'2500 press {pad} Start 5',f'2600 press {pad} Down 5',f'2650 press {pad} C 5',f'2800 press {pad} Start 5','3100 snap',f'3200 press {pad} Down 5',f'3300 press {pad} C 5','3600 snap',f'3700 press {pad} Start 5',f'4100 press {pad} C 5',f'4500 press {pad} A 8',f'4500 press {pad} C 8','5000 snap','5100 press 1 Down 5','5200 press 1 Down 5']
 for i in range(21):cmd.append(f'{5300+i*80} press 1 Right 5')
 cmd+=['7180 snap','7300 press 1 Start 5','8000 press 1 C 5','9000 snap','9001 exit'];(p/'commands.txt').write_text('\n'.join(cmd)+'\n')
 env={k:v for k,v in os.environ.items() if k not in ['GPGX_HOOK','GPGX_SRM','GPGX_WAV']};env.update(GPGX_DEV='513',GPGX_SRM=str(p/'battery.srm'),GPGX_HOOK=str(hook))
 with (p/'log.txt').open('w') as log:subprocess.run(['python3',str(here/'test_core.py'),str(a.rom.resolve()),str(p/'commands.txt'),str(p)],env=env,check=True,stdout=log,stderr=log,timeout=30)
 result=json.loads((p/'results.json').read_text());assert len(result)==12;return dict(**spec,checks=len(result))
with ThreadPoolExecutor(max_workers=3) as pool:results=list(pool.map(run,cases))
(out/'results.json').write_text(json.dumps(results,indent=2)+'\n');print('PASS',len(results),'completed-season flows,',sum(x['checks'] for x in results),'checks; review/cancel readonly, restart only selected slot, first Winnipeg game distinct')
