#!/usr/bin/env python3
"""PORT BATCHES: the kill port plan into the game, split into 10 fair batches, with a tracked checklist per kill.

User, 28 Oct ~23:40: port kills in batches, split by ratio, fair for every weapon; exclude not-built-yet; REWORK is fixed
during the port. Decisions (AskUserQuestion, 28 Oct ~23:50): 10 batches of ~10 % of every weapon; ready first, rework last;
open page items (flagged new kills) finish inside their batch.

State lives in _tools/coord/port_batches.json (the source of truth). The batch assignment is made once and kept stable;
re-running only refreshes names/notes and re-renders the outputs. Outputs:
  _tools/coord/PORT_BATCHES.md   checklist in markdown (one line per kill, per-batch gate)
  65_port_batches.html           the tracker page (linked from 36_index)

Usage:
  python3 _tools/port_batches_gen.py                       re-render from the state file (creates it on the first run)
  python3 _tools/port_batches_gen.py --rebuild             reassign batches from scratch (DROPS ticks; only before porting starts)
  python3 _tools/port_batches_gen.py --tick KEY STEP [--note TEXT]     mark a kill step done (STEP id or 'all')
  python3 _tools/port_batches_gen.py --untick KEY STEP
  python3 _tools/port_batches_gen.py --btick N STEP [--note TEXT]      mark a batch gate step done
  python3 _tools/port_batches_gen.py --block KEY TEXT                  mark a kill blocked (--unblock KEY clears)
"""
import re, glob, json, os, sys, collections, datetime, html

HERE = os.path.dirname(os.path.abspath(__file__)); ROOT = os.path.dirname(HERE)
STATE = os.path.join(HERE, 'coord', 'port_batches.json')
NB = 10

