"""
grind_local_2.py — HYBRID grinder: SENSE mobs from WASM memory (the entity registry,
reliable ids + world positions), ACT via real clicks (the game's own engage handler).
Leaves grind_local.py untouched. Driven by the "Grind local 2" panel button.

Why hybrid: reading state from memory is rock-solid (no vision misdetection); but sending
crafted packets does NOT drive the character (the action needs the game's full flow). So we
keep clicking (trusted input → the game's real engage), and use memory only to know exactly
where the mobs are. The bridge is a world->screen projection. PRIMARY method: you click ~6
mobs to give exact (world XYZ -> screen) pairs, then we SCAN WASM memory for the live
view-projection MATRIX that reproduces all of them, and read it LIVE each frame (accurate
everywhere, survives camera motion). FALLBACK: a player-relative homography (approximate).

Reuses grind_local for: grab, viewport, calibrate_view, find_nameplate_mobs, arrows_center,
hp_fraction, FOOD_KEY/HEAL_AT, engage helpers.
"""
import time, math, struct, importlib
import numpy as np
import cv2
import grind_local as gl

PLAYER_PTR = 1230588
REG_MAP, REG_CAP = 362748, 362752
POS_OFF = 72                 # entity world position f32 @ +72/+76/+80 (x,y,z)
INLIER_PX = 28              # projection match tolerance during calibration
CYCLE = 0.4

# ---- read registry mobs + player from memory ----
READ_JS = """()=>{
  const dv=new DataView(HEAPU8.buffer); const ok=p=>p>0x10000&&p<0x10000000-512;
  const f=(a,o)=>dv.getFloat32(a+o,true);
  const ctrl=dv.getUint32(1230588,true);
  const player={x:f(ctrl,72),y:f(ctrl,76),z:f(ctrl,80)};
  const map=dv.getUint32(362748,true), cap=dv.getUint32(362752,true);
  const mobs=[]; const seen={};
  for(let b=0;b<cap;b++){ let node=dv.getUint32(map+b*4,true),g=0;
    while(ok(node)&&g++<30){ const lo=dv.getUint32(node+8,true),hi=dv.getUint32(node+12,true),ent=dv.getUint32(node+16,true);
      const key=hi+'_'+lo;
      if(ok(ent)&&!seen[key]){ seen[key]=1;
        const st=dv.getInt32(ent+204,true), alive=dv.getUint32(ent+192,true), dying=dv.getInt32(ent+220,true);
        if(st===5 && alive!==0 && dying===0){ mobs.push({lo,hi, x:f(ent,72),y:f(ent,76),z:f(ent,80)}); } }
      node=dv.getUint32(node,true);} }
  return {player, mobs};
}"""

def read_world(page):
    return page.evaluate(READ_JS)

def read_selected_id(page):
    """The mob the game currently has selected (controller+2656 i64), or None."""
    r = page.evaluate("""()=>{const dv=new DataView(HEAPU8.buffer);const c=dv.getUint32(1230588,true);
      const lo=dv.getUint32(c+2656,true),hi=dv.getUint32(c+2660,true);
      return (lo===0&&hi===0)?null:[lo,hi];}""")
    return tuple(r) if r else None

def mob_alive(page, lo, hi):
    return page.evaluate("""(m)=>{const dv=new DataView(HEAPU8.buffer);const ok=p=>p>0x10000&&p<0x10000000-512;
      const map=dv.getUint32(362748,true),cap=dv.getUint32(362752,true);
      for(let b=0;b<cap;b++){let n=dv.getUint32(map+b*4,true),g=0;
        while(ok(n)&&g++<30){ if(dv.getUint32(n+8,true)===m[0]&&dv.getUint32(n+12,true)===m[1]){
          const e=dv.getUint32(n+16,true); if(!ok(e))return false;
          return dv.getInt32(e+204,true)===5 && dv.getUint32(e+192,true)!==0 && dv.getInt32(e+220,true)===0; }
          n=dv.getUint32(n,true);} } return false; }""", [lo, hi])

# ---- calibrate PLAYER-RELATIVE world(dx,dz)->screen via self-clicking ----
# We click points; when a click selects a mob, the game writes its id to controller+2656,
# so we read that exact mob's world pos -> a perfect (world-player, screen-click) pair.
def _read_target_and_player(page):
    return page.evaluate("""()=>{const dv=new DataView(HEAPU8.buffer);const ok=p=>p>0x10000&&p<0x10000000-512;
      const c=dv.getUint32(1230588,true);
      const tlo=dv.getUint32(c+2656,true),thi=dv.getUint32(c+2660,true);
      const player={x:dv.getFloat32(c+72,true),y:dv.getFloat32(c+76,true),z:dv.getFloat32(c+80,true)};
      if(tlo===0&&thi===0) return {player,have:false};
      const map=dv.getUint32(362748,true),cap=dv.getUint32(362752,true);
      for(let b=0;b<cap;b++){let n=dv.getUint32(map+b*4,true),g=0;
        while(ok(n)&&g++<30){ if(dv.getUint32(n+8,true)===tlo&&dv.getUint32(n+12,true)===thi){
          const e=dv.getUint32(n+16,true); if(ok(e)) return {player,have:true,
            mx:dv.getFloat32(e+72,true),my:dv.getFloat32(e+76,true),mz:dv.getFloat32(e+80,true)}; }
          n=dv.getUint32(n,true);} } return {player,have:false}; }""")

