#!/usr/bin/env python3
"""Three independent season saves: native menu/simulation/playoffs, migration and restart.

Usage: verify_season_slots.py ROM /private/output --fixtures-root /private/work --seed preferences.srm
Only private copied saves are modified. No window, live SRAM or published ROM is changed.
Historical captures/paths and SRAM layout are documented in EXPERIMENTAL_BUILD.md.
"""
from pathlib import Path
import argparse, hashlib, json, os, shutil, struct, subprocess
from menu_protocol import refinements_enabled

ap=argparse.ArgumentParser(description=__doc__)
ap.add_argument('rom',type=Path);ap.add_argument('output',type=Path)
ap.add_argument('--fixtures-root',type=Path,required=True);ap.add_argument('--seed',type=Path,required=True)
a=ap.parse_args();root=a.fixtures_root.resolve();out=a.output.resolve();out.mkdir(parents=True,exist_ok=True)
here=Path(__file__).resolve().parent;rom=a.rom.resolve();reports=[]
def crc(b):
    assert b[0x1fff]==sum(b[:0x1ffe])%256 and b[0x1ffe]==(b[0x1fff]^255),'SRAM checksum'
def unpack(p):return bytearray(p.read_bytes()[1::2])
def put(p,b):
    b[0x1fff]=sum(b[:0x1ffe])%256;b[0x1ffe]=b[0x1fff]^255
    raw=bytearray(len(b)*2);raw[1::2]=b;p.write_bytes(raw)
def header(b):return list(struct.unpack_from('>6H',b,0x1004))
def digest(b):return hashlib.sha256(b[0xe20:0x1ea0]).hexdigest()
def at(rows,f):return next(r for k,r in reversed(rows) if k<=f)
def state(rows,f,slot,gp):
    r=at(rows,f);assert r['slot']==slot and r['view']==1 and r['header'][2]==gp,(f,slot,gp,r)
    assert r['crc'],(f,'checksum');return r
def same(x,y,po=False):
    assert x['header']==y['header'] and x['season']==y['season'],('season changed on switch',x,y)
    if po:assert x['password']==y['password'] and x['bracket']==y['bracket'],('playoff state changed',x,y)
def run(name,source,lines,force=-1,corrupt_slots=()):
    case=out/name;case.mkdir(exist_ok=True);save=case/'battery.srm';shutil.copy2(source,save)
    cmd=case/'commands.txt';cmd.write_text('\n'.join(lines)+'\n')
    env=dict(os.environ,GPGX_DEV='513',GPGX_SRM=str(save),GPGX_HOOK=str(here/'testcases/season_slots_hook.py'),FORCE_END=str(force))
    with (case/'log.txt').open('w') as log:subprocess.run(['python3',str(here/'test_core.py'),str(rom),str(cmd),str(case)],env=env,check=True,stdout=log,stderr=log)
    rows=[json.loads(s) for s in (case/'trace.jsonl').read_text().splitlines()]
    b=unpack(save);assert len(b)==32768;crc(b)
    mask=int.from_bytes(b[0x1ea8:0x1eaa],'big')
    for slot in [1,2,3]:
        if mask&(1<<(slot-1)) and slot not in corrupt_slots:
            bank=b[8192*slot:8192*(slot+1)];crc(bank)
            assert bank[0x1ea0:0x1ea4]==b'S3SV' and int.from_bytes(bank[0x1ea6:0x1ea8],'big')==slot
    reports.append(dict(case=name,samples=len(rows),header=header(b),active=rows[-1][1]['slot']))
    return rows,save,b

def league(b):
    gp=header(b)[2];rows=[struct.unpack_from('>8H',b,0x1010+16*t) for t in range(32)]
    assert all(r[0]==gp and r[0]==r[1]+r[2]+r[3]+r[6] for r in rows)
    assert sum(r[4] for r in rows)==sum(r[5] for r in rows)
    for t,r in enumerate(rows):
        players=[struct.unpack_from('>BBH',b,0x1220+100*t+4*k) for k in range(25)]
        assert sum(p[0] for p in players)==r[4] and sum(p[1] for p in players)<=2*r[4]
        assert all(p[2]>=p[0] for p in players)

