#!/usr/bin/env python3
"""Mission state from guest RAM: every entity's hull, not just the player's.

`own_state.py` found the player's hull by anchoring on a solved definition
field: an undamaged craft carries its definition's own `HP` (+0x054), so the
live counter is the copy of that number that *falls*. The result was
`hull = position + 0x154`.

The escort question needs the same number for **someone else's** ship. Stage 02
is lost when the ACROPOLIS sinks, not when the player dies, and the 240 s run in
autopilot-memory-driven.md hit GAME OVER with our own hull untouched. So the
claim to test here is that `+0x154` is a property of the *entity class*, not of
the player object: every entity, hostile or friendly, fighter or capital ship,
should hold its own definition's `HP` there at spawn and lose it when hit.

That is falsifiable in one run: read `pos+0x154` and the definition `HP` for
every entity in the scene and compare. If the anchor is class-wide, the ratio
is 1.0 for everything undamaged and nothing else lines up by accident; if it is
player-specific, most entities hold something unrelated.

Sub-commands:
  scan [cfg]                    one table of every entity: hull vs definition HP
  watch <cfg> <secs> [hz] [out] sample over time; report what took damage
"""
import json
import math
import os
import struct
import sys
import time

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gmem  # noqa: E402
import navigator  # noqa: E402

HULL_OFF = 0x154        # own_state.py, player craft
DEF_HP = 0x054          # unit-struct-runtime.md, ✅ CONFIRMED
DEF_SIZE_R = 0x50


def f32(fd, off):
    b = os.pread(fd, 4, off)
    if len(b) < 4:
        return float("nan")
    (v,) = struct.unpack(">f", b)
    return v


class Mission:
    def __init__(self, cfg):
        self.W = navigator.World(cfg)
        self.def_hp = {}
        for va in self.W.defs:
            self.def_hp[va] = f32(self.W.fd, gmem.va_to_off(va) + DEF_HP)

    def snapshot(self):
        """[(off, name, faction, radius, pos, hull, hp_max)] for every entity."""
        out = []
        for off, va in self.W.ents:
            p = self.W.pos(off)
            if p is None:
                continue
            hull = f32(self.W.fd, off + HULL_OFF)
            nm = self.W.defs[va]
            out.append((off, nm, navigator.faction(nm), self.W.radius[va],
                        p, hull, self.def_hp.get(va, float("nan"))))
        return out


def fmt(e):
    off, nm, fac, r, p, hull, hp0 = e
    frac = hull / hp0 if hp0 and math.isfinite(hp0) and hp0 > 0 else float("nan")
    return (f"{gmem.primary_va(off):#010x} {nm[:34]:<34} {fac:<4} r={r:6.0f} "
            f"({p[0]:+8.0f},{p[1]:+8.0f},{p[2]:+8.0f}) hull={hull:9.1f} "
            f"HP={hp0:9.1f} frac={frac:6.3f}")


def cmd_scan(cfg):
    m = Mission(cfg)
    m.W.scan()
    ents = m.snapshot()
    print(f"# {len(ents)} entities")
    ok = sum(1 for e in ents
             if math.isfinite(e[6]) and e[6] > 0 and abs(e[5] / e[6] - 1.0) < 1e-3)
    fin = sum(1 for e in ents if math.isfinite(e[6]) and e[6] > 0)
    print(f"# hull(+{HULL_OFF:#x}) == definition HP for {ok}/{fin} entities "
          f"whose definition has an HP")
    for e in sorted(ents, key=lambda e: -e[3]):
        print("  " + fmt(e))


def cmd_watch(cfg, secs, hz, out):
    m = Mission(cfg)
    m.W.scan()
    base = {e[0]: e for e in m.snapshot()}
    print(f"# watching {len(base)} entities for {secs:g}s at {hz:g}Hz", flush=True)
    fh = open(out, "w") if out else None
    t0 = time.time()
    last_scan = t0
    last_seen = {}
    while time.time() - t0 < secs:
        t = time.time()
        if t - last_scan > 15.0:        # new waves spawn; re-enumerate rarely
            m.W.scan()
            last_scan = t
            for e in m.snapshot():
                base.setdefault(e[0], e)
        ents = m.snapshot()
        rec = {"t": round(t - t0, 2),
               "n": len(ents),
               "adan": sum(1 for e in ents if e[2] == "ADAN"),
               "tcaf": sum(1 for e in ents if e[2] == "TCAF"),
               "ents": []}
        for e in ents:
            off, nm, fac, r, p, hull, hp0 = e
            b = base.get(off)
            drop = (b[5] - hull) if b and math.isfinite(b[5]) else 0.0
            big = r >= navigator.Navigator.BIG_RADIUS
            if big or drop > 0.5:
                rec["ents"].append({"va": gmem.primary_va(off), "nm": nm,
                                    "fac": fac, "r": round(r, 1),
                                    "hull": round(hull, 1), "hp0": round(hp0, 1),
                                    "drop": round(drop, 1),
                                    "pos": [round(float(c), 1) for c in p]})
            if drop > 0.5 and abs(last_seen.get(off, 0.0) - hull) > 0.5:
                last_seen[off] = hull
                print(f"[{t-t0:6.1f}] HIT {nm[:30]:<30} {fac} "
                      f"hull {hull:9.1f}/{hp0:9.1f} (-{drop:.0f})", flush=True)
        if fh:
            fh.write(json.dumps(rec) + "\n")
            fh.flush()
        time.sleep(max(0.0, 1.0 / hz - (time.time() - t)))
    if fh:
        fh.close()

    print("\n# damage summary")
    ents = {e[0]: e for e in m.snapshot()}
    for off, b in sorted(base.items(), key=lambda kv: -kv[1][3]):
        cur = ents.get(off)
        gone = cur is None
        hull = cur[5] if cur else float("nan")
        if not gone and abs(hull - b[5]) < 0.5 and b[3] < navigator.Navigator.BIG_RADIUS:
            continue
        print(f"  {b[1][:34]:<34} {b[2]:<4} r={b[3]:6.0f} "
              f"{b[5]:9.1f} -> {hull:9.1f}" + ("   GONE" if gone else ""))


def main():
    if len(sys.argv) < 3:
        sys.exit(__doc__)
    cmd = sys.argv[1]
    cfg = json.load(open(sys.argv[2]))
    if cmd == "scan":
        cmd_scan(cfg)
    elif cmd == "watch":
        cmd_watch(cfg,
                  float(sys.argv[3]) if len(sys.argv) > 3 else 120.0,
                  float(sys.argv[4]) if len(sys.argv) > 4 else 1.0,
                  sys.argv[5] if len(sys.argv) > 5 else None)
    else:
        sys.exit(__doc__)


if __name__ == "__main__":
    main()