# per-kill port gate (PORT_GATE.md + the 28 Oct house rules); 'fix' only applies to FIXABLE / REWORK / NEW-FLAGGED
STEPS = [
  ('fix',    'FIX',    'Page fixes done first: the verify fix note applied, motionQA 0 FAIL at g5 + g0 with the strict faceless check'),
  ('port',   'PORT',   'Ported to Unity on the batch branch; compiles (csc_check), no warnings'),
  ('parity', 'PARITY', 'Matches the page frame for frame at all 6 gore levels (XT strongest, L/VL alive by design)'),
  ('perf',   'PERF',   'LOW / MED / HIGH (+ perfslow) inside budget: p95 <= 16.7 ms, no GC spikes, film start < 20 ms'),
  ('arenas', 'ARENAS', 'All 18 arenas + the zone version, a strip per arena; the fallback works where staging does not fit'),
  ('look',   'LOOK',   'Frame-by-frame bug look on every arena: nothing over a head, no slide/pop/float/sink, real grip, wide roll-0 final, no on-screen words'),
  ('edge',   'EDGE',   'Edge cases: killer left/right (mirror), mid range, airborne, 2v2, SKIP, double K.O., hazard death, films off'),
  ('coord',  'REVIEW', 'Coordinator review of the game capture side by side with the page'),
]
BSTEPS = [
  ('allpass', 'ALL KILLS PASS', 'Every kill in the batch has every step ticked (or is moved to a later batch with a reason)'),
  ('merge',   'MERGED',         'Batch branch merged into v4 by exact hash, after csc_check + build_test (never while build_test runs)'),
  ('regress', 'REGRESSION',     'Earlier batches still pass: playall + a sampled perf/arena run on the merged build'),
  ('build',   'TEST BUILD',     'A Mac test build for the user (no store build; next store build >= 7)'),
  ('user',    'USER OK',        'The user played the batch and said OK'),
]
PAGE = {'speedsabre': 'sabre'}
# built STYLE 3 replacements still behind a test flag: their slot is REPLACE-flagged (old kill), but the new kill is built
FLAGGED_NEW = {
  'rpg.tilt':            ('MINED', 'NEW STYLE 3 behind ?i58test, not approved: Faris out of the blast radius + tube off her head, crown/plate in zooms, distinct XT/VH/H, wide final (STATE_I.md)'),
  'thammer.obliterated': ('CONDUCTOR', 'NEW STYLE 3 behind ?x2test, not approved: distinct XT/VH/H finals, f5.74 own arm over his chin (x2/STATE.md)'),
  'spear.the-door':      ('KEEP-AWAY', 'NEW STYLE 3 behind ?x7test: maps run, finals/beats/MP4 pack, then coord review (x7/STATE.md)'),
}
NOTES = {
  'knife.splice':       'STACKED (STYLE 3, coord-approved) is behind ?s3test: port STACKED, set live58:true on the page',
  'minigun.hold-still': 'OUT OF TRUE (STYLE 3) is behind ?review58: coord review of outoftrue_v1.mp4 first, then port it (old HOLD STILL is the fallback)',
  # approved kills that the strict faceless check (28 Oct night) now fails: fix on the page before the port
  'minigun.the-shield': 'motion fixed (0/0); its tower map pin (530 u room) is still open',
  'minigun.iai':        'motion fixed; its map room (380 u) plays on 1 arena only, still open',
  'fists.freight-train': 'F+A 29 Oct: g5 6→2, g0 PASS. Left (lane layers): 4.78-5.88 glass-pane shards + INK/blood strokes over her chin; 5.42-6.43 INK strokeRect (iron door/fence outline) across both heads. Plan in _tools/8plus_F.md: lifted steps back after tWall (heads ≥ 30 u), shards below the chest',
  'laser.strike':       'RAILGUN: g5 PASS, g0 1 fail left (facing flip at 11.13 in the VL ending) (x5 STATE)',
  'pump.late-news':     'fix half done: 1 face fail left at 2.65-2.77, likely the lead-in pistol pose (STATE_C.md)',
  'fists.mid-liver-shot': 'fails 0/0 but one g0 face FAILCLASS: killer head covered 4.98-5.07 (8plus_F.md)',
}
# FIXABLE kills a builder already brought to motionQA 0 FAIL at g5 + g0 before the wrap-up (28 Oct night): FIX step pre-ticked
PREFIXED = ['fists.mid-glasgow-kiss', 'fists.mid-cross-counter', 'fists.up-top', 'fists.acid-reflux', 'fists.kidney-stones',
            'revolver.rocking-chair', 'revolver.old-iron', 'revolver.spittoon', 'revolver.rattler', 'revolver.mid-cylinder-grip',
            'revolver.mid-hoodoo', 'revolver.mid-toothpick', 'revolver.skin-gold']

# tracker keys whose page key differs (A, 29 Oct preflight) and badged keys that aren't registered on their page at all
ALIAS = {'sawedoff.both-down': 'sawedoff.mid-both-down', 'sawedoff.harmonica': 'sawedoff.mid-harmonica'}
NOT_ON_PAGE = {'scythe.by-a-thread': 'declared as a skin() in 58k_w_scythe_7.js but never reaches K58.ALL (retired or unwired)',
               'thammer.bling': 'declared as a skin() in 58k_w_thammer.js but never reaches K58.ALL (retired or unwired)'}

