#!/usr/bin/env python3
"""WEAPON AUDIT (STYLE-PORT, 29 Oct; _tools/coord/WEAPON_RULE.md). Every live kill's weapon-like drawing that does NOT go through the
shared game-weapon path (K.drawWeaponAt / E40.drawWeapon / drawHeld = the body's held b.wd), classified:
  GAME:<id>  a weapon the game has (40_wdefs.js, not hidden): replace the custom drawing with the real one (K.drawWeaponAt / b.wd)
  INK        not a game weapon (a custom blade / club / prop, a retired game weapon): draw it ink-cast (CASTINK58)
  ?          a weapon word near drawing code that needs a human look
Static scan (no page run): each kill's region in its lane file (from its `id: '<id>'` to the next kill's) and the same-file / kit helpers
that region calls. Writes _tools/coord/WEAPON_AUDIT.md (sorted by batch, then weapon, then key) and weapon_audit.json beside it.
   python3 _tools/weapon_audit.py [--out _tools/coord/WEAPON_AUDIT.md]"""
import json, os, re, sys, glob, collections
FB = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
A = sys.argv[1:]
OUT = A[A.index('--out') + 1] if '--out' in A else os.path.join(FB, '_tools', 'coord', 'WEAPON_AUDIT.md')

# ---------------------------------------------------------------- the game's weapons
t = open(A[A.index('--defs') + 1] if '--defs' in A else os.path.join(FB, '40_wdefs.js')).read(); W = json.loads(t[t.index('{'):t.rindex('}') + 1])
GAME = {k for k, d in W.items() if not d.get('hidden')}
GEAR = {k for k, d in W.items() if d.get('gear')}            # throwables / gear: in the game, not as a held weapon
REMOVED = {k for k, d in W.items() if d.get('removed')}
# words -> a game id (None: not a game weapon -> INK). Order matters: the first match on a name wins.
WORDS = [
    (r'hand\s*cannon|handcannon', 'handcannon'), (r'sawn[-_ ]?off|sawed[-_ ]?off|sawedoff', 'sawedoff'), (r'mini[-_ ]?gun|gatling', 'minigun'),
    (r'flame[-_ ]?thrower|flamer', 'flamer'), (r'grenade[-_ ]?launcher|\bgl\s+(gun|tube)', 'gl'), (r'rocket[-_ ]?launcher|\brpg\b|bazooka', 'rpg'),
    (r'cluster[-_ ]?(launcher|gun|lobber)', 'cluster'), (r'laser[-_ ]?(gun|rifle|pistol)', 'laser'), (r'beam[-_ ]?(gun|rifle|cannon)', 'beam'), (r'rail[-_ ]?gun', 'railgun'), (r'ice[-_ ]?gun|cryo', 'ice'),
    (r'glue[-_ ]?gun', 'glue'), (r'golden[-_ ]?(gun|pistol)', 'golden'), (r'portal[-_ ]?gun', 'portalgun'), (r'shrink[-_ ]?ray|shrinker', 'shrink'),
    (r'grav[-_ ]?flip[-_ ]?(gun)?\b', 'gravflip'), (r'speed[-_ ]?sabre|\bsabre', 'speedsabre'), (r'thunder[-_ ]?hammer|thammer', 'thammer'),
    (r'tk[-_ ]?glove|telekin', 'tkglove'), (r'tp[-_ ]?dagger|teleport[-_ ]?dagger', 'tpdagger'), (r'\bscythe', 'scythe'),
    (r'bouncer', 'bouncer'), (r'sniper|marksman', 'sniper'), (r'\bsmg\b|uzi|sub[-_ ]?machine', 'smg'), (r'burst[-_ ]?(rifle|gun)', 'burst'),
    (r'revolver|six[-_ ]?shooter|magnum', 'revolver'), (r'pistol|handgun|sidearm', 'pistol'), (r'shotgun|\bpump\b', 'pump'),
    (r'assault|\bar\b|rifle', 'ar'), (r'\bspear|javelin', 'spear'), (r'frying[-_ ]?pan|\bpan\b|skillet', 'pan'),
    (r'sledge|\bhammer', 'hammer'), (r'\bknife|knives|switchblade|shiv', 'knife'), (r'\bgrenade', 'grenades'),
    # not in the game (or retired): INK
    (r'katana|\bsword|longsword|rapier|cutlass|machete|cleaver|\baxe\b|hatchet|\bmace\b|crowbar|wrench|bottle|chainsaw|crossbow|\bbow\b'
     r'|\barrow|dagger|nunchu|\bbat\b|baseball|boomerang|chappal|sandal|\bstaff\b|baton|shovel|\bsaw\b|needle|syringe|harpoon|trident'
     r'|\blance\b|halberd|sickle|kunai|shuriken|razor|scalpel|scissor|\bclub\b|cudgel|\bwhip\b', None),
    (r'blade', '?blade'), (r'\bgun\b|holster|gun[-_ ]?barrel', '?gun'),
]
WRX = [(re.compile(p, re.I), g) for p, g in WORDS]
MELEE = {k for k, d in W.items() if (d.get('kind') == 'Melee')}
def classify(text, kw=None):
    for rx, g in WRX:
        m = rx.search(text)
        if m:
            if g is None: return 'INK', m.group(0)
            if g in ('?gun', '?blade'):
                # a generic gun / blade drawing in a kill of that weapon: the kill's own game weapon drawn by hand
                if kw in GAME and ((g == '?blade') == (kw in MELEE)): return 'GAME:' + kw, m.group(0) + ' (the kill\'s ' + kw + ')'
                return '?', m.group(0)
            if g in REMOVED: return 'INK', m.group(0) + ' (retired: ' + g + ')'
            if g in GEAR: return 'GAME:' + g + ' (throwable)', m.group(0)
            return ('GAME:' + g) if g in GAME else 'INK', m.group(0)
    return None, None

