"""click_nav.py — SMOOTH, click-like navigation + targeting for grind_RE, driven by memory ground-truth.

Idea (validated live 2026-07-02): the game writes the world (x,z) that a click raycasts to at
**controller+2540/+2548**. So we build an accurate world<->screen projection with ZERO vision — click
screen points, read +2540, fit an affine map (camera is ~orthographic; live fit error < 1 world unit). Then:
  * MOVE to any world point = project it to a screen pixel and REAL-click there -> the game's own smooth
    walk (server + client, no teleport, no rubber-band). Chain clicks for far targets (a screen spans ~25u).
  * ATTACK a mob = project to get near (chasing its fresh position), then hover a fast spiral and let the
    GAME's cursor swap to `curattack` confirm the exact sprite pixel (the vision bot's trick) before
    clicking. First click SELECTS, a second click ATTACKS; the game then auto-attacks to death.

Reliable on stationary/slow mobs; ~half on fast-fleeing flyers (aibats move between the two clicks).
This does NOT touch the sacred HP/MP/mob/view reads — it only reads +2540 + positions and issues clicks.
The projection is CAMERA-relative, so calibrate ONCE per camera setup and reuse (recalibrate on zoom/tilt).
"""
import re
import time
import math

CTRL_PTR = 1230588
DEST_OFF = 2540            # controller+2540/+2548 = the world (x,z) the last click raycast to
ATTACK_CURSOR = "curattack"
MAX_REACH = 9.0           # clamp per-click walk (a screen only spans ~25u; keep the click on-screen)
CALIB_CACHE = "click_calib.json"