def load_pool():
    led = open(os.path.join(HERE, '58k_LEDGER.md')).read()
    names = {}
    for k, n in re.findall(r"^- ([a-z0-9]+\.[a-z0-9\-]+) \(([^)]*)\)", led, re.M):
        names[k] = n  # last ledger line wins (rebuilt slots carry the new name)
    done = dict(re.findall(r"^\s*['\"]([a-z0-9]+\.[a-z0-9\-]+)['\"]\s*:\s*['\"](.*)['\"],?\s*$",
                           open(os.path.join(HERE, 'done58_gen.py')).read(), re.M))
    js = open(os.path.join(ROOT, '58k_replace58.js')).read()
    rep = json.loads(js.split('K58.REPLACE58 = ')[1].split('; K58.REPLACED_LIVE')[0])
    live = json.loads(js.split('K58.REPLACED_LIVE = ')[1].split('; })();')[0])
    ver = {}
    for f in glob.glob(os.path.join(HERE, 'coord', 'verify', '*.md')):
        for l in open(f):
            p = [x.strip() for x in l.split('|')]
            if len(p) > 7 and p[2] in ('READY', 'FIXABLE', 'REWORK') and re.match(r'^[a-z0-9]+\.[a-z0-9\-]+$', p[0]):
                ver[p[0]] = (p[2], p[1], p[-1])
    score = {}
    for f in glob.glob(os.path.join(HERE, 'scores', '*.json')):
        for r in json.load(open(f)):
            score[r['id']] = sum((r.get(k) or 0) for k in ('brut', 'cine', 'script', 'anim'))
    pool = {}
    for k, why in done.items():
        k = ALIAS.get(k, k)
        if k in rep or k in NOT_ON_PAGE: continue
        style3 = k in live
        pool[k] = dict(status='READY', style3=style3, name=live.get(k) or names.get(k, ''),
                       rank=(0 if style3 else 1 if 'VERIFIED READY' in why else 2, -score.get(k, 0)), fix='')
    for k, (v, nm, why) in ver.items():
        if k in pool or k in rep: continue
        pool[k] = dict(status=v, style3=False, name=nm or names.get(k, ''), fix=why if v != 'READY' else '',
                       rank=({'READY': 2, 'FIXABLE': 3, 'REWORK': 5}[v], -score.get(k, 0)))
    for k, (nm, why) in FLAGGED_NEW.items():
        pool[k] = dict(status='NEW-FLAGGED', style3=True, name=nm, fix=why, rank=(4, 0))
    # A's port preflight (strict motionQA at g5 + g0 on the live engine): a READY kill that FAILs it needs a page fix too
    pre = {}
    for f in sorted(glob.glob(os.path.join(HERE, 'coord', 'verify', 'preflight_b*.md'))):
        for l in open(f):
            p = [x.strip() for x in l.split('|')]
            if len(p) > 8 and re.match(r'^[a-z0-9]+\.[a-z0-9\-]+$', p[1]) and p[-2] in ('PASS', 'FAIL'):
                pre[p[1]] = (p[-2], f'g5 {p[3]} g0 {p[4]} F/W; face: {p[5]}; other: {p[6]}')
    for k in pool:
        pool[k]['weapon'] = k.split('.')[0]
        if k in NOTES: pool[k]['note'] = NOTES[k]
        if k in pre:
            pool[k]['pre'] = pre[k][0]
            if pre[k][0] == 'FAIL' and not pool[k]['fix']:
                pool[k]['fix'] = 'PREFLIGHT (strict motionQA, live engine): ' + pre[k][1][:600]
    return pool, len(rep) - sum(1 for k in FLAGGED_NEW if k in rep)

def assign(pool):
    byw = collections.defaultdict(list)
    for k, d in pool.items(): byw[d['weapon']].append((d['rank'], k))
    out = {}
    for w, items in byw.items():
        items.sort(); n = len(items)
        for i, (_, k) in enumerate(items): out[k] = int((i + 0.5) * NB / n) + 1  # centred: small weapons spread evenly, not all in batch 1
    return out