# ---------------------------------------------------------------- the files
FILES = sorted(glob.glob(os.path.join(FB, '58k_*.js')))
SRC = {os.path.basename(f): open(f, encoding='utf-8', errors='replace').read().split('\n') for f in FILES}
DRAW = re.compile(r'\b(ctx|c|g|cx|x2|ct)\.(fill|stroke|moveTo|lineTo|arc|ellipse|fillRect|strokeRect|quadraticCurveTo|bezierCurveTo|rect|roundRect|drawImage)\b|beginPath|fillStyle|strokeStyle')
PATH = re.compile(r'drawWeaponAt|E40\.drawWeapon|E\.drawWeapon|drawHeld|K\.WD\(|\.wd\s*=|showWeapon\s*=\s*true')

# helpers: a definition line, its body = until the indentation returns (approx: up to 60 lines, stop at a line with the same indent
# that closes); kept when the body draws
DEF = re.compile(r'^(\s*)(?:const|let|var|function)\s+([A-Za-z_$][\w$]*)\s*(?:=\s*(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)|\()'
                 r'|^(\s*)([A-Za-z_$][\w$]*)\s*[:=]\s*(?:function\b|\([^)]*\)\s*=>)'
                 r'|^(\s*)(?:[A-Za-z_$][\w$.]*\.)([A-Za-z_$][\w$]*)\s*=\s*(?:function\b|\([^)]*\)\s*=>)')
def body_of(lines, i):
    m = DEF.match(lines[i]); ind = len(next(g for g in (m.group(1), m.group(3), m.group(5)) if g is not None)) if m else 0
    opens = 0; j = i; out = []
    while j < len(lines) and j < i + 120:
        L = lines[j]; out.append(L); opens += L.count('{') - L.count('}')
        if j > i and opens <= 0: break
        if j == i and opens <= 0 and (L.rstrip().endswith(';') or L.rstrip().endswith(',')): break
        j += 1
    return out
INKCALL = re.compile(r'\binkCast\s*\(')
HELP = {}   # name -> list of (file, line, class, word, draws_path)
for fn, lines in SRC.items():
    for i, L in enumerate(lines):
        m = DEF.match(L)
        if not m: continue
        name = m.group(2) or m.group(4) or m.group(6)
        if not name or len(name) < 3: continue
        body = body_of(lines, i); txt = '\n'.join(body)
        if not DRAW.search(txt): continue
        nm_words = re.sub(r'([a-z])([A-Z])', r'\1 \2', name)   # knifeAt -> knife At
        if not any(rx.search(nm_words) for rx, g in WRX): continue
        HELP.setdefault(name, []).append({'file': fn, 'line': i + 1, 'name': nm_words, 'path': bool(PATH.search(txt)), 'kit': bool(m.group(6)), 'ink': bool(INKCALL.search(txt))})

