#!/usr/bin/env python3
"""Prepare reproducible card assets; build_test.py itself never uses the network.

prepare_player_cards.py ORIGINAL_ROM CURRENT_ROM --data tools/data --cache /private/cache
Catalogue names/IDs come from cached official NHL rosters/prospects/statistics.
New faces use the matching NHL player landing response's headshot URL. Crop the
face and encode it into the existing48x48, eight-colour tile format. Source
responses/PNGs remain in the private cache; public metadata records provenance.
Requires Pillow/numpy/OpenCV and its installed frontal-face cascade.
"""
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
from urllib.request import Request,urlopen
import argparse,collections,hashlib,json,struct,time
import numpy as np
from PIL import Image,ImageDraw
import cv2
from player_cards import identity,rosters,ASSETS,TABLE,TABLE_END,PHOTO_BYTES

def fetch(url,path):
    if path.exists():return path.read_bytes()
    for retry in range(3):
        try:
            data=urlopen(Request(url,headers={'User-Agent':'Mozilla/5.0','Referer':'https://www.nhl.com/'}),timeout=30).read()
            path.write_bytes(data);return data
        except Exception:
            if retry==2:raise
            time.sleep(0.5*(retry+1))

def registry(data):
    result=collections.defaultdict(dict)
    def text(v):return v.get('default','') if isinstance(v,dict) else v or ''
    def walk(x):
        if isinstance(x,dict):
            pid=x.get('id',x.get('playerId'))
            name=(text(x['firstName'])+' '+text(x['lastName'])).strip() if x.get('firstName') and x.get('lastName') else x.get('skaterFullName') or x.get('goalieFullName') or x.get('fullName')
            if isinstance(pid,int) and pid>8000000 and isinstance(name,str) and name:result[identity(name)][pid]=name
            for v in x.values():walk(v)
        elif isinstance(x,list):
            for v in x:walk(v)
    for path in sorted(data.glob('*.json')):
        try:walk(json.loads(path.read_text()))
        except (ValueError,UnicodeError):pass
    aliases=json.loads((Path(__file__).parent/'player_card_aliases.json').read_text())
    for alias,record in aliases.items():result[identity(alias)]={record['nhl_id']:record['official_name']}
    return result