def build_state(rebuild=False):
    pool, notbuilt = load_pool()
    old = {} if rebuild or not os.path.exists(STATE) else json.load(open(STATE))
    kills = old.get('kills', {})
    if not kills:
        b = assign(pool)
        for k, d in pool.items():
            kills[k] = dict(batch=b[k], checks={}, notes=[], blocked='')
            if k in PREFIXED and d['status'] in ('FIXABLE', 'REWORK'):
                kills[k]['checks']['fix'] = '28 Oct night (builder motionQA 0/0 g5+g0; coord strip look at port time)'
    else:  # new pool entries (e.g. a kill fixed or a flagged kill approved) join the last open batch of their weapon
        for k, d in pool.items():
            if k not in kills:
                same = [v['batch'] for kk, v in kills.items() if kk.split('.')[0] == d['weapon']]
                kills[k] = dict(batch=max(same) if same else NB, checks={}, notes=['added after the split'], blocked='')
    for k, d in pool.items():
        kills[k].update(weapon=d['weapon'], name=d['name'], status=d['status'], style3=d['style3'],
                        fix=d['fix'], note=d.get('note', ''), order=list(d['rank']), pre=d.get('pre', ''))
    for k in list(kills):
        if k not in pool: kills[k]['gone'] = True   # slot left the pool (e.g. REPLACE-flagged later); kept for the record
    st = dict(made=old.get('made') or datetime.datetime.now().strftime('%d %b %Y %H:%M'),
              rule=('%d batches; each takes ~1/%d of every weapon pool (by ratio, fair per weapon); inside a weapon: '
                    'STYLE 3 live first, then VERIFIED READY, then other READY by page-62 score, then FIXABLE, then the built '
                    'STYLE 3 kills behind a flag, then REWORK (fixed during its port). Not built yet (REPLACE-flagged slots) '
                    'excluded.') % (NB, NB),
              notbuilt=notbuilt, kills=kills, batches=old.get('batches') or {str(i): dict(checks={}, notes=[]) for i in range(1, NB + 1)})
    return st

def needs_fix(d): return d['status'] in ('FIXABLE', 'REWORK', 'NEW-FLAGGED') or d.get('pre') == 'FAIL'
def kill_steps(d): return [s for s in STEPS if s[0] != 'fix' or needs_fix(d)]
def kill_done(d): return all(d['checks'].get(s[0]) for s in kill_steps(d))

