# Build rosters/<AB>.json for all teams from NHL API + DailyFaceoff, ratings from ROM or kNN.
import json, struct, re, unicodedata, statistics as st, itertools, sys, os
_CWD0 = os.getcwd()
os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data'))
ROM = os.path.expanduser('~/.config/retroarch/roms/claude/NHL 2026 - 94 Edition v5.1W.bin.orig.bak')
OUTDIR = os.path.join(_CWD0, sys.argv[1]) if len(sys.argv) > 1 else os.path.join(os.path.dirname(os.path.abspath(__file__)), 'rosters')
SKIP = set(sys.argv[2:])          # team abbrevs to leave alone (e.g. EDM, hand-made)
d = open(ROM, 'rb').read()
AB = ['ANA','BOS','BUF','CGY','CAR','CHI','COL','CBJ','DAL','DET','EDM','FLA','LAK','MIN','MTL','NSH',
      'NJD','NYI','NYR','OTT','PHI','PIT','SJS','SEA','STL','TBL','TOR','UTA','VAN','VGK','WSH','WPG']

def norm(s):
    s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode().lower()
    return re.sub(r'[^a-z]', '', s)
def ascii_(s): return unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode()

# ---------- NHL stats ----------
bio, sm, rt, G = {}, {}, {}, {}
for s in ('20242025', '20252026', '20262027'):
    for p in json.load(open(f'sk_bios_{s}.json'))['data']: bio[p['playerId']] = p
    for p in json.load(open(f'sk_summary_{s}.json'))['data']: sm.setdefault(p['playerId'], []).append(p)
    for p in json.load(open(f'sk_realtime_{s}.json'))['data']: rt.setdefault(p['playerId'], []).append(p)
    for p in json.load(open(f'g_{s}.json'))['data']:
        g = G.setdefault(p['playerId'], dict(gp=0, sa=0, sv=0)); g['gp'] += p['gamesPlayed']; g['sa'] += p['shotsAgainst']; g['sv'] += p['saves']
bioname = {norm(b['skaterFullName']): pid for pid, b in bio.items()}

def feats(pid, wgt=None, isD=None):
    S = sm.get(pid, []); R = rt.get(pid, [])
    gp = sum(x['gamesPlayed'] for x in S)
    if gp == 0: return None, 0
    toi = sum(x['timeOnIcePerGame'] * x['gamesPlayed'] for x in S) / gp / 60
    hrs = max(1, sum(x['timeOnIcePerGame'] * x['gamesPlayed'] for x in R) / 3600)
    h60 = lambda k: sum(x[k] for x in R) / hrs
    b = bio.get(pid, {})
    return dict(isD=float(isD if isD is not None else b.get('positionCode') == 'D'),
                ppg=sum(x['points'] for x in S) / gp, gpg=sum(x['goals'] for x in S) / gp, apg=sum(x['assists'] for x in S) / gp,
                spg=sum(x['shots'] for x in S) / gp, toi=toi, hits=h60('hits'), blk=h60('blockedShots'), take=h60('takeaways'),
                give=h60('giveaways'), pim=sum(x['penaltyMinutes'] for x in S) / gp, wgt=wgt or b.get('weight', 200),
                pm=sum(x['plusMinus'] for x in S) / gp), gp
KEYS = ['isD','ppg','gpg','apg','spg','toi','hits','blk','take','give','pim','wgt','pm']
W = dict(isD=3, ppg=2, gpg=1.5, apg=1.5, spg=1, toi=1.5, hits=1, blk=1, take=.7, give=.5, pim=1, wgt=1, pm=.5)

# ---------- ROM teams ----------
def tptr(t): return struct.unpack('>I', d[0x30E + 4 * t:0x312 + 4 * t])[0]
def team_players(t):
    a = tptr(t); h = struct.unpack('>6H', d[a:a + 12]); i = a + h[0]; out = []
    while i < a + h[2] - 2:
        n = struct.unpack('>H', d[i:i + 2])[0]
        out.append((d[i + 2:i + n].rstrip(b'\x00').decode('latin1'), bytes(d[i + n:i + n + 8]))); i += n + 8
    return out
gnames = set()
for s in ('20242025', '20252026', '20262027'):
    for p in json.load(open(f'g_{s}.json'))['data']: gnames.add(norm(p['goalieFullName']))
ROMP = {}          # norm name -> (rom name, bytes)
COUNTS = {}
romrows = []
CLS = {}
POS = {}
for s_ in ('20242025', '20252026', '20262027'):
    for p in json.load(open(f'sk_bios_{s_}.json'))['data']: POS[norm(p['skaterFullName'])] = p['positionCode']