fresh=out/'fresh.srm';b=unpack(a.seed);b=b[:8192];b[0xe20:0x1ef6]=bytes(0x10d6)
b[0x1000:0x1004]=b'SESN';struct.pack_into('>6H',b,0x1004,0,0,0,84,1,3);put(fresh,b)
# Different clubs and GP in all three slots, through native menus and Simulate Match.
lines=['1100 press 1 Start 5','1500 press 1 Down 5','1600 press 1 C 5','2100 press 1 Start 5',
'2600 press 1 Right 5','2800 press 1 Down 5','3000 press 1 Down 5','3200 press 1 Left 5','3400 press 1 Left 5','3700 press 1 Start 5','4100 press 1 Down 5','4200 press 1 C 5','4600 press 1 Start 5',
'5100 press 1 Start 5','5500 press 1 Down 5','5600 press 1 C 5','6000 press 1 Start 5',
'6500 press 1 Right 5','6700 press 1 Down 5','6900 press 1 Down 5','7100 press 1 Right 5','7300 press 1 Right 5','7500 press 1 Start 5','7900 press 1 Down 5','8000 press 1 C 5','8400 press 1 Start 5',
'8900 press 1 Left 5','9300 press 1 Left 5','9700 press 1 Right 5','9900 press 1 Right 5','10300 exit']
r,save,b=run('roundtrip',fresh,lines)
full=state(r,2300,1,1);light=state(r,6300,2,2);mini=state(r,8700,3,1)
same(full,state(r,9500,1,1));same(light,state(r,9100,2,2));same(mini,state(r,10100,3,1))
assert len({full['header'][1],light['header'][1],mini['header'][1]})==3
for s in [1,2]:league(b[s*8192:(s+1)*8192])
league(b)
r,_,_=run('power-cycle',save,['1100 press 1 Left 5','1300 press 1 Left 5','1500 press 1 Right 5','1700 press 1 Right 5','2000 exit'])
same(mini,state(r,1000,3,1));same(light,state(r,1200,2,2));same(full,state(r,1400,1,1));same(mini,state(r,1900,3,1))
# Cancel is non-destructive; confirm resets only Light, including its player/goalie data.
lines=['1100 press 1 Left 5','1300 press 1 Start 5']+[f'{1700+i*100} press 1 Down 5' for i in range(8)]+['2600 press 1 C 5','2900 press 1 Start 5','3200 press 1 C 5','3500 press 1 A 5','3500 press 1 C 5','4100 press 1 Left 5','4500 press 1 Right 5','4700 press 1 Right 5','5100 exit']
r,_,_=run('restart-light-only',save,lines)
same(light,state(r,3100,2,2));cleared=state(r,3900,2,0);assert cleared['header'][1]==0 and cleared['header'][4]==2
same(full,state(r,4300,1,1));same(mini,state(r,4900,3,1))
# Genuine legacy data migrates byte-for-byte, including82-game schedules.
fixtures=[('legacy82',root/'simulate-match-20261023-153017/full-before82-qualified.srm',1),('legacy31',root/'light-last-game-20261023-104717/edmonton-before-game31.srm',2),('legacy8',root/'mini-season-20261024-133459/mini-before8-qualified.srm',3)]
for name,seed,slot in fixtures:
    original=unpack(seed);gp=header(original)[2]
    directions=['Right','Left'] if slot<3 else ['Left','Right']
    r,_,_=run(name,seed,[f'1100 press 1 {directions[0]} 5',f'1300 press 1 {directions[1]} 5','1600 exit'])
    first=state(r,1000,slot,gp);assert first['season']==digest(original)
    same(first,state(r,1500,slot,gp))
# A real single-game playoff save resumes its round/bracket/password across slots and restart.
seed=root/'mini-season-20261024-133459/playoff.srm'
r,ps,_=run('mini-playoff-resume',seed,['1100 press 1 Left 5','1300 press 1 Left 5','1500 press 1 Right 5','1700 press 1 Right 5','2100 press 1 Right 5','2300 exit'])
po=state(r,1000,3,8);assert po['bracket']['round']==1;same(po,state(r,1900,3,8),True)
r,_,_=run('resume-suspended-after-reboot',ps,['1200 exit']);same(po,state(r,1100,3,8),True)
# Restart is available in season-playoff pre-game menus too.
lines=['1100 press 1 Start 5','1700 press 1 Start 5','2100 press 1 Start 5']+[f'{2600+i*100} press 1 Down 5' for i in range(8)]+['3500 press 1 C 5','3900 press 1 A 5','3900 press 1 C 5','4600 exit']
if refinements_enabled(rom):
    # The real Matchups acknowledgement helper now returns to the playoff
    # pregame by2000 (opened current-ROM replay evidence). A third Start at
    #2100 starts the match instead of navigating Abandon Season. Retain all
    # original reset timing, slot/password/checksum and preservation oracles.
    lines.remove('2100 press 1 Start 5')