# search memory for the live view-projection MATRIX that reproduces all (world->screen) pairs
def find_vp_matrix(page, pairs, w, h):
    """pairs = [(wx,wy,wz, sx,sy)]. Returns {off, layout} of a 16-float matrix M (read live) that
    projects every pair to its screen pos, or None. Tries column- and row-major; y-flip viewport."""
    # layout = "{col|row}_{flip|noflip}"  (matrix-major × viewport Y convention)
    js = """(a)=>{
      const F=new Float32Array(HEAPU8.buffer); const P=a.pairs, w=a.w, h=a.h, tol=a.tol;
      const proj=(i,col,flip,X,Y,Z)=>{ let cx,cy,cw;
        if(col){ cx=F[i]*X+F[i+4]*Y+F[i+8]*Z+F[i+12]; cy=F[i+1]*X+F[i+5]*Y+F[i+9]*Z+F[i+13]; cw=F[i+3]*X+F[i+7]*Y+F[i+11]*Z+F[i+15]; }
        else   { cx=F[i]*X+F[i+1]*Y+F[i+2]*Z+F[i+3]; cy=F[i+4]*X+F[i+5]*Y+F[i+6]*Z+F[i+7]; cw=F[i+12]*X+F[i+13]*Y+F[i+14]*Z+F[i+15]; }
        if(!(cw>0.0001)) return null;
        const ndx=cx/cw, ndy=cy/cw;
        return [(ndx*0.5+0.5)*w, (flip?(1-(ndy*0.5+0.5)):(ndy*0.5+0.5))*h]; };
      const test=(i,col,flip)=>{ for(let k=0;k<P.length;k++){
          const r=proj(i,col,flip,P[k][0],P[k][1],P[k][2]); if(!r) return false;
          if(Math.abs(r[0]-P[k][3])>tol || Math.abs(r[1]-P[k][4])>tol) return false; } return true; };
      const combos=[['col',true],['row',true],['col',false],['row',false]];
      for(let i=0;i<F.length-16;i++){
        let ok16=true; for(let k=0;k<16;k++){const v=F[i+k]; if(!isFinite(v)||Math.abs(v)>1e9){ok16=false;break;}}
        if(!ok16) continue;
        for(const c of combos){ if(test(i,c[0]==='col',c[1])) return {off:i*4, layout:c[0]+'_'+(c[1]?'flip':'noflip')}; }
      }
      return null;
    }"""
    return page.evaluate(js, {"pairs": [[p[0], p[1], p[2], p[3], p[4]] for p in pairs], "w": w, "h": h, "tol": 30})

def project_with_matrix(page, off, layout, w, h, wx, wy, wz):
    return page.evaluate("""(a)=>{const F=new Float32Array(HEAPU8.buffer); const i=a.off/4, X=a.x,Y=a.y,Z=a.z;
      const col=a.layout.indexOf('col')===0, flip=a.layout.indexOf('noflip')<0;
      let cx,cy,cw;
      if(col){cx=F[i]*X+F[i+4]*Y+F[i+8]*Z+F[i+12];cy=F[i+1]*X+F[i+5]*Y+F[i+9]*Z+F[i+13];cw=F[i+3]*X+F[i+7]*Y+F[i+11]*Z+F[i+15];}
      else{cx=F[i]*X+F[i+1]*Y+F[i+2]*Z+F[i+3];cy=F[i+4]*X+F[i+5]*Y+F[i+6]*Z+F[i+7];cw=F[i+12]*X+F[i+13]*Y+F[i+14]*Z+F[i+15];}
      if(!(cw>0.0001)) return null;
      const ndx=cx/cw, ndy=cy/cw;
      return [(ndx*0.5+0.5)*a.w, (flip?(1-(ndy*0.5+0.5)):(ndy*0.5+0.5))*a.h]; }""",
      {"off": off, "layout": layout, "w": w, "h": h, "x": wx, "y": wy, "z": wz})

def _clear_target(page):
    page.evaluate("()=>{const dv=new DataView(HEAPU8.buffer);const c=dv.getUint32(1230588,true);dv.setUint32(c+2656,0,true);dv.setUint32(c+2660,0,true);}")