# ---------------------------------------------------------------- the kills
PB = json.load(open(os.path.join(FB, '_tools', 'coord', 'port_batches.json')))['kills']
IDRX = re.compile(r'''\b(id|key|skin)\s*:\s*['"]([a-z0-9][\w-]*)['"]''')
regions = collections.defaultdict(list)   # key -> (file, l0, l1)
for fn, lines in SRC.items():
    if not fn.startswith('58k_w_'): continue
    wfile = re.match(r'58k_w_([a-z0-9]+)', fn).group(1)
    wfile = {'sabre': 'speedsabre', 'portal': 'portalgun'}.get(wfile, wfile)
    hits = []
    for i, L in enumerate(lines):
        for m in IDRX.finditer(L):
            seg = L[m.end():m.end() + 200] + ' '.join(lines[i + 1:i + (5 if m.group(1) == 'skin' else 3)])
            if not ('name' in seg and ('rarity' in seg or 'arena' in seg or 'len' in seg or 'vic' in seg)): continue
            kid = m.group(2)
            if m.group(1) == 'skin': kid = 'skin-' + kid
            hits.append((i, kid)); break
        else:
            m2 = re.search(r'''\b[A-Za-z_$][\w$]*\(\s*['"]([a-z0-9][\w-]*)['"]\s*,\s*\{\s*name\s*:''', L)   # D('bayonet', { name: ...
            if m2: hits.append((i, m2.group(1)))
    for k, (i, kid) in enumerate(hits):
        j = hits[k + 1][0] if k + 1 < len(hits) else len(lines)
        # the weapon: an explicit w: near the id, else the file's own (KILL wrapper / file name)
        near = ' '.join(lines[max(0, i - 2):i + 2]); mw = re.search(r'''\bw\s*:\s*['"]([a-z0-9]+)['"]''', near)
        w = mw.group(1) if mw else wfile
        for ww in {w, wfile}:
            regions[ww + '.' + kid].append((fn, i, j))
            if not kid.startswith(('mid-', 'skin-')): regions[ww + '.mid-' + kid].append((fn, i, j))   # (a mid kill's tracker key: mid-<id>)

# LAST REGISTRATION WINS (QA-GATE, 31 Oct; the coordinator: golden.eureka resolved to the shadowed old EUREKA def): the live page loads
# K58.SHARED, then its K58.page({ files }) list (58k_page.js; files missing from the 58k_files.js manifest are skipped), and K58.kill's
# re-register replaces a key in place. So a key's region is the one in the LAST file its weapon's page loads (the last occurrence in that
# file); regions in files the page doesn't load, or loads earlier, are shadowed and not scanned. A key no page loads keeps every region.
def _page_orders():
    pg = open(os.path.join(FB, '58k_page.js'), encoding='utf-8', errors='replace').read()
    msh = re.search(r"K58\.SHARED\s*=\s*K58\.SHARED\s*\|\|\s*\[([^\]]*)\]", pg)
    shared = [f for f in re.findall(r"'([^']+\.js)'", msh.group(1)) if f != '58k_maps.js'] if msh else []
    man = None
    try:
        mt = open(os.path.join(FB, '58k_files.js'), encoding='utf-8', errors='replace').read(); man = set(re.findall(r'"(58k_[^"]+\.js)"', mt))
    except Exception: man = None
    orders = {}
    for h in sorted(glob.glob(os.path.join(FB, '58k_*.html'))):
        t = open(h, encoding='utf-8', errors='replace').read(); m = re.search(r"K58\.page\(\{(.*?)\n\}\);", t, re.S)
        if not m: continue
        mw = re.search(r"\bweapon\s*:\s*'([a-z0-9]+)'", m.group(1)); mf = re.search(r"\bfiles\s*:\s*\[([^\]]*)\]", m.group(1))
        if not mw or not mf: continue
        seq = []
        for f in shared + re.findall(r"'([^']+\.js)'", mf.group(1)):
            if f in seq or (man is not None and f not in man): continue
            seq.append(f)
        orders.setdefault(mw.group(1), seq)
    return orders
ORDERS = _page_orders()
# the GROUND TRUTH when present: _tools/coord/kill_src58.json (node _tools/kill_src58.cjs: each live page's def.src58 = the file that ran the
# last registration; conditional registrations (X2TEST / APPROVED / X7ON / has()) only count when they ran). Static load order is the fallback.
SRC58 = {}
try: SRC58 = json.load(open(os.path.join(FB, '_tools', 'coord', 'kill_src58.json'))).get('keys', {})
except Exception: SRC58 = {}
SHADOWED = {}   # key -> the shadowed regions (file:line), for the report
LIVE_UNSEEN = {}   # key -> the live file whose registration the static scan did not find (not scanned: check by hand)
for key in list(regions):
    R = regions[key]
    lf = SRC58.get(key)
    if lf:
        inl = [r for r in R if r[0] == lf]
        if inl:
            last = max(inl, key=lambda r: r[1])
        else:
            LIVE_UNSEEN[key] = lf; SHADOWED[key] = ['%s:%d' % (r[0], r[1] + 1) for r in R]; regions[key] = []; continue
    else:
        if len(R) < 2: continue
        seq = ORDERS.get(key.split('.')[0])
        if not seq: continue
        live = [r for r in R if r[0] in seq]
        if not live: continue
        last = max(live, key=lambda r: (seq.index(r[0]), r[1]))
    drop = [r for r in R if r is not last]
    if drop: SHADOWED[key] = ['%s:%d' % (r[0], r[1] + 1) for r in drop]
    regions[key] = [last]
