#!/usr/bin/env python3
"""Native bottom-row rendering/input, per-slot Icing lock/persistence and Edmonton defaults."""
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import argparse,json,struct,subprocess,os,hashlib
ap=argparse.ArgumentParser();ap.add_argument('rom',type=Path);ap.add_argument('output',type=Path);ap.add_argument('--seed',type=Path,required=True);a=ap.parse_args();h=Path(__file__).resolve().parent;o=a.output.resolve();o.mkdir(parents=True,exist_ok=True)
assert a.rom.read_bytes()[0x17c92:0x17c94]==b'\0\x0a'
(o/'probe.s').write_text(''' .text
 .globl _start
_start:
 cmpi.w #24,%d7
 bne.s original
 move.w %sr,-(%sp)
 addq.w #1,0xFFF080
 move.l %a1,0xFFF084
 move.w 0xFFB028,0xFFF08C
 move.w (%sp)+,%sr
original:
 jmp 0x11BA4
''')
subprocess.run([str(h.parent/'m68kasm.sh'),str(o/'probe.s'),'2FEA00',str(o/'probe.bin')],check=True,stdout=subprocess.DEVNULL)
b=bytearray(a.rom.read_bytes());code=(o/'probe.bin').read_bytes();assert b[0xf7bf4:0xf7bfa]==bytes.fromhex('4ef900011ba4') and set(b[0x2fea00:0x2fea00+len(code)])=={255};b[0x2fea00:0x2fea00+len(code)]=code;b[0xf7bf4:0xf7bfa]=bytes.fromhex('4ef9002fea00');(o/'instrumented.bin').write_bytes(b)
(o/'hook.py').write_text('''from pathlib import Path
import json
p=Path(outdir);s=json.loads((p/'spec.json').read_text());rows=[]
def rw(a):return (wram[ram(a)]<<8)|wram[ram(a+1)]
def test_tick(f):
 if str(f) not in s['checks']:return
 expected=s['checks'][str(f)];ptr=(rw(0xf084)<<16)|rw(0xf086)
 r=dict(frame=f,slot=rw(0xf06a),icing=rw(0xf0ee),home=rw(0xd04c),user=rw(0xe3f4),user_records=rw(0xd054),row=rw(0xb00c),render_calls=rw(0xf080),column=rw(0xf08c))
 for key,val in expected.items():assert r[key]==val,(s['name'],r,expected)
 if s.get('render'):
  assert r['render_calls']>0 and r['column']==17,('Icing value missing/misaligned',r)
  assert 0x2de900<=ptr<0x2def00 and romdata[ptr:ptr+2]==b'\\0\\x16',(r,hex(ptr))
  r['text']=romdata[ptr+2:ptr+22].decode('ascii').rstrip();assert r['text']==('Off' if r['icing'] else 'On'),('wrong visible toggle',r)
 rows.append(r);(p/'results.json').write_text(json.dumps(rows,indent=2)+'\\n')
''')
def pack(logical):
 for start in range(0,len(logical),8192):logical[start+0x1fff]=sum(logical[start:start+0x1ffe])&255;logical[start+0x1ffe]=logical[start+0x1fff]^255
 raw=bytearray(len(logical)*2);raw[1::2]=logical;return raw
def battery(played,tag=0x55,club=11):
 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)]:
  q=bytearray(base);struct.pack_into('>4s6H',q,0x1000,b'SESN',int(played),club if played else 0,int(played),length,0 if played else slot,3);struct.pack_into('>4s4H',q,0x1ea0,b'S3SV',1,slot,7,1)
  q[0x1ef0:0x1ef6]=b'PC\x01\xfe\0\xff';icing=(slot+1)%2;q[0x1ff6:0x1ffe]=bytes([tag,0,0,2,2,0,0x5a^(icing if tag==0x55 else 0),icing]);banks.append(q)
 return pack(bytearray(banks[0])+b''.join(banks))
def run(name,dev,raw,cmd,checks,render=True):
 p=o/name;p.mkdir(exist_ok=True);(p/'battery.srm').write_bytes(raw);(p/'spec.json').write_text(json.dumps(dict(name=name,checks=checks,render=render)));(p/'commands.txt').write_text('\n'.join(cmd)+'\n');env={k:v for k,v in os.environ.items() if not k.startswith('GPGX_')};env.update(GPGX_DEV=dev,GPGX_HOOK=str(o/'hook.py'),GPGX_SRM=str(p/'battery.srm'))
 with (p/'log.txt').open('w') as log:subprocess.run(['python3',str(h/'test_core.py'),str(o/'instrumented.bin'),str(p/'commands.txt'),str(p)],env=env,stdout=log,stderr=log,check=True,timeout=45)
 return dict(name=name,observations=json.loads((p/'results.json').read_text())),(p/'battery.srm').read_bytes()