import glob
for f_ in glob.glob('roster_*.json') + glob.glob('prosp_*.json'):
    r_ = json.load(open(f_))
    for g_ in ('forwards', 'defensemen', 'goalies'):
        for p in r_[g_]: POS.setdefault(norm(p['firstName']['default'] + p['lastName']['default']), p['positionCode'])
for t in range(32):
    pl = team_players(t); cls = ''
    for i, (nm, b) in enumerate(pl):
        k = norm(nm.replace('_', ' '))
        if k in gnames or POS.get(k) == 'G': c = 'G'
        elif POS.get(k) == 'D': c = 'D'
        elif POS.get(k) in ('C', 'L', 'R'): c = 'F'
        elif cls.endswith('G') and b[4] == 0 and b[5] & 0xf0 == 0: c = 'G'
        else: c = cls[-1] if cls else 'G'
        cls += c
    COUNTS[AB[t]] = (cls.count('G'), cls.count('F'), cls.count('D'))
    for i, (nm, b) in enumerate(pl):
        ROMP.setdefault(norm(nm.replace('_', ' ')), (nm, b))
        romrows.append(dict(name=nm, grp=cls[i], nib=[int(c, 16) for c in b[1:].hex()]))
    CLS[AB[t]] = [(nm, b, cls[i]) for i, (nm, b) in enumerate(pl)]

# kNN training set: ROM skaters with NHL stats
train = []
for r in romrows:
    if r['grp'] == 'G': continue
    if all(x == 1 for x in r['nib'][1:]): continue          # hack placeholder ratings, not real data
    pid = bioname.get(norm(r['name'].replace('_', ' ')))
    if pid is None: continue
    f, gp = feats(pid, isD=(r['grp'] == 'D'))
    if f and gp >= 25: train.append((r, f))
mu = {k: st.mean(f[k] for _, f in train) for k in KEYS}; sd = {k: st.pstdev(f[k] for _, f in train) or 1 for k in KEYS}
z = lambda f: [W[k] * (f[k] - mu[k]) / sd[k] for k in KEYS]
TZ = [(r, z(f)) for r, f in train]
def knn(f, k=12):
    zf = z(f); ds = sorted(((sum((a - b) ** 2 for a, b in zip(zf, zz)), r) for r, zz in TZ), key=lambda x: x[0])[:k]
    ws = sum(1 / (.5 + dd) for dd, _ in ds)
    return [sum(r['nib'][j] / (.5 + dd) for dd, r in ds) / ws for j in range(14)], [r['name'] for _, r in ds[:3]]
xs = [(f['wgt'], r['nib'][0]) for r, f in train]
mx = st.mean(a for a, _ in xs); my = st.mean(b for _, b in xs)
kw = sum((a - mx) * (b - my) for a, b in xs) / sum((a - mx) ** 2 for a, _ in xs)
wn = lambda lb: max(1, min(14, int(my + kw * (lb - mx) + .5)))
r5 = lambda x: int(x + .5)
# templates for players without NHL games: bottom-usage ROM players
def template(isD):
    low = [r for r, f in train if f['isD'] == float(isD) and f['toi'] < (15 if isD else 11.5)]
    return [st.mean(r['nib'][j] for r in low) for j in range(14)]
TPL = {False: template(False), True: template(True)}
# goalies
gtrain = []
for r in romrows:
    if r['grp'] != 'G': continue
    pid = None
    for s in ('20242025', '20252026', '20262027'):
        for p in json.load(open(f'g_{s}.json'))['data']:
            if norm(p['goalieFullName']) == norm(r['name'].replace('_', ' ')): pid = p['playerId']
    if pid and G[pid]['sa'] > 200: gtrain.append((G[pid]['sv'] / G[pid]['sa'], G[pid]['gp'], r))
def goalie_rating(pid, lb):
    g = G.get(pid)
    if g and g['sa'] >= 150:
        sv = g['sv'] / g['sa']; sv = (sv * g['sa'] + .895 * 400) / (g['sa'] + 400)   # shrink to league-ish
        nb = sorted(gtrain, key=lambda x: abs(x[0] - sv) * 100 + abs(min(x[1], 150) - min(g['gp'], 150)) / 60)[:8]
        why = 'sv%% %.3f like %s' % (sv, ', '.join(r['name'] for _, _, r in nb[:3]))
    else:
        nb = sorted(gtrain, key=lambda x: x[0])[:10]; why = 'no NHL sample: backup template'
    v = [r5(st.mean(r['nib'][j] for _, _, r in nb)) for j in range(14)]
    v[0] = wn(lb)
    for j in (6, 7, 8, 9): v[j] = 0
    return ''.join('%x' % x for x in v), why