class ClickNav:
    def __init__(self, game):
        self.g = game
        self.c = game.evaluate(f"()=>new DataView(HEAPU8.buffer).getUint32({CTRL_PTR},true)")
        vp = game.evaluate("()=>({w:innerWidth,h:innerHeight})")
        self.W, self.H = vp["w"], vp["h"]
        self.coef = None       # (cx, cz) affine coeffs: worldOffset = f(screen)

    # ── low-level reads (all cheap, no hooks) ──
    def pos(self):
        return self.g.evaluate("(c)=>{const dv=new DataView(HEAPU8.buffer);return [dv.getFloat32(c+152,true),dv.getFloat32(c+160,true)];}", self.c)

    def _dest(self):
        return self.g.evaluate("(a)=>{const dv=new DataView(HEAPU8.buffer);return [dv.getFloat32(a[0]+a[1],true),dv.getFloat32(a[0]+a[1]+8,true)];}", [self.c, DEST_OFF])

    def sel_id(self):
        return self.g.evaluate("(c)=>{const dv=new DataView(HEAPU8.buffer);return [dv.getUint32(c+2656,true),dv.getUint32(c+2660,true)];}", self.c)

    def cursor_kind(self):
        css = self.g.evaluate("()=>{const c=document.querySelector('canvas');return (c&&c.style.cursor)||document.body.style.cursor||(c&&getComputedStyle(c).cursor)||'';}")
        m = re.search(r"cursor/(\w+)\.", css or "")
        return m.group(1) if m else ""

    def mob_addr(self, lo, hi):
        return self.g.evaluate(r"""(a)=>{const dv=new DataView(HEAPU8.buffer);const heap=HEAPU8.buffer.byteLength;const ok=x=>x>0x10000&&x<heap;
          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),gd=0;while(ok(n)&&gd++<100000){const e=dv.getUint32(n+16,true);
            if(ok(e)&&dv.getUint32(e+368,true)===(a[0]>>>0)&&dv.getUint32(e+372,true)===(a[1]>>>0))return e;n=dv.getUint32(n,true);}}return 0;}""", [lo & 0xffffffff, hi & 0xffffffff])

    def mob_pos(self, ea):
        """fast [x, z, hp] read of a specific entity address (no registry walk)."""
        return self.g.evaluate("(e)=>{const dv=new DataView(HEAPU8.buffer);return [dv.getFloat32(e+152,true),dv.getFloat32(e+160,true),(dv.getUint32(e+2768,true)^dv.getUint32(e+2744,true))|0];}", ea)

    # ── clicking ──
    def click(self, sx, sy):
        self.g.mouse.move(int(sx), int(sy)); time.sleep(0.05)
        self.g.mouse.down(); time.sleep(0.04); self.g.mouse.up()

    def _wait_stop(self, mx=18):
        prev = None
        for _ in range(mx):
            time.sleep(0.15); q = self.pos()
            if prev and math.hypot(q[0] - prev[0], q[1] - prev[1]) < 0.15:
                return
            prev = q

    # ── projection ──
    def _fit(self, pts):
        import numpy as np
        A = np.array([[s[0], s[1], 1] for s in pts])
        cx = np.linalg.lstsq(A, np.array([s[2] for s in pts]), rcond=None)[0]
        cz = np.linalg.lstsq(A, np.array([s[3] for s in pts]), rcond=None)[0]
        err = np.hypot(A @ cx - np.array([s[2] for s in pts]), A @ cz - np.array([s[3] for s in pts]))
        return (cx, cz), err

    def calibrate(self, good=1.1, tries=3):
        """Fit worldOffset = affine(screen) from +2540 ground-truth, with outlier rejection + retry.
        Returns fit-error (world units); < ~1 is healthy. Wanders the char a little (grid clicks)."""
        import numpy as np
        best = None
        for _ in range(tries):
            pts, pd = [], None
            for fy in (0.32, 0.44, 0.56, 0.68):
                for fx in (0.32, 0.48, 0.64, 0.80):
                    p0 = self.pos(); self.click(self.W * fx, self.H * fy); time.sleep(0.16); dd = self._dest()
                    off = math.hypot(dd[0] - p0[0], dd[1] - p0[1])
                    if (pd is None or math.hypot(dd[0] - pd[0], dd[1] - pd[1]) > 0.5) and 1 < off < 40:
                        pts.append((self.W * fx, self.H * fy, dd[0] - p0[0], dd[1] - p0[1]))
                    pd = dd; time.sleep(0.35)
            if len(pts) < 6:
                continue
            coef, err = self._fit(pts)
            thr = max(1.5, 2.0 * float(np.median(err)))            # drop outlier pairs, refit
            keep = [p for p, ee in zip(pts, err) if ee <= thr]
            if 6 <= len(keep) < len(pts):
                coef, err = self._fit(keep)
            me = float(err.mean())
            if best is None or me < best[1]:
                best = (coef, me)
            if me <= good:
                break
        self.coef = best[0]
        return best[1]

    def save_calib(self, path=CALIB_CACHE):
        import json
        (cx, cz) = self.coef
        json.dump({"cx": list(cx), "cz": list(cz)}, open(path, "w"))

    def load_calib(self, path=CALIB_CACHE):
        """Load cached projection coeffs (camera-relative, stable). Recalibrate on camera zoom/tilt change."""
        import json, os
        if not os.path.exists(path):
            return False
        try:
            import numpy as np
            j = json.load(open(path))
            self.coef = (np.array(j["cx"]), np.array(j["cz"]))
            return True
        except Exception:
            return False

    def world_to_screen(self, dwx, dwz):
        """camera-relative world offset (target - player) -> screen pixel."""
        (a, b, e), (d, f, g) = self.coef
        det = a * f - b * d
        return (((dwx - e) * f - (dwz - g) * b) / det, (a * (dwz - g) - d * (dwx - e)) / det)

    def move_to_world(self, wx, wz, arrive=3.0, maxhops=12):
        """Walk the char to world (wx,wz) by chaining real clicks (smooth, server+client). Returns final err.
        Breaks (doesn't spin) if a hop makes no progress (obstacle/edge/unreachable)."""
        last = None; stalls = 0
        for _ in range(maxhops):
            p = self.pos(); dwx, dwz = wx - p[0], wz - p[1]; dist = math.hypot(dwx, dwz)
            if dist < arrive:
                break
            if last is not None and last - dist < 0.8:
                stalls += 1
                if stalls >= 2:
                    break
            else:
                stalls = 0
            last = dist
            r = min(dist, MAX_REACH)
            sx, sy = self.world_to_screen(dwx / dist * r, dwz / dist * r)
            sx = max(self.W * 0.12, min(self.W * 0.88, sx)); sy = max(self.H * 0.20, min(self.H * 0.85, sy))
            self.click(sx, sy); self._wait_stop()
        p = self.pos()
        return math.hypot(wx - p[0], wz - p[1])

    def attack(self, lo, hi, ea=None, tries=5):
        """Walk to the mob (lo,hi) and attack it until its HP starts dropping (then the game auto-attacks to
        death). Chases the mob's fresh position; uses the `curattack` cursor to land clicks ON the sprite
        (click=SELECT, a 2nd click=ATTACK). Smooth + click-like (server+client). True once damage confirmed."""
        if ea is None:
            ea = self.mob_addr(lo, hi)
        if not ea:
            return False
        for _ in range(9):                                    # approach: CHASE the mob's fresh position
            p = self.pos(); mp = self.mob_pos(ea); dist = math.hypot(mp[0] - p[0], mp[1] - p[1])
            if dist < 4.0:
                break
            r = min(dist, MAX_REACH)
            sx, sy = self.world_to_screen((mp[0] - p[0]) / dist * r, (mp[1] - p[1]) / dist * r)
            sx = max(self.W * 0.12, min(self.W * 0.88, sx)); sy = max(self.H * 0.20, min(self.H * 0.85, sy))
            self.click(sx, sy); self._wait_stop(10)
        self._wait_stop(6)
        hp0 = self.mob_pos(ea)[2]
        ap = self.find_cursor_target(ea)                      # seed the sprite offset (curattack)
        if not ap:
            return False
        p = self.pos(); mp = self.mob_pos(ea); gx, gy = self.world_to_screen(mp[0] - p[0], mp[1] - p[1])
        odx, ody = ap[0] - gx, ap[1] - gy
        for _ in range(tries * 2):                            # rapid: click, quick HP check, click again
            if not self._confirm_click(ea, odx, ody):
                self._wait_stop(3); continue
            time.sleep(0.5)
            h = self.mob_pos(ea)[2]
            if h is None or h < hp0:
                return True
            hp0 = min(hp0, h)
        return False

    _SPIRAL = [(0, 0)] + [(int(r * math.cos(t)), int(r * math.sin(t)))
                          for r in (16, 32, 48, 64, 80) for t in [i * math.pi / 4 for i in range(8)]]

    def _confirm_click(self, ea, odx, ody):
        """Hover a fast spiral around the mob's FRESH projection (+seed offset); the instant the game cursor
        is `curattack` (on the mob), click WITHOUT re-moving. Returns True if it clicked a mob."""
        p = self.pos(); mp = self.mob_pos(ea)
        bx, by = self.world_to_screen(mp[0] - p[0], mp[1] - p[1])
        bx += odx; by += ody
        for ox, oy in self._SPIRAL:
            sx, sy = int(bx + ox), int(by + oy)
            if not (0 < sx < self.W and 0 < sy < self.H):
                continue
            self.g.mouse.move(sx, sy); time.sleep(0.028)
            if self.cursor_kind() == ATTACK_CURSOR:
                self.g.mouse.down(); time.sleep(0.04); self.g.mouse.up()
                return True
        return False

    def find_cursor_target(self, ea, ygrid=range(-90, 31, 10), xgrid=range(-55, 56, 11), settle=0.033):
        """Hover a grid around mob `ea`'s projection; return the screen point where the game cursor is
        `curattack` (a real clickable mob under it) — or None."""
        mp = self.mob_pos(ea); p = self.pos()
        bx, by = self.world_to_screen(mp[0] - p[0], mp[1] - p[1])
        for oy in ygrid:
            for ox in xgrid:
                self.g.mouse.move(int(bx + ox), int(by + oy)); time.sleep(settle)
                if self.cursor_kind() == ATTACK_CURSOR:
                    return (bx + ox, by + oy)
        return None