def render(st):
    now = datetime.datetime.now().strftime('%d %b %H:%M')
    kills = {k: v for k, v in st['kills'].items() if not v.get('gone')}
    B = collections.defaultdict(list)
    for k, d in kills.items(): B[d['batch']].append((d['weapon'], d['order'], k))
    for b in B: B[b].sort()
    tot = len(kills); dn = sum(kill_done(d) for d in kills.values())
    cnt = collections.Counter(d['status'] for d in kills.values())
    # ---------- markdown ----------
    md = ['# PORT BATCHES: tracked checklist (generated by _tools/port_batches_gen.py; do not hand-edit, use --tick)', '',
          'User 28 Oct ~23:40: "port kills in batches, u split things to bateches like by ration, fair for everyweapon too, '
          'excluded thing not build yet. rework will now done directly when porting". Decisions: 10 batches, ready first, '
          'rework last, open page items finish inside their batch.', '',
          f'Updated {now}. Pool: **{tot} kills** (READY {cnt["READY"]}, FIXABLE {cnt["FIXABLE"]}, REWORK {cnt["REWORK"]}, '
          f'NEW-FLAGGED {cnt["NEW-FLAGGED"]}); not built yet, excluded: {st["notbuilt"]}. Done: {dn}/{tot}.', '',
          'Rule: ' + st['rule'], '', '## Per-kill steps (all must be ticked; FIX only for FIXABLE / REWORK / NEW-FLAGGED)']
    md += [f'- **{s[1]}**: {s[2]}' for s in STEPS]
    md += ['', '## Per-batch gate'] + [f'- **{s[1]}**: {s[2]}' for s in BSTEPS] + ['']
    for b in range(1, NB + 1):
        items = B.get(b, []); bd = st['batches'][str(b)]
        c = collections.Counter(kills[k]['status'] for _, _, k in items)
        bdn = sum(kill_done(kills[k]) for _, _, k in items)
        md.append(f'## BATCH {b}: {len(items)} kills, {bdn} done (READY {c["READY"]}, FIXABLE {c["FIXABLE"]}, '
                  f'REWORK {c["REWORK"]}, NEW-FLAGGED {c["NEW-FLAGGED"]}; {len({w for w, _, _ in items})} weapons)')
        md.append('Gate: ' + ' · '.join(f'[{"x" if bd["checks"].get(s[0]) else " "}] {s[1]}' for s in BSTEPS))
        w0 = None
        for w, _, k in items:
            d = kills[k]
            if w != w0: md.append(f'\n**{w}**'); w0 = w
            ticks = ' '.join(f'[{"x" if d["checks"].get(s[0]) else " "}] {s[1]}' for s in kill_steps(d))
            tag = '' if d['status'] == 'READY' else f' `{d["status"]}`'
            md.append(f'- `{k}` {d["name"]}{" (STYLE 3)" if d["style3"] else ""}{tag}: {ticks}'
                      + (f'\n  - fix: {d["fix"]}' if d['fix'] else '') + (f'\n  - note: {d["note"]}' if d['note'] else '')
                      + (f'\n  - BLOCKED: {d["blocked"]}' if d['blocked'] else ''))
        md.append('')
    open(os.path.join(HERE, 'coord', 'PORT_BATCHES.md'), 'w').write('\n'.join(md) + '\n')
    # ---------- html ----------
    data = dict(now=now, rule=st['rule'], notbuilt=st['notbuilt'],
                steps=[list(s) for s in STEPS], bsteps=[list(s) for s in BSTEPS], batches=st['batches'],
                kills=[dict(k=k, page='58k_%s.html' % PAGE.get(d['weapon'], d['weapon']), **{x: d[x] for x in
                       ('batch', 'weapon', 'name', 'status', 'style3', 'fix', 'note', 'checks', 'blocked', 'notes', 'pre')})
                       for b in range(1, NB + 1) for _, _, k in B.get(b, []) for d in [kills[k]]])
    tpl = open(os.path.join(HERE, 'port_batches_tpl.html')).read()
    open(os.path.join(ROOT, '65_port_batches.html'), 'w').write(
        tpl.replace('/*DATA*/null', json.dumps(data, ensure_ascii=False)).replace('{{NOW}}', html.escape(now)))
    return tot, dn

def main(a):
    if '--rebuild' in a:
        st = build_state(True)
    else:
        st = build_state()
    note = a[a.index('--note') + 1] if '--note' in a else ''
    if '--tick' in a or '--untick' in a:
        i = a.index('--tick') if '--tick' in a else a.index('--untick'); k, s = a[i + 1], a[i + 2]
        d = st['kills'][k]; ids = [x[0] for x in kill_steps(d)] if s == 'all' else [s]
        for s1 in ids:
            assert s1 in [x[0] for x in STEPS], 'unknown step ' + s1
            if '--tick' in a: d['checks'][s1] = datetime.datetime.now().strftime('%d %b %H:%M')
            else: d['checks'].pop(s1, None)
        if note: d['notes'].append(note)
    if '--btick' in a:
        i = a.index('--btick'); b, s = a[i + 1], a[i + 2]
        assert s in [x[0] for x in BSTEPS], 'unknown batch step ' + s
        st['batches'][b]['checks'][s] = datetime.datetime.now().strftime('%d %b %H:%M')
        if note: st['batches'][b]['notes'].append(note)
    if '--block' in a:
        i = a.index('--block'); st['kills'][a[i + 1]]['blocked'] = a[i + 2]
    if '--unblock' in a:
        st['kills'][a[a.index('--unblock') + 1]]['blocked'] = ''
    tmp = STATE + '.tmp'; json.dump(st, open(tmp, 'w'), indent=1, ensure_ascii=False); os.replace(tmp, STATE)
    tot, dn = render(st)
    print(f'{tot} kills in {NB} batches, {dn} done; wrote coord/port_batches.json, coord/PORT_BATCHES.md, 65_port_batches.html')

if __name__ == '__main__':
    main(sys.argv[1:])