def skater_rating(pid, isD, lb, hand):
    f, gp = feats(pid, wgt=lb, isD=isD) if pid else (None, 0)
    if f and gp >= 1:
        p, nb = knn(f)
        if gp < 20:                       # shrink small samples toward the low-usage template
            w = gp / 20; p = [w * a + (1 - w) * b for a, b in zip(p, TPL[isD])]
        why = f'{gp} NHL GP, like ' + ', '.join(nb)
    else:
        p = TPL[isD]; why = 'no NHL games: depth template'
    v = [r5(x) for x in p]; v[0] = wn(lb)
    fight = max(0, min(7, r5(p[7] / 2))); v[7] = fight * 2 + (1 if hand == 'R' else 0)
    return ''.join('%x' % x for x in v), why

def romname(first, last):
    n = ascii_(first) + ' ' + ascii_(last).replace(' ', '_')
    if len(n) > 24: n = ascii_(first)[0] + '. ' + ascii_(last).replace(' ', '_')
    return n[:24]

report = []
_ovp = os.path.join(OUTDIR, 'overrides.json')
OVR = {k: v for k, v in json.load(open(_ovp)).items() if not k.startswith('_')} if os.path.exists(_ovp) else {}
FA_USED = set()
TAKEN = set()
for f_ in glob.glob('roster_*.json') + glob.glob('prosp_*.json'):
    r_ = json.load(open(f_))
    for g_ in ('forwards', 'defensemen', 'goalies'):
        for p in r_[g_]: TAKEN.add(norm(p['firstName']['default'] + p['lastName']['default']))
