"""plog_analyze.py — correlate bot ACTIONS to the encrypted SENT frame each one triggers, by TIMING.

The packet logs (packet_logs/<ts>_<mode>.txt) interleave every WS frame (SENT/RECV, full hex) with the
bot's own ACTIONs. Bodies are XOR-encrypted, but a frame's LENGTH + direction + timing are in the clear.
So to map an action to its opcode we don't decrypt — we find which SENT-frame LENGTH consistently shows
up in the short window right after that action, ABOVE the constant background (position-update spam).

Method (per action type A, window W):
  observed[A][L] = fraction of A-occurrences with >=1 SENT of length L within W after it
  baseline[L]    = fraction of RANDOM timepoints with the same  (the background rate of length L)
  enrichment     = observed / baseline   -> the action-specific length has HIGH enrichment + low baseline
Also reports the length of the FIRST SENT after each action (usually the cleanest single signal).

Usage:
    python plog_analyze.py [logfile ...]        # defaults to newest *_local.txt + newest *_healbuff.txt
    python plog_analyze.py --window 0.15 <file>
"""
import sys
import os
import re
import glob
import bisect
import random
from collections import defaultdict

random.seed(12345)                      # deterministic baseline sampling
LINE = re.compile(r'^\d[\d:.]*\s+\+\s*([\-\d.]+)\s+(SENT|RECV|ACTION)\s+(.*)$')


def parse(path):
    """-> list of (rel_s, kind, length_or_None, detail), time-ordered."""
    ev = []
    with open(path, encoding='utf-8', errors='replace') as f:
        for line in f:
            m = LINE.match(line)
            if not m:
                continue
            rel = float(m.group(1)); kind = m.group(2); detail = m.group(3).rstrip()
            length = None
            if kind != 'ACTION':
                lm = re.search(r'len=(\d+)', detail)
                if lm:
                    length = int(lm.group(1))
            ev.append((rel, kind, length, detail))
    return ev


def action_key(detail):
    """Normalize an ACTION detail to a TYPE: drop coords/ids/keys/numbers."""
    d = re.sub(r'\(.*', '', detail)               # drop "(x,y)" / "(HP 90%)"
    d = re.sub(r"'[^']*'", "'K'", d)              # 'Digit2' -> 'K'
    d = re.sub(r'\d+', '', d)                     # drop stray numbers
    return d.strip()


def lengths_in_window(sent_t, sent_l, t0, w):
    """Set of SENT lengths in (t0, t0+w]; and the length of the FIRST one (or None)."""
    i = bisect.bisect_right(sent_t, t0)
    out = set(); first = None
    while i < len(sent_t) and sent_t[i] <= t0 + w:
        if first is None:
            first = sent_l[i]
        out.add(sent_l[i])
        i += 1
    return out, first


def analyze(path, window):
    ev = parse(path)
    if not ev:
        print(f"  (no parsable lines in {os.path.basename(path)})"); return
    sents = sorted((t, l) for (t, k, l, d) in ev if k == 'SENT' and l is not None)
    sent_t = [t for t, l in sents]; sent_l = [l for t, l in sents]
    if not sent_t:
        print("  (no SENT frames)"); return
    t_lo, t_hi = ev[0][0], ev[-1][0]
    span = max(1e-6, t_hi - t_lo)

    # background: probability a RANDOM window contains length L (and first-after distribution)
    NB = 4000
    base_has = defaultdict(int); base_first = defaultdict(int)
    for _ in range(NB):
        t0 = random.uniform(t_lo, max(t_lo, t_hi - window))
        s, first = lengths_in_window(sent_t, sent_l, t0, window)
        for L in s:
            base_has[L] += 1
        if first is not None:
            base_first[first] += 1
    base_has = {L: c / NB for L, c in base_has.items()}

    # actions
    acts = defaultdict(list)
    for (t, k, l, d) in ev:
        if k == 'ACTION':
            ak = action_key(d)
            if ak.startswith('===') or ak.startswith('#'):
                continue
            acts[ak].append(t)

    sent_rate = len(sents) / span
    print(f"\n=== {os.path.basename(path)} ===")
    print(f"  span {span:.0f}s | {len(sents)} SENT ({sent_rate:.1f}/s) | window {int(window*1000)}ms")
    base_top = sorted(base_has.items(), key=lambda kv: -kv[1])[:6]
    print(f"  background SENT lengths (P in window): " +
          "  ".join(f"{L}:{p*100:.0f}%" for L, p in base_top))

    for ak in sorted(acts, key=lambda a: -len(acts[a])):
        times = acts[ak]; N = len(times)
        has = defaultdict(int); firstc = defaultdict(int); delays = []
        for t in times:
            s, first = lengths_in_window(sent_t, sent_l, t, window)
            for L in s:
                has[L] += 1
            if first is not None:
                firstc[first] += 1
                i = bisect.bisect_right(sent_t, t)
                if i < len(sent_t):
                    delays.append(sent_t[i] - t)
        # enrichment vs baseline
        rows = []
        for L, c in has.items():
            obs = c / N
            b = base_has.get(L, 0.5 / NB)               # tiny floor so novel lengths rank high
            rows.append((obs / b, L, obs, b, firstc.get(L, 0)))
        rows.sort(reverse=True)
        med_delay = sorted(delays)[len(delays)//2]*1000 if delays else float('nan')
        print(f"\n  ACTION  {ak!r}   (N={N}, median 1st-SENT delay {med_delay:.0f}ms)")
        print(f"    {'len':>5} {'enrich':>7} {'after':>7} {'base':>6} {'is-1st':>7}")
        for enr, L, obs, b, fc in rows[:6]:
            flag = "  <-- likely this action's frame" if (enr >= 2.5 and obs >= 0.4) else ""
            print(f"    {L:>5} {enr:>6.1f}x {obs*100:>6.0f}% {b*100:>5.0f}% {fc/N*100:>6.0f}%{flag}")


def main():
    args = sys.argv[1:]
    window = 0.20
    files = []
    i = 0
    while i < len(args):
        if args[i] == '--window':
            window = float(args[i+1]); i += 2
        else:
            files.append(args[i]); i += 1
    if not files:
        d = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'packet_logs')
        for mode in ('local', 'healbuff'):
            cands = sorted(glob.glob(os.path.join(d, f'*_{mode}.txt')), key=os.path.getsize)
            if cands:
                files.append(cands[-1])               # the LARGEST (most data) of that mode
    if not files:
        print("no logs found"); return
    print(f"window={int(window*1000)}ms   files: {', '.join(os.path.basename(f) for f in files)}")
    for f in files:
        analyze(f, window)


if __name__ == '__main__':
    main()