for key in [k for k, v in regions.items() if not v]: del regions[key]

# the shared hand props (58k_arrive.js K58.HANDPROPS): their names as drawn props
HPN = set(re.findall(r'\bHP\.([a-z]+)\s*=\s*\(', '\n'.join(SRC.get('58k_arrive.js', []))))
_AR = '\n'.join(SRC.get('58k_arrive.js', []))
HP_GAME = set(re.findall(r"HP\.([a-z]+)\s*=\s*gameProp\(", _AR))          # hand props that draw the game's own weapon (on the path)
HP_INK = set(re.findall(r"inkProp\('([a-z]+)'", _AR))                        # hand props drawn ink-cast (K.inkCast)
HPCALL = re.compile(r'(drawHandProp|\.hand\(|pullOut|PullOut|\bprop\s*:|handProp)')
CALL = re.compile(r'(?:\b([A-Za-z_$][\w$]*)\.)?\b([A-Za-z_$][\w$]*)\s*\(')
rows, missing, clean = [], [], []
for key, kv in PB.items():
    if key not in regions: missing.append(key); continue
    seen = set(); found = []
    for fn, l0, l1 in regions[key]:
        lines = SRC[fn]
        for i in range(l0, l1):
            L = lines[i]
            if L.strip().startswith('//'): continue
            # helper calls
            kw = PB[key].get('weapon')
            for m in CALL.finditer(L):
                pre, nm = m.group(1), m.group(2)
                for h in HELP.get(nm, []):
                    # a bare call resolves in its own file; a dotted one (KC.knifeAt) to a kit / object helper of that name
                    if h['path'] or (pre is None and h['file'] != fn) or (pre is not None and (not h['kit'] or not pre[:1].isupper())): continue   # (A.blade / V.at: body methods, never painters; a kit call KC.x resolves to a member helper)
                    if pre in ('ctx', 'c', 'g', 'Math', 'console', 'K', 'JSON', 'Object', 'Array'): continue
                    sig = (nm, h['file'], h['line'])
                    if sig in seen: continue
                    cls, word = classify(h['name'], kw)
                    if not cls: continue
                    if INKCALL.search(L) or h.get('ink'): cls = 'INK-CAST'   # (wrapped: drawn through K.inkCast)
                    seen.add(sig); found.append({'at': '%s:%d' % (fn, i + 1), 'what': 'calls %s%s (%s:%d)' % (pre + '.' if pre else '', nm, h['file'], h['line']), 'cls': cls, 'word': word})
            # a shared hand prop by name (K58.HANDPROPS: knife / cleaver / machete / scissors / grenade ...): its own drawing, never the game's
            if HPCALL.search(L):
                for nm in re.findall(r'''['"]([a-z]+)['"]''', L):
                    if nm not in HPN or nm in HP_GAME: continue
                    cls, word = classify(nm, PB[key].get('weapon'))
                    if not cls: continue
                    if nm in HP_INK: cls = 'INK-CAST'
                    sig = ('hp', nm, fn, i)
                    if sig not in seen: seen.add(sig); found.append({'at': '%s:%d' % (fn, i + 1), 'what': "hand prop '%s' (58k_arrive.js HANDPROPS)" % nm, 'cls': cls, 'word': word})
            # inline drawing with a weapon word on the line or the one before (a comment naming what is drawn)
            if DRAW.search(L) and not PATH.search(L):
                ctxt = ' '.join(re.findall(r'//[^\n]*|/\*.*?\*/|\'[^\']*\'|"[^"]*"', (lines[i - 1] if i > 0 else '') + '\n' + L))
                cls, word = classify(ctxt, PB[key].get('weapon'))
                if cls and INKCALL.search(' '.join(lines[max(0, i - 3):i + 1])): cls = 'INK-CAST'
                if cls and cls != '?' or (cls == '?' and re.search(r'muzzle|barrel|trigger|\bgun\b', L, re.I)):
                    sig = ('inline', fn, i)
                    if sig not in seen:
                        seen.add(sig); found.append({'at': '%s:%d' % (fn, i + 1), 'what': 'inline: ' + L.strip()[:110], 'cls': cls, 'word': word})
    if not found: clean.append(key); continue
    for f in found: rows.append(dict(key=key, batch=PB[key].get('batch'), weapon=PB[key].get('weapon'), name=PB[key].get('name'), status=PB[key].get('status'), **f))