r,_,_=run('restart-mini-playoffs-only',ps,lines);assert state(r,4400,3,0)['header'][4]==3
# Full84: advance a real81-game fixture three times, then play/finish a native playoff game.
b=unpack(fixtures[0][1]);struct.pack_into('>H',b,0x100a,84);seed84=out/'before84.srm';put(seed84,b)
lines=[]
for t in [1100,2600,4100]:lines +=[f'{t} press 1 Start 5',f'{t+400} press 1 Down 5',f'{t+500} press 1 C 5',f'{t+1000} press 1 Start 5']
lines+=['5700 press 1 Start 5','6500 press 1 Start 5','7300 press 1 Start 5','7700 press 1 Start 5','11500 press 1 Start 5','13000 press 1 Start 5','16000 exit']
r,fs,fb=run('full84-final-and-playoff-game',seed84,lines,9000);league(fb)
assert state(r,5500,1,84)['playmode']==3
assert any(sum(sum(v) for v in row['bracket']['wins'])>0 and row['password']!='0'*20 for _,row in r)
r,_,_=run('full84-best-of-seven-resume',fs,['1100 press 1 Right 5','1300 press 1 Right 5','1500 press 1 Left 5','1700 press 1 Left 5','2000 exit'])
po=state(r,1000,1,84);assert po['bracket']['round']==0 and max(sum(v) for v in po['bracket']['wins'])==1
same(po,state(r,1900,1,84),True)
# Native final-match transition for both short formats.
for name,source,slot in fixtures[1:]:
    r,_,_=run(name+'-final',source,['1100 press 1 Start 5','1500 press 1 Down 5','1600 press 1 C 5','2100 press 1 Start 5','2600 exit'])
    row=state(r,2500,slot,31 if slot==2 else 8);assert row['playmode']==2 and row['bracket']['series']==7
# A stale old New choice must not discard the existing legacy full season.
b=unpack(fixtures[0][1]);struct.pack_into('>H',b,0x100c,3);pending=out/'legacy-pending.srm';put(pending,b)
r,_,_=run('legacy-pending-keeps-running-season',pending,['1200 exit'])
struct.pack_into('>H',b,0x100c,0);assert state(r,1100,1,81)['season']==digest(b)
# Legacy82 still ends at82, with its original best-of-seven playoffs.
r,_,_=run('legacy82-final',fixtures[0][1],['1100 press 1 Start 5','1500 press 1 Down 5','1600 press 1 C 5','2100 press 1 Start 5','2600 exit'])
assert state(r,2500,1,82)['header'][3]==82 and state(r,2500,1,82)['playmode']==3
# Reject a damaged inactive slot before overwriting any current data or its backup.
b=unpack(save);b[0x4000+0x1220]^=1;damaged=out/'damaged.srm'
raw=bytearray(len(b)*2);raw[1::2]=b;damaged.write_bytes(raw)
r,_,after=run('damaged-slot-keeps-current',damaged,['1100 press 1 Left 5','1400 exit'],corrupt_slots=(2,))
same(mini,state(r,1300,3,1));assert b[0x4000:0x6000]==after[0x4000:0x6000]
# Finish an actual Light playoff game; single-game mode advances to round2.
lines=['1100 press 1 Start 5','1500 press 1 Down 5','1600 press 1 C 5','2100 press 1 Start 5','2600 press 1 Start 5','3400 press 1 Start 5','4200 press 1 Start 5','4600 press 1 Start 5','8500 press 1 Start 5','9200 press 1 Start 5','10000 press 1 C 5','11000 exit']
r,ls,_=run('light-final-and-playoff-game',fixtures[1][1],lines,6000)
r,ls,lb=run('light-single-game-resume',ls,['1100 press 1 Right 5','1300 press 1 Left 5','1500 press 1 Left 5','1700 press 1 Right 5','2000 exit'])
lightpo=state(r,1000,2,31);assert lightpo['bracket']['round']==1;same(lightpo,state(r,1900,2,31),True)
# Combine three native, independently played playoff contexts, then switch all of them.
fullsave=out/'full84-best-of-seven-resume/battery.srm'
minisave=out/'mini-playoff-resume/battery.srm'
fullbytes=unpack(fullsave);minibytes=unpack(minisave)
combined=bytearray(fullbytes)
combined[0x4000:0x6000]=lb[0x4000:0x6000]
combined[0x6000:0x8000]=minibytes[0x6000:0x8000]
struct.pack_into('>HHH',combined,0x1ea6,1,7,1)
allpo=out/'three-playoffs.srm';put(allpo,combined)
r,cs,_=run('three-concurrent-playoffs',allpo,['1100 press 1 Right 5','1300 press 1 Right 5','1500 press 1 Left 5','1700 press 1 Left 5','2100 exit'])
fullpo=state(r,1000,1,84);same(fullpo,state(r,1900,1,84),True)
same(lightpo,state(r,1200,2,31),True)
same(at([json.loads(x) for x in (out/'mini-playoff-resume/trace.jsonl').read_text().splitlines()],1000),state(r,1400,3,8),True)
r,_,_=run('three-playoffs-after-reboot',cs,['1100 press 1 Right 5','1300 press 1 Right 5','1500 press 1 Left 5','1700 press 1 Left 5','2100 exit'])
same(fullpo,state(r,1900,1,84),True);same(lightpo,state(r,1600,2,31),True)
(out/'results.json').write_text(json.dumps(reports,indent=2)+'\n')
print('PASS',len(reports),'season-slot migration, real-input roundtrip/reboot/restart and playoff cases')