def _fit(pairs):
    """fit a projective HOMOGRAPHY: player-relative world (dx,dz) -> screen (sx,sy)."""
    src = np.array([[dx, dz] for dx, dz, _, _ in pairs], dtype=np.float32).reshape(-1, 1, 2)
    dst = np.array([[sx, sy] for _, _, sx, sy in pairs], dtype=np.float32).reshape(-1, 1, 2)
    H, _ = cv2.findHomography(src, dst, cv2.RANSAC, 12.0)
    return H

def _apply(H, dx, dz):
    pt = np.array([[[float(dx), float(dz)]]], dtype=np.float32)
    out = cv2.perspectiveTransform(pt, H)
    return float(out[0, 0, 0]), float(out[0, 0, 1])

CALIB_N = 6      # mobs you click to calibrate

def calibrate(page, w, h, report=None):
    """ASSISTED: you click CALIB_N different mobs (spread across the screen). For each click we
    read the EXACT mob it selected (memory) -> a clean (mob-player, click) pair. Fit a homography
    once; then the bot grinds autonomously. (Camera must not move during/after calibration.)"""
    if report: report(f"OPTIONAL: click the BASE of {CALIB_N} mobs to calibrate, or wait to skip")
    print(f"[calib] OPTIONAL — click the FEET/base of {CALIB_N} mobs (spread out, don't walk),")
    print(f"[calib] or just wait ~12s to skip and grind with vision targeting.")
    pairs = []; last_t = -1; t0 = time.time(); last_pair_t = time.time()
    while len(pairs) < CALIB_N:
        if page.evaluate("window.__bot && window.__bot.cmd") != "start":
            return None
        # skip calibration entirely if no clicks come in the grace window
        if not pairs and time.time() - t0 > 12:
            print("[calib] no clicks — skipping calibration (vision targeting).")
            return None
        # proceed with however many we have if the user stops clicking
        if len(pairs) >= 4 and time.time() - last_pair_t > 6:
            break
        if time.time() - t0 > 120:
            break
        lc = page.evaluate("window.__lastClick || null")
        if lc and lc["t"] != last_t:
            last_t = lc["t"]
            time.sleep(0.22)
            st = _read_target_and_player(page)
            if st.get("have"):
                ssx, ssy = lc["x"], lc["y"]          # the click point IS the screen target (mob base)
                if all(abs(ssx-q[2]) + abs(ssy-q[3]) > 25 for q in pairs):
                    # store player-relative (for homography) AND absolute world XYZ (for the matrix)
                    pairs.append((st["mx"] - st["player"]["x"], st["mz"] - st["player"]["z"], ssx, ssy,
                                  st["mx"], st["my"], st["mz"]))
                    last_pair_t = time.time()
                    if report: report(f"calibrate: {len(pairs)}/{CALIB_N} mobs clicked")
            else:
                if report: report(f"that wasn't a mob — click a mob ({len(pairs)}/{CALIB_N})")
        time.sleep(0.12)
    if len(pairs) < 4:
        if report: report("calibration skipped — vision targeting")
        return None
    # 1) try to find the LIVE view-projection matrix (accurate everywhere; survives camera motion)
    if report: report(f"searching for camera matrix ({len(pairs)} pairs)...")
    vp = find_vp_matrix(page, [(p[4], p[5], p[6], p[2], p[3]) for p in pairs], w, h)
    if vp:
        if report: report(f"found LIVE camera matrix @{vp['off']} ({vp['layout']})")
        return {"kind": "matrix", "off": vp["off"], "layout": vp["layout"], "w": w, "h": h}
    # 2) fall back to the player-relative homography (approximate)
    H = _fit([(p[0], p[1], p[2], p[3]) for p in pairs])
    if H is None:
        if report: report("calibration failed (no matrix, no homography)"); return None
    errs = sorted(math.hypot(_apply(H, dx, dz)[0]-sx, _apply(H, dx, dz)[1]-sy) for dx, dz, sx, sy, *_ in pairs)
    med = errs[len(errs)//2]
    if report: report(f"calibrated via homography ({len(pairs)} pts, median {med:.0f}px) — less accurate")
    return {"kind": "homo", "H": H} if med < 50 else None

def project(page, proj, mob, player):
    if proj["kind"] == "matrix":
        r = project_with_matrix(page, proj["off"], proj["layout"], proj["w"], proj["h"], mob["x"], mob["y"], mob["z"])
        return r
    return _apply(proj["H"], mob["x"] - player["x"], mob["z"] - player["z"])

# ---- engage at a screen point (two-click attack, reuse arrows for the 2nd click) ----
def engage_at(page, sx, sy):
    page.mouse.click(sx, sy)                                   # select
    time.sleep(gl.SELECT_GAP)
    c = gl.arrows_center(gl.grab(page), near=(sx, sy))
    if c:
        page.mouse.click(c[0], c[1])                          # attack at arrows center
        return c
    return (sx, sy)

SNAP_PX = 110     # how close a memory-mob projection must be to a nameplate to "confirm" it

def _pick_plate(page, aff, img, wd, w, h):
    """Decide WHICH on-screen nameplate to click. Vision gives the real click pixels;
    memory + projection only choose among them. Returns (sx,sy, dbg) or None.
    Never returns a raw projected point — only actual detected nameplates (so a click
    can't land on empty ground and walk the character)."""
    plates = gl.find_nameplate_mobs(img, None)         # [(mx,my,bw,aggro)] — real on-screen mobs
    if not plates:
        return None
    # aggro plates first, regardless of projection
    aggro = [p for p in plates if len(p) > 3 and p[3]]
    if aggro:
        fx, fy = w*0.5, h*0.58
        a = min(aggro, key=lambda p: (p[0]-fx)**2 + (p[1]-fy)**2)
        return (a[0], a[1], "aggro")
    # if we have a projector, snap the NEAREST memory mob to its closest nameplate
    if aff is not None:
        px, pz = wd["player"]["x"], wd["player"]["z"]
        for m in sorted(wd["mobs"], key=lambda m: (m["x"]-px)**2 + (m["z"]-pz)**2)[:8]:
            pr = project(page, aff, m, wd["player"])
            if not pr:
                continue
            cand = min(plates, key=lambda p: (p[0]-pr[0])**2 + (p[1]-pr[1])**2)
            d = math.hypot(cand[0]-pr[0], cand[1]-pr[1])
            if d < SNAP_PX:
                return (cand[0], cand[1], f"snap d={d:.0f}")
        # projection didn't line up with any plate — fall through to vision-nearest
    # fallback: nearest nameplate to the player (screen centre-front) = nearest mob
    fx, fy = w*0.5, h*0.58
    t = min(plates, key=lambda p: (p[0]-fx)**2 + (p[1]-fy)**2)
    return (t[0], t[1], "nearest-front")

def grind(page, w, h, should_stop=None, report=None):
    importlib.reload(gl)
    print("GRIND-LOCAL-2 (hybrid: memory sense + vision-nameplate clicks). Ctrl+C to stop.")
    last_heal = 0.0
    gl.calibrate_view(page)
    aff = calibrate(page, w, h, report=report)     # optional: projection only helps pick a plate
    if aff is None:
        if report: report("no projection — using memory-gated vision targeting")
        print("[calib] no projection; falling back to vision nameplate targeting (still memory-gated)")
    kills = 0
    target = None     # (lo,hi) from memory
    misses = 0
    while True:
        if should_stop and should_stop():
            return kills
        img = gl.grab(page); h, w = img.shape[:2]
        hp = gl.hp_fraction(img)
        now = time.time()
        if report: report(f"local2 · kills {kills} · HP {int(hp*100)}%{' · fighting' if target else ''}")
        if hp < gl.HEAL_AT and now - last_heal > gl.HEAL_COOLDOWN:
            page.keyboard.down(gl.FOOD_KEY); time.sleep(0.06); page.keyboard.up(gl.FOOD_KEY)
            last_heal = now; print(f"[heal] {int(hp*100)}%")

        # current target still alive? (memory = bulletproof death detection)
        if target and not mob_alive(page, target[0], target[1]):
            kills += 1; print(f"[kill ] mob down — total {kills}"); target = None

        if target is None:
            wd = read_world(page)
            if not wd["mobs"]:
                gl.rotate_view(page, w, h); print("[scan] no mobs in memory — rotating"); time.sleep(CYCLE); continue
            pick = _pick_plate(page, aff, img, wd, w, h)
            if pick is None:
                # mobs exist in memory but none visible on screen — camera angle/occlusion
                gl.rotate_view(page, w, h); print("[scan] mobs exist but no nameplate visible — rotating"); time.sleep(CYCLE); continue
            sx, sy, dbg = pick
            print(f"[engage] clicking nameplate ({sx:.0f},{sy:.0f}) [{dbg}]")
            engage_at(page, sx, sy)
            time.sleep(0.18)
            sel = read_selected_id(page)               # did we actually select a mob?
            if sel:
                target = sel
                misses = 0
                print(f"[engaged] selected mob 0x{sel[1]:x}_{sel[0]:x}")
                time.sleep(gl.ENGAGE_SETTLE)
            else:
                misses += 1
                print(f"[miss ] no selection after click ({misses})")
                if misses >= 3:
                    gl.rotate_view(page, w, h); misses = 0
                time.sleep(CYCLE)
        else:
            time.sleep(CYCLE)
