#!/usr/bin/env python3
"""Native resumed-season controller ownership, home/away and cold boots.

Private valid slot fixtures select Edmonton; actual native sides and controlled
actors must still belong to that club after Start. No installed saves touched.
"""
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();here=Path(__file__).resolve().parent;out=a.output.resolve();out.mkdir(parents=True,exist_ok=True)
hook=out/'hook.py';hook.write_text('''from pathlib import Path
import json,struct
p=Path(outdir);spec=json.loads((p/'spec.json').read_text());rows=[];first_live=False
def rw(a):return (wram[ram(a)]<<8)|wram[ram(a+1)]
def test_tick(f):
 global first_live
 ordinary=rw(0xc468)>0 and not(rw(0xc2ea)&0x1100 or rw(0xc2ec)&0x8100 or rw(0xc2f2)&0x0400 or rw(0xd412) or rw(0xc2fa)&0x0100)
 first=not first_live and f>=2100 and ordinary
 if f not in [1300,3600] and not first:return
 if first:first_live=True
 away=spec['gp']%2==1 and spec['count']==1;side=2 if away else 1
 clubs=[wram[ram(0xc331)],wram[ram(0xc333)]]
 sides=[rw(0xc328+2*i) for i in range(4)];actors=[rw(0xc320+2*i) for i in range(4)]
 assert rw(0xf06a)==spec['slot'] and rw(0xf0d0)==spec['count'],('wrong fixture',spec)
 if f==1300:
  assert rw(0xd04a)==(2 if away else {1:1,2:3,3:5,4:6}[spec['count']])
  assert rw(0xd04e if away else 0xd04c)==10,('wrong selected club',spec)
 else:
  assert rw(0xc468)>0 and rw(0xc2e8)>8,('native match not running',spec)
  assert sides==[side]*spec['count']+[0]*(4-spec['count']),('controller changed side',spec,sides,clubs)
  assert clubs[side-1]==10,('controller owns another club',spec,sides,clubs)
  for actor in actors[:spec['count']]:
   if ordinary:
    assert (6<=actor<=11 if away else 0<=actor<=5),('controlled actor on wrong team in ordinary play',spec,actor)
   else:
    # Native C0F8/FD22 may expose a parked non-player record while the whistle
    # transition owns control. Club/side checks still apply; first real normal
    # play MUST independently contain actual skaters from the saved club.
    assert (6<=actor<=11 if away else 0<=actor<=5) or actor in [14,65535],('unknown stopped-state controller record',spec,actor)
  if f==3600:assert first_live,('no actual normal-play ownership observation',spec)
 b=bytes(sram)[1:65536:2]
 assert struct.unpack_from('>H',b,0x1006)[0]==11,('saved chosen club changed',spec)
 assert b[0x1fff]==sum(b[:0x1ffe])%256,('bad checksum',spec)
 rows.append(dict(frame=f,sides=sides,actors=actors,clubs=clubs,clock=rw(0xc468),ordinary_play=ordinary,first_normal_play=first,stop_flags=[rw(0xc2ea),rw(0xc2ec),rw(0xc2f2)]))
 (p/'observed.json').write_text(json.dumps(rows,indent=2)+'\\n')
''')
def battery(gp,count):
 base=bytearray(a.seed.read_bytes()[1:16384:2]);base[0xe20:0x1ffe]=bytes(0x11de);banks=[]
 for slot,length in [(1,84),(2,31),(3,7)]:
  b=bytearray(base);struct.pack_into('>4s6H',b,0x1000,b'SESN',gp,11,gp,length,0,3)
  struct.pack_into('>4s4H',b,0x1ea0,b'S3SV',1,slot,7,1)
  # A native live slot snapshot: D064 is a human game, D054 User Records is Off.
  # Zeroing D064 creates a CPU-only fixture and cannot test controller ownership.
  struct.pack_into('>H',b,0x1eca,0xff00);struct.pack_into('>H',b,0x1eba,1)
  b[0x1ef0:0x1ef4]=bytes([0x50,0x43,count,count^255]);b[0x1ef4:0x1ef6]=bytes([1,254])
  b[0x1ff6:0x1ffd]=bytes([0x54,0,0,2,2,0,0x5a]);banks.append(b)
 b=bytearray(banks[0])+b''.join(banks)
 for start in range(0,len(b),8192):b[start+0x1fff]=sum(b[start:start+0x1ffe])%256;b[start+0x1ffe]=b[start+0x1fff]^255
 raw=bytearray(len(b)*2);raw[1::2]=b;return raw

def run(case):
 slot,gp,count=case;name=f'slot{slot}-gp{gp}-pads{count}';last=None
 for boot in (1,2):
  p=out/(name+f'-boot{boot}');p.mkdir(exist_ok=True)
  (p/'battery.srm').write_bytes(battery(gp,count) if last is None else last.read_bytes())
  (p/'spec.json').write_text(json.dumps(dict(slot=slot,gp=gp,count=count)))
  # Fresh process, real main-menu slot selection followed by native Start/C.
  lines=[f'{1000+100*i} press 1 Right 5' for i in range(slot-1 if boot==1 else 0)]
  lines+=['1500 press 1 Start 5','2100 press 1 C 5','3600 snap','3601 exit']
  (p/'commands.txt').write_text('\n'.join(lines)+'\n')
  env={k:v for k,v in os.environ.items() if not k.startswith('GPGX_')}
  env.update(GPGX_DEV='1281' if count>2 else '513',GPGX_SRM=str(p/'battery.srm'),GPGX_HOOK=str(hook))
  with (p/'log.txt').open('w') as log:subprocess.run(['nice','-n','19','python3',str(here/'test_core.py'),str(a.rom.resolve()),str(p/'commands.txt'),str(p)],env=env,stdout=log,stderr=log,check=True,timeout=40)
  observations=json.loads((p/'observed.json').read_text());assert len(observations)>=3 and sum(x['first_normal_play'] for x in observations)==1
  last=p/'battery.srm'
 return dict(slot=slot,gp=gp,count=count,cold_boots=2)
cases=[(slot,gp,count) for slot in (1,2,3) for gp in (1,2) for count in (1,2)]
cases += [(3,gp,count) for gp in (1,2) for count in (3,4)]
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),'native ownership cases, two cold boots each; all three leagues, both sides, one to four humans')
