#!/usr/bin/env python3
"""
SPIKE — ¿conviven barrido y marchas en un solo cliente de Evony?

Inyecta agent_spike.js, ejecuta el protocolo de 3 fases y vuelca métricas a JSONL.
Sin web, sin login, sin puerto: la salida es un fichero de datos y un resumen.

Uso:
  ./spike.py --probe                 # calibrar: qué tipo son los monstruos, qué tropas hay
  ./spike.py --run                   # protocolo completo (warmup + A + B + C)
  ./spike.py --run --phase-secs 900  # fases más cortas
"""
import argparse, json, os, sys, time
from collections import defaultdict

import frida

HERE = os.path.dirname(os.path.abspath(__file__))
AGENT_JS = os.path.join(HERE, "agent_spike.js")
DEVICE = "emulator-6008"
PACKAGE = "com.topgamesinc.evony"

# Buckets del histograma de gaps del main thread (deben coincidir con el agente)
GAP_BUCKETS = [20, 50, 100, 250, 500, 1000, 2000, 5000]
BUCKET_LABELS = ["<20ms", "20-50", "50-100", "100-250", "250-500", "500ms-1s", "1-2s", "2-5s", ">5s"]


class Spike:
    def __init__(self, out_path):
        self.out = open(out_path, "a", buffering=1)
        self.script = None
        self.snapshots = []          # todos los "metrics"
        self.by_phase = defaultdict(list)
        self.last = None

    # ---------- plumbing ----------
    def on_message(self, message, data):
        if message.get("type") == "error":
            print("!! ERROR agente:", message.get("description"), file=sys.stderr)
            return
        p = message.get("payload")
        if not isinstance(p, dict):
            return
        kind = p.get("kind")
        if kind == "metrics":
            self.last = p
            self.snapshots.append(p)
            self.by_phase[p.get("phase", "?")].append(p)
            self.out.write(json.dumps(p) + "\n")
        else:
            print("<<", json.dumps(p)[:400])
            self.out.write(json.dumps(p) + "\n")

    def attach(self):
        dev = frida.get_device(DEVICE, timeout=10)
        # frida publica el proceso con el NOMBRE DE LA APP ("Evony"), no con el del
        # paquete: la vía fiable es la lista de aplicaciones, que sí lleva identifier.
        pid = None
        try:
            for app in dev.enumerate_applications():
                if app.identifier == PACKAGE and app.pid:
                    pid = app.pid
                    break
        except Exception:
            pass
        if pid is None:
            for pr in dev.enumerate_processes():
                if pr.name in (PACKAGE, "Evony"):
                    pid = pr.pid
                    break
        if pid is None:
            print(f"!! {PACKAGE} no está corriendo en {DEVICE}. Arráncalo primero.", file=sys.stderr)
            sys.exit(1)
        print(f"[spike] attach a {PACKAGE} pid={pid} en {DEVICE}")
        session = dev.attach(pid)
        with open(AGENT_JS) as f:
            src = f.read()
        self.script = session.create_script(src)
        self.script.on("message", self.on_message)
        self.script.load()
        print(f"[spike] agente cargado ({len(src)} bytes)")

    def post(self, **kw):
        self.script.post({"type": "ctl", **kw})

    # ---------- protocolo ----------
    def phase(self, name, secs):
        print(f"\n=== FASE {name} — {secs}s ===")
        self.post(cmd="phase", phase=name)
        t0 = time.time()
        while time.time() - t0 < secs:
            time.sleep(10)
            if self.last:
                m, s, f = self.last["mt"], self.last["sweep"], self.last["fire"]
                el = int(time.time() - t0)
                print(f"  [{el:5d}s] sweep sent={s['sent']:5d} rep={s['replies']:5d} objs={s['objs']:6d} "
                      f"| mt ticks={m['ticks']:6d} max={m['max']:5d}ms freezes={m['freezes']:2d} "
                      f"| fire ok={f['sent_ok']}/{f['tries']} conf={f['confirmed']} | targets={self.last['targets']}")

    # ---------- análisis ----------
    def summarize(self):
        print("\n" + "=" * 78)
        print("RESUMEN DEL SPIKE")
        print("=" * 78)
        rows = {}
        for ph in ("A", "B", "C"):
            snaps = self.by_phase.get(ph, [])
            if not snaps:
                continue
            last = snaps[-1]
            s, m, f = last["sweep"], last["mt"], last["fire"]
            dur = max(1, (last["t"] - snaps[0]["t"]) / 1000.0)
            rows[ph] = {
                "req/s": s["sent"] / dur,
                "ratio_reply": (s["replies"] / s["sent"]) if s["sent"] else 0.0,
                "objs": s["objs"],
                "fps_aprox": m["ticks"] / dur,
                "gap_avg": m["avg"],
                "gap_max": m["max"],
                "freezes_h": m["freezes"] * 3600.0 / dur,
                "fire_ok": f["sent_ok"],
                "fire_conf": f["confirmed"],
                "fire_unconf": f["unconfirmed"],
                "lat_avg": (f["lat_sum"] / f["lat_n"]) if f["lat_n"] else 0,
                "lat_max": f["lat_max"],
                "hist": m["hist"],
                "dur": dur,
            }

        if not rows:
            print("(sin datos)")
            return

        def line(label, key, fmt="{:.2f}"):
            vals = "  ".join(f"{fmt.format(rows[p][key]):>12}" if p in rows else f"{'—':>12}" for p in ("A", "B", "C"))
            print(f"{label:<26}{vals}")

        print(f"{'':<26}{'A (barrido)':>12}  {'B (marchas)':>12}  {'C (ambas)':>12}")
        print("-" * 78)
        line("duración (s)", "dur", "{:.0f}")
        print("--- barrido " + "-" * 62)
        line("requests/s", "req/s")
        line("ratio replies/requests", "ratio_reply")
        line("objetos vistos", "objs", "{:.0f}")
        print("--- salud del cliente " + "-" * 52)
        line("frames/s aprox", "fps_aprox")
        line("gap medio (ms)", "gap_avg", "{:.0f}")
        line("gap máximo (ms)", "gap_max", "{:.0f}")
        line("congelaciones >2s /hora", "freezes_h")
        print("--- marchas " + "-" * 62)
        line("enviadas", "fire_ok", "{:.0f}")
        line("CONFIRMADAS", "fire_conf", "{:.0f}")
        line("no confirmadas", "fire_unconf", "{:.0f}")
        line("latencia envío media (ms)", "lat_avg", "{:.0f}")
        line("latencia envío máx (ms)", "lat_max", "{:.0f}")

        print("\nDistribución de gaps del main thread (% de frames):")
        print(f"{'':<12}" + "".join(f"{l:>10}" for l in BUCKET_LABELS))
        for ph in ("A", "B", "C"):
            if ph not in rows:
                continue
            h = rows[ph]["hist"]; tot = max(1, sum(h))
            print(f"{ph:<12}" + "".join(f"{100.0*v/tot:>9.2f}%" for v in h))

        # Veredicto
        print("\n" + "=" * 78)
        if "C" not in rows or ("A" not in rows and "B" not in rows):
            print("VEREDICTO: faltan fases para comparar.")
            return
        c = rows["C"]
        problems = []
        if "A" in rows:
            a = rows["A"]
            if a["req/s"] and c["req/s"] < a["req/s"] * 0.85:
                problems.append(f"el barrido cae {100*(1-c['req/s']/a['req/s']):.0f}% en C ({c['req/s']:.2f} vs {a['req/s']:.2f} req/s)")
            if a["ratio_reply"] and c["ratio_reply"] < a["ratio_reply"] * 0.9:
                problems.append(f"el servidor responde menos en C (ratio {c['ratio_reply']:.2f} vs {a['ratio_reply']:.2f})")
            if c["freezes_h"] > max(1.0, a["freezes_h"] * 1.5):
                problems.append(f"más congelaciones en C ({c['freezes_h']:.1f}/h vs {a['freezes_h']:.1f}/h)")
            if a["fps_aprox"] and c["fps_aprox"] < a["fps_aprox"] * 0.85:
                problems.append(f"el cliente pierde frames en C ({c['fps_aprox']:.1f} vs {a['fps_aprox']:.1f} fps)")
        if "B" in rows:
            b = rows["B"]
            rb = b["fire_conf"] / b["fire_ok"] if b["fire_ok"] else 0
            rc = c["fire_conf"] / c["fire_ok"] if c["fire_ok"] else 0
            if b["fire_ok"] and rc < rb * 0.9:
                problems.append(f"se confirman menos marchas en C ({100*rc:.0f}% vs {100*rb:.0f}%)")
            if b["lat_avg"] and c["lat_avg"] > b["lat_avg"] * 1.5:
                problems.append(f"el envío de marcha tarda más en C ({c['lat_avg']:.0f}ms vs {b['lat_avg']:.0f}ms)")

        if problems:
            print("VEREDICTO: SE PISAN. Hace falta coordinación scan<->act.")
            for p in problems:
                print("  · " + p)
        else:
            print("VEREDICTO: CONVIVEN. No se detecta degradación significativa en C.")
            print("  => la app puede barrer y actuar sin turnos; arquitectura simple.")
        print("=" * 78)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--probe", action="store_true", help="calibrar: tipos de objeto, targets y generales")
    ap.add_argument("--run", action="store_true", help="ejecutar el protocolo completo")
    ap.add_argument("--warmup-secs", type=int, default=180, help="barrido previo para llenar el pool de targets")
    ap.add_argument("--phase-secs", type=int, default=1800, help="duración de cada fase")
    ap.add_argument("--monster-t", type=int, default=2, help="tipo de mapinfo que son monstruos")
    ap.add_argument("--max-lv", type=int, default=3, help="nivel máximo del objetivo")
    ap.add_argument("--fire-every", type=int, default=45000, help="ms entre marchas")
    ap.add_argument("--troops", default="", help='tropas por marcha, ej "21:1000"')
    ap.add_argument("--out", default=os.path.join(HERE, "spike_metrics.jsonl"))
    args = ap.parse_args()

    if not (args.probe or args.run):
        ap.error("elige --probe o --run")
    if not os.path.exists(AGENT_JS):
        print(f"!! falta {AGENT_JS} — compila primero con ./build_spike.sh", file=sys.stderr)
        sys.exit(1)

    sp = Spike(args.out)
    sp.attach()

    troops = None
    if args.troops:
        troops = []
        for part in args.troops.split(","):
            t, n = part.split(":")
            troops.append({"t": int(t), "n": int(n)})

    cfg = {"cmd": "cfg", "monster_t": args.monster_t, "max_lv": args.max_lv, "fire_every_ms": args.fire_every}
    if troops:
        cfg["troops"] = troops
    time.sleep(3)
    sp.post(**cfg)

    if args.probe:
        print("[spike] PROBE: barriendo 90s para ver qué hay en el mapa...")
        sp.post(cmd="phase", phase="warmup")
        time.sleep(90)
        sp.post(cmd="targets")
        sp.post(cmd="gen_states")
        time.sleep(3)
        if sp.last:
            print("\nHistograma de tipos de objeto del mapa (type -> nº visto):")
            for t, n in sorted(sp.last.get("types", {}).items(), key=lambda kv: -kv[1]):
                print(f"   type={t:>4}  {n}")
            print(f"\ntargets en pool (t={args.monster_t}, lv<={args.max_lv}): {sp.last.get('targets')}")
            print(f"server detectado: {sp.last.get('server')}")
        sp.post(cmd="phase", phase="idle")
        print("\n[spike] PROBE terminado. Ajusta --monster-t/--max-lv y lanza --run.")
        return

    try:
        print(f"[spike] WARMUP {args.warmup_secs}s (llenando el pool de objetivos)")
        sp.phase("warmup", args.warmup_secs)
        sp.phase("A", args.phase_secs)
        sp.phase("B", args.phase_secs)
        sp.phase("C", args.phase_secs)
        sp.post(cmd="phase", phase="idle")
    except KeyboardInterrupt:
        print("\n[spike] interrumpido — resumo lo que haya")
    finally:
        sp.summarize()
        print(f"\n[spike] métricas en {args.out}")


if __name__ == "__main__":
    main()