rows.sort(key=lambda r: (r['batch'] if isinstance(r['batch'], int) else 99, r['weapon'] or '', r['key'], r['at']))
cnt = collections.Counter(r['cls'].split(' ')[0] if r['cls'] else '-' for r in rows)
kills_with = len({r['key'] for r in rows})
with open(OUT, 'w') as o:
    o.write('# WEAPON AUDIT (STYLE-PORT, generated by `python3 _tools/weapon_audit.py`; the rule: _tools/coord/WEAPON_RULE.md)\n\n')
    o.write('Every weapon-like drawing in a live kill that does NOT go through the shared game-weapon path (K.drawWeaponAt / E40.drawWeapon / the body\'s held weapon), from a static scan of each kill\'s region in its lane file and the helpers it calls.\n\n')
    o.write('- **GAME:<id>**: a weapon the game has. Replace the custom drawing with the real one: hold it as the body\'s weapon (`b.wd = K.WD(id)`, drawn by drawHeld) or draw it with `K.drawWeaponAt(ctx, id, skin, x, y, ang, K.WS * b.k, t)`; a dropped one as a flat58 fx that draws drawWeaponAt.\n')
    o.write('- **INK**: not a game weapon (a custom blade / club / prop, or a weapon the game retired: %s). Draw it ink-cast (CASTINK58, THE OFFERED HAND\'s ink swords).\n' % ', '.join(sorted(REMOVED)))
    o.write('- **INK-CAST**: already drawn through K.inkCast (the WEAPON check passes it).\n')
    o.write('- **?**: gun-ish words near drawing code; look before changing (often a muzzle flash or an aim line, which is fine).\n\n')
    o.write('Kills scanned: %d of %d on the tracker (%d with no region found in the lane files, listed at the end). Kills with findings: %d. Clean: %d.\n\n' % (len(PB) - len(missing), len(PB), len(missing), kills_with, len(clean)))
    o.write('Findings by class: %s.\n\n' % ', '.join('%s %d' % (k, v) for k, v in sorted(cnt.items())))
    o.write('Helpers that draw weapon-like things off the path (the fixers convert the helper once, every caller follows):\n\n| helper | defined at | class | word |\n|---|---|---|---|\n')
    for nm in sorted(HELP):
        for h in HELP[nm]:
            if not h['path']: c2, w2 = classify(h['name']); o.write('| %s | %s:%d | %s | %s |\n' % (nm, h['file'], h['line'], c2 or '', w2 or ''))
    cur = None
    for r in rows:
        b = r['batch']
        if b != cur:
            cur = b; o.write('\n## Batch %s\n\n| weapon | key | name | status | at | drawing | class |\n|---|---|---|---|---|---|---|\n' % b)
        o.write('| %s | %s | %s | %s | %s | %s | %s |\n' % (r['weapon'], r['key'], r['name'], r['status'], r['at'], r['what'].replace('|', '\\|'), r['cls'] or ''))
    o.write('\n## No region found (check by hand)\n\n' + ', '.join(sorted(missing)) + '\n')
    o.write('\n## Live def not found by the static scan (the live page registers it from this file; check by hand)\n\n' + '\n'.join('- %s: %s' % (k, v) for k, v in sorted(LIVE_UNSEEN.items()) if k in PB) + '\n')
    o.write('\n## Shadowed defs (not scanned: the live page registers the key from another file / later; last registration wins, per kill_src58.json)\n\n' + '\n'.join('- %s: %s' % (k, ', '.join(v)) for k, v in sorted(SHADOWED.items()) if k in PB) + '\n')
json.dump({'rows': rows, 'missing': missing, 'clean': clean, 'helpers': HELP, 'shadowed': {k: v for k, v in SHADOWED.items() if k in PB}, 'live_unseen': {k: v for k, v in LIVE_UNSEEN.items() if k in PB}, 'src58': bool(SRC58)}, open(os.path.splitext(OUT)[0].replace('WEAPON_AUDIT', 'weapon_audit') + '.json', 'w'), indent=1)
print('kills %d, regions found %d, missing %d, with findings %d, clean %d, rows %d, by class %s' % (len(PB), len(PB) - len(missing), len(missing), kills_with, len(clean), len(rows), dict(cnt)))