for t, ab in enumerate(AB):
    if ab in SKIP: continue
    nab = 'WSH' if ab == 'WAS' else ab
    R = json.load(open(f'roster_{nab}.json')); D = json.load(open(f'dfo_{nab}.json'))
    P = json.load(open(f'prosp_{nab}.json'))
    people = {}
    def add(p, src):
        k = norm(p['firstName']['default'] + p['lastName']['default'])
        if k not in people: people[k] = dict(p=p, src=src)
    for g in ('forwards', 'defensemen', 'goalies'):
        for p in R[g]: add(p, 'roster')
    for g in ('forwards', 'defensemen', 'goalies'):
        for p in P[g]: add(p, 'prospect')
    for nm, b, c in CLS[ab]:                      # old ROM players not on any current NHL roster/prospect list
        k = norm(nm.replace('_', ' '))
        if k in TAKEN: continue
        fn, ln = (nm.split(' ', 1) + [''])[:2]
        p = dict(id=None, firstName={'default': fn}, lastName={'default': ln.replace('_', ' ')},
                 positionCode={'G': 'G', 'D': 'D'}.get(c, 'C'), sweaterNumber=int(b[:1].hex()) if b[:1].hex().isdigit() else None,
                 weightInPounds=200, shootsCatches='L')
        add(p, 'romleft'); FA_USED.add(k)
    def find(name):
        k = norm(name)
        if k in people: return k
        for kk in people:                                     # e.g. "JJ" vs "J.J.", nicknames: match on last name + initial
            pl = people[kk]['p']
            if norm(pl['lastName']['default']) == norm(name.split(' ', 1)[-1]) and norm(pl['firstName']['default'])[:1] == k[:1]: return kk
        return None
    dfo = {}
    for e in D:
        k = find(e['name'])
        if k: dfo.setdefault(k, []).append((e['groupIdentifier'], e['positionIdentifier'], e.get('injuryStatus')))
    hurt = {k for k, v in dfo.items() if any(g == 'ir' for g, _, _ in v)}
    def grp(k):
        pc = people[k]['p']['positionCode']; return 'G' if pc == 'G' else 'D' if pc == 'D' else 'F'
    def prio(k):
        v = dfo.get(k, []); gs = [g for g, _, _ in v]
        if any(g in ('f1','f2','f3','f4','d1','d2','d3') for g in gs): return 0
        if any(g == 'g' for g in gs): return 0 if any(pi == 'g1' for g, pi, _ in v) else 1
        if people[k]['src'] == 'roster' and k not in hurt: return 2
        if k in hurt: return 3
        if people[k]['src'] == 'romleft': return 6
        pid = people[k]['p']['id']; gp = sum(x['gamesPlayed'] for x in sm.get(pid, [])) + G.get(pid, {}).get('gp', 0)
        return 4 if gp else 5
    ng, nf, nd = COUNTS[ab]
    navail = lambda gname: len([k for k in people if grp(k) == gname])
    if navail('D') < nd: nf += nd - navail('D'); nd = navail('D')
    if navail('F') < nf: nd += nf - navail('F'); nf = navail('F')
    chosen = {}
    for gname, n in (('G', ng), ('F', nf), ('D', nd)):
        cand = [k for k in people if grp(k) == gname]
        def sk(k):
            p = people[k]['p']; pid = p['id']
            pts = sum(x['points'] for x in sm.get(pid, [])) + G.get(pid, {}).get('gp', 0)
            age = int(p.get('birthDate', '2000')[:4])
            return (prio(k), -pts, age)
        cand.sort(key=sk)
        chosen[gname] = cand[:n]
        while len(chosen[gname]) < n:              # free agents: old ROM players not on any NHL list, best first
            fa = [(sum(b[1:]), nm, b, c, t2) for t2 in CLS for nm, b, c in CLS[t2] if c == gname
                  and norm(nm.replace('_', ' ')) not in TAKEN and norm(nm.replace('_', ' ')) not in FA_USED]
            if not fa: print('!!', ab, gname, 'no free agents'); break
            fa.sort(reverse=True); _, nm, b, c, t2 = fa[0]; k = norm(nm.replace('_', ' ')); FA_USED.add(k)
            fn, ln = (nm.split(' ', 1) + [''])[:2]
            people[k] = dict(p=dict(id=None, firstName={'default': fn}, lastName={'default': ln.replace('_', ' ')},
                 positionCode={'G': 'G', 'D': 'D'}.get(c, 'C'), sweaterNumber=None, weightInPounds=200, shootsCatches='L'), src='romleft')
            chosen[gname].append(k); print('   ', ab, 'free agent', nm, 'from old', t2)
    # build entries
    used_j = set()
    def entry(k):
        p = people[k]['p']; first, last = p['firstName']['default'], p['lastName']['default']
        rp = ROMP.get(norm(first + ' ' + last))
        nm = rp[0] if rp else romname(first, last)
        if rp and grp(k) != 'G' and all(x == 1 for x in [int(c, 16) for c in rp[1][1:].hex()][1:]): rp = None   # hack placeholder (all 1s)
        j = p.get('sweaterNumber')
        if j is None or j in used_j or j > 99: j = next(x for x in range(1, 100) if x not in used_j)
        used_j.add(j)
        lb = p.get('weightInPounds', 200)
        ov = OVR.get(nm.replace('_', ' '))
        if ov: src, why = ov, 'set by hand (overrides.json)'
        elif rp: src, why = 'rom', 'ROM'
        elif grp(k) == 'G': src, why = goalie_rating(p['id'], lb)
        else: src, why = skater_rating(p['id'], grp(k) == 'D', lb, p.get('shootsCatches', 'L'))
        return [nm, '%02d' % j, src], why
    lastname = lambda k: ascii_(people[k]['p']['lastName']['default']).lower()
    out = {'team': t, 'note': f'{ab} {__import__("datetime").date.today()} from NHL API roster/prospects + DailyFaceoff lines, official NHL positions; generated by gen.py'}
    names = {}
    whys = []
    for key, gname in (('goalies', 'G'), ('forwards', 'F'), ('defense', 'D')):
        lst = []
        for k in sorted(chosen[gname], key=lastname):
            e, why = entry(k); lst.append(e); names[k] = e[0]; whys.append((e[0], why, prio(k)))
        out[key] = lst
    # ---- lines ----
    slot = {}
    for k, v in dfo.items():
        for g, pi, _ in v: slot[(g, pi)] = k
    off = lambda k: people[k]['p']['positionCode']          # C / L / R / D / G
    def trio(ks, dfo_pos):
        best = None
        for perm in itertools.permutations(range(3)):
            c = 0
            for s_, i in zip(('L', 'C', 'R'), perm):
                k = ks[i]; c += (0 if off(k) == s_ else 1) + (0 if dfo_pos[i] == s_ else .1)
            if best is None or c < best[0]: best = (c, [ks[i] for i in perm])
        return best[1]
    F = [k for k in chosen['F']]; Dm = [k for k in chosen['D']]
    def fline(n):
        ks = [slot.get((f'f{n}', p)) for p in ('lw', 'c', 'rw')]
        if None in ks or any(k not in names for k in ks): return None
        return trio(ks, ['L', 'C', 'R'])
    def dpair(n):
        ks = [slot.get((f'd{n}', p)) for p in ('ld', 'rd')]
        if None in ks or any(k not in names for k in ks): return None
        a, b = ks
        if off(a) == 'D' and people[a]['p'].get('shootsCatches') == 'R' and people[b]['p'].get('shootsCatches') == 'L': a, b = b, a
        return [a, b]
    goalie = slot.get(('g', 'g1'));
    if goalie not in names: goalie = chosen['G'][0]
    fl = [fline(n) for n in (1, 2, 3, 4)]; dl = [dpair(n) for n in (1, 2, 3)]
    # fallbacks if DFO incomplete
    pool = [k for k in F]
    for i in range(4):
        if fl[i] is None:
            rest = [k for k in pool if not any(k in (x or []) for x in fl)][:3]
            fl[i] = trio(rest, ['L', 'C', 'R']) if len(rest) == 3 else fl[0]
    for i in range(3):
        if dl[i] is None:
            rest = [k for k in Dm if not any(k in (x or []) for x in dl)][:2]
            dl[i] = rest if len(rest) == 2 else dl[0]
    def special(g, n):
        ks = [slot.get((g, f'sk{i}')) for i in range(1, 6)]
        ks = [k for k in ks if k in names]
        dd = [k for k in ks if grp(k) == 'D']; ff = [k for k in ks if grp(k) != 'D']
        return dd, ff
    def unit(g, base_f, base_d, nf):
        dd, ff = special(g, 0)
        if len(dd) + len(ff) < 4: dd, ff = list(base_d), list(base_f)
        while len(dd) < 2: dd.append(next(k for k in Dm if k not in dd))
        allf = ff + dd[2:]; dd = dd[:2]
        fx = [k for k in allf][:3]
        for k in base_f + F:
            if len(fx) >= 3: break
            if k not in fx and k not in dd: fx.append(k)
        fx = trio(fx, ['L', 'C', 'R']) if all(grp(k) == 'F' for k in fx) else fx
        extra = next(k for k in base_f + F if k not in fx and k not in dd)
        return [goalie, dd[0], dd[1], fx[0], fx[1], fx[2], extra]
    # the 7th slot (extra skater) must not be someone from another even-strength line: use the 4th line
    L1 = [goalie] + dl[0] + fl[0] + [fl[3][1]]
    L2 = [goalie] + dl[1] + fl[1] + [fl[3][0]]
    L3 = [goalie] + dl[2] + fl[2] + [fl[3][2]]
    PP1 = unit('pp1', fl[0], dl[0], 5); PP2 = unit('pp2', fl[1], dl[1], 5)
    PK1 = unit('pk1', fl[2], dl[0], 4); PK2 = unit('pk2', fl[3], dl[1], 4)
    lines = [L1, L1, L2, L3, PP1, PP2, PK1, PK2]
    out['lines_order'] = 'G LD RD LW C RW extra'
    out['lines'] = [[names[k] for k in ln] for ln in lines]
    out['lines_names'] = ['(extra/6 attackers?)', 'Sc1', 'Sc2', 'Chk', 'PP1', 'PP2', 'PK1', 'PK2']
    out['fourth_line'] = [names[k] for k in fl[3]]
    out['rating_notes'] = {n: w for n, w, _ in whys if w != 'ROM'}
    out['from_old_rom'] = [names[k] for k in names if people[k]['src'] == 'romleft']
    out['left_out'] = sorted(people[k]['p']['firstName']['default'] + ' ' + people[k]['p']['lastName']['default']
                             for k in people if people[k]['src'] == 'roster' and k not in names)
    json.dump(out, open(os.path.join(OUTDIR, f'{ab}.json'), 'w'), indent=1, ensure_ascii=False)
    nnew = sum(1 for _, w, _ in whys if w != 'ROM'); npro = sum(1 for _, _, p in whys if p >= 4)
    report.append(f'{ab}: G{ng}/F{nf}/D{nd} (rom {COUNTS[ab]})  new ratings {nnew}  prospects {npro}  left out {len(out["left_out"])}  oldrom {len(out["from_old_rom"])}  Sc1 {" - ".join(names[k] for k in fl[0])}')
print('\n'.join(report))