def encode(source,palette,cascade):
    rgba=Image.open(source).convert('RGBA')
    image=Image.new('RGB',rgba.size,'white');image.paste(rgba,mask=rgba.getchannel('A'))
    pixels=np.asarray(image)
    faces=cascade.detectMultiScale(cv2.cvtColor(pixels,cv2.COLOR_RGB2GRAY),1.05,5,
                                  minSize=(max(30,image.width//8),)*2)
    candidates=[list(map(int,f)) for f in faces if abs(f[0]+f[2]/2-image.width/2)<image.width*.2]
    placeholder=len(np.unique(pixels.reshape(-1,3),axis=0))<=512
    if placeholder:
        crop=(0,0,image.width,image.height);method='nhl-placeholder'
    elif candidates:
        x,y,width,height=max(candidates,key=lambda f:f[2]*f[3])
        size=round(max(width,height)*1.5);left=round(x+width/2-size/2);top=round(y-height*.34)
        crop=(left,top,left+size,top+size);method='face-detection'
    else:
        box=rgba.getbbox() or (0,0,image.width,image.height)
        size=round(image.width*.54);left=(image.width-size)//2;top=box[1]+round(image.height*.01)
        crop=(left,top,left+size,top+size);method='review-standard-crop'

    image=image.crop(crop).resize((48,48),Image.Resampling.LANCZOS)
    fixed=Image.new('P',(1,1));fixed.putpalette([v for rgb in palette for v in rgb]+list(palette[-1])*248)
    indices=np.minimum(np.asarray(image.quantize(palette=fixed,dither=Image.Dither.FLOYDSTEINBERG)),7)
    body=bytearray()
    for ty in range(6):
        for tx in range(6):
            for row in range(8):
                packed=0
                for col in range(8):packed=(packed<<3)|int(indices[ty*8+row,tx*8+col])
                body+=packed.to_bytes(3,'big')
    preview=Image.fromarray(np.asarray(palette,dtype=np.uint8)[indices])
    return bytes(8)+struct.pack('>H',36)+body,preview,crop,method

def main():
    ap=argparse.ArgumentParser(description=__doc__)
    ap.add_argument('original',type=Path);ap.add_argument('current',type=Path)
    ap.add_argument('--data',type=Path,required=True);ap.add_argument('--cache',type=Path,required=True)
    args=ap.parse_args();args.cache.mkdir(parents=True,exist_ok=True);ASSETS.mkdir(exist_ok=True)
    original=args.original.read_bytes();current=args.current.read_bytes()
    assert hashlib.md5(original).hexdigest()=='8f863ecb108c1d213953bada9b98f4f2'
    ids=registry(args.data);old_names=rosters(original);names=sorted({n for row in rosters(current) for n in row})
    old={}
    for team,row in enumerate(old_names):
        for k in range(26):
            ptr,slot=struct.unpack_from('>IH',original,TABLE+team*160+k*6)
            key=identity(row[slot]);assert key not in old;old[key]=ptr
    original_by_id={}
    original_names={identity(n):n for row in old_names for n in row}
    for key,ptr in old.items():
        if len(ids[key])==1:
            pid=next(iter(ids[key]));assert pid not in original_by_id,('ambiguous original identity',pid)
            original_by_id[pid]=(ptr,original_names[key])
    palbytes=original[0xc63f8+0x36a+32:0xc63f8+0x36a+64]
    words=struct.unpack('>16H',palbytes)
    palette=[tuple(((word>>shift)&14)*255//14 for shift in (0,4,8)) for word in words[5:13]]
    cascades=[*Path('/usr/share').glob('opencv*/haarcascades/haarcascade_frontalface_default.xml')]
    try:cascades.append(Path(cv2.data.haarcascades)/'haarcascade_frontalface_default.xml')
    except AttributeError:pass
    assert cascades,'OpenCV frontal-face cascade not installed'
    for name in names:assert len(ids[identity(name)])==1,('ambiguous/missing NHL identity',name,ids[identity(name)])
    source_overrides=json.loads((Path(__file__).parent/'player_card_sources.json').read_text())
    def prepare(name):
        key=identity(name);pid=next(iter(ids[key]))
        record=dict(name=name,nhl_id=pid)
        if key in old or pid in original_by_id:
            ptr,original_name=(old[key],original_names[key]) if key in old else original_by_id[pid]
            record.update(kind='original',original_name=original_name,pointer=ptr,sha256=hashlib.sha256(original[ptr:ptr+PHOTO_BYTES]).hexdigest())
            return key,record,None
        url=f'https://api-web.nhle.com/v1/player/{pid}/landing'
        meta=json.loads(fetch(url,args.cache/f'{pid}.json'))
        canonical=meta['firstName']['default']+' '+meta['lastName']['default']
        assert meta['playerId']==pid and identity(canonical)==key,('NHL identity mismatch',name,canonical,pid)
        override=source_overrides.get(str(pid))
        photo=override['url'] if override else meta['headshot'];assert photo.startswith('https://assets.nhle.com/') and f'/{pid}.' in photo
        image_path=args.cache/(f'{pid}-override.png' if override else f'{pid}.png')
        raw=fetch(photo,image_path);cascade=cv2.CascadeClassifier(str(cascades[0]))
        blob,preview,crop,method=encode(image_path,palette,cascade)
        file=f'{pid}.bin';(ASSETS/file).write_bytes(blob);preview.save(args.cache/f'{pid}-48.png')
        record.update(kind='nhl-placeholder' if method=='nhl-placeholder' else 'nhl',crop_method=method,file=file,sha256=hashlib.sha256(blob).hexdigest(),source=photo,
                      metadata_url=url,source_sha256=hashlib.sha256(raw).hexdigest(),crop=list(crop),canonical_name=canonical)
        if override:record['source_reason']=override['reason']
        print('Prepared',pid,name,flush=True)
        return key,record,preview
    with ThreadPoolExecutor(max_workers=4) as pool:results=list(pool.map(prepare,names))
    manifest=dict(version=1,original_rom_md5=hashlib.md5(original).hexdigest(),
                  original_table_sha256=hashlib.sha256(original[TABLE:TABLE_END]).hexdigest(),
                  palette_sha256=hashlib.sha256(palbytes).hexdigest(),
                  players={key:record for key,record,_ in results})
    keep={r['file'] for _,r,_ in results if 'file' in r}
    for path in ASSETS.glob('*.bin'):
        if path.name not in keep:path.unlink()
    (ASSETS/'manifest.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2)+'\n')
    faces=[(record,preview) for _,record,preview in results if preview is not None]
    sheet=Image.new('RGB',(800,100*((len(faces)+9)//10)),'#222222');draw=ImageDraw.Draw(sheet)
    for n,(record,preview) in enumerate(faces):
        x=(n%10)*80;y=(n//10)*100;sheet.paste(preview,(x+16,y));draw.text((x,y+50),record['name'][:12],fill='white');draw.text((x,y+65),str(record['nhl_id']),fill='white')
    sheet.save(args.cache/'contact-sheet.png')
    counts=collections.Counter(record['kind'] for _,record,_ in results)
    print('Prepared',len(names),'roster names;',dict(counts))

if __name__=='__main__':main()