reports=[]
for dev in ('513','1281'):
 for played in (False,True):
  cmd=[f'{900+70*i} press 1 Down 5' for i in range(12)]+['1800 snap','1900 press 1 Right 5','2100 press 1 Left 5','2300 press 1 Down 5','2400 press 1 Up 5','2500 snap','2701 exit'];checks={1800:dict(icing=0,slot=1),2000:dict(icing=0 if played else 1,slot=1),2200:dict(icing=0,slot=1),2600:dict(icing=0,slot=1)}
  r,raw=run(f'menu-{dev}-{played}',dev,battery(played),cmd,checks);reports.append(r)
  # Independent Full/Light/Mini choices survive live switches and a cold boot.
  r,raw=run(f'slots-{dev}-{played}',dev,raw,['1100 press 1 Right 5','1300 press 1 Right 5','1500 press 1 Left 5','1700 press 1 Left 5','1901 exit'],{1000:dict(icing=0,slot=1),1200:dict(icing=1,slot=2),1400:dict(icing=0,slot=3),1600:dict(icing=1,slot=2),1800:dict(icing=0,slot=1)},False);reports.append(r)
  r,_=run(f'reboot-{dev}-{played}',dev,raw,['1301 exit'],{1200:dict(icing=0,slot=1)},False);reports.append(r)
 # Clean boot uses real native initialization, no pre-existing profile/season.
 r,_=run('edmonton-fresh-'+dev,dev,bytes(65536),['1200 snap','1301 exit'],{1200:dict(icing=0,home=10,user_records=1)},False);reports.append(r)
 # Editing Off is frozen before native preparation, and survives a real cold boot.
 cmd=[f'{900+70*i} press 1 Down 5' for i in range(12)]+['1900 press 1 Right 5']+[f'{2100+70*i} press 1 Up 5' for i in range(12)]+['3100 press 1 Start 5','3700 press 1 Start 5','4601 exit']
 r,raw=run('off-first-start-'+dev,dev,battery(False),cmd,{2000:dict(icing=1),4500:dict(icing=1)},False);reports.append(r)
 q=raw[1:16384:2];assert q[0x1ff6]==0x55 and q[0x1ffd]==1 and q[0x1fff]==sum(q[:0x1ffe])&255
 r,_=run('off-start-reboot-'+dev,dev,raw,['1301 exit'],{1200:dict(icing=1)},False);reports.append(r)
 # A played season with Icing Off cannot enable it through either direction.
 q=bytearray(battery(True)[1::2])
 for bank in (0,0x2000):q[bank+0x1ffd]=1;q[bank+0x1ffc]^=1
 cmd=[f'{900+70*i} press 1 Down 5' for i in range(12)]+['1900 press 1 Left 5','2100 press 1 Right 5','2301 exit']
 r,_=run('off-locked-'+dev,dev,pack(q),cmd,{1800:dict(icing=1),2000:dict(icing=1),2200:dict(icing=1)});reports.append(r)
 # A new empty format starts at Edmonton even after manually browsing another club.
 cmd=['1000 press 1 Down 5','1100 press 1 Down 5','1200 press 1 Right 5','1300 press 1 Up 5','1400 press 1 Up 5','1500 press 1 Right 5','1700 press 1 Down 5','1800 press 1 Down 5','1900 press 1 Right 5','2000 press 1 Up 5','2100 press 1 Up 5','2200 press 1 Right 5','2401 exit']
 r,_=run('new-slot-edmonton-'+dev,dev,bytes(65536),cmd,{1250:dict(home=11,slot=1),1600:dict(home=10,slot=2,icing=0),1950:dict(home=11,slot=2),2300:dict(home=10,slot=3,icing=0)},False);reports.append(r)
 # Saved ongoing Anaheim remains Anaheim, including native club identity tests in shared matrix.
 r,_=run('saved-other-club-'+dev,dev,battery(True,club=1),['1301 exit'],{1200:dict(user=0,icing=0)},False);reports.append(r)
 for bad in ('enum','checksum'):
  q=bytearray(battery(True)[1::2])
  for bank in (0,0x2000,0x4000,0x6000):
   q[bank+0x1ffd]=2 if bad=='enum' else 0
   q[bank+0x1ffc]=0x5a^2 if bad=='enum' else 0x5b
  r,_=run('corrupt-U-'+bad+'-'+dev,dev,pack(q),['1100 press 1 Right 5','1401 exit'],{1300:dict(icing=0,slot=2)},False);reports.append(r)
 for tag in (0x53,0x54,0):
  r,_=run(f'legacy-{dev}-{tag}',dev,battery(True,tag),['1100 press 1 Right 5','1401 exit'],{1300:dict(icing=0,slot=2)},False);reports.append(r)
(o/'results.json').write_text(json.dumps(dict(rom_sha256=hashlib.sha256(a.rom.read_bytes()).hexdigest(),cases=reports),indent=2)+'\n');print('PASS',len(reports),'native Icing/menu/persistence/default-club cases')
