"""
evony_boss_scanner_adb.py - Camino D
-------------------------------------
Scanner de bosses para Evony usando ADB. Sustituye pyautogui por eventos de
touch reales enviados al Android dentro de BlueStacks.

Ventajas vs Camino C (evony_boss_scanner.py con pyautogui):
- BlueStacks NO tiene que estar al frente. Minimizalo y haz otras cosas.
- No requiere permisos Mac (Accessibility / Screen Recording).
- Screencaps directos del Android, mas rapidos y exactos.
- Texto/teclas via keyevent (mas fiable que pyautogui en EditText Android).
- Multi-escala matching, pan-drag rapido en serpiente.
- Detector de freeze (delta de filesize en screencaps consecutivos).
- Detector basico de captcha (template) - para si aparece.

Sigue siendo Camino C en cuanto a riesgo de TOS: cuenta desechable, sesiones
cortas, parar si aparece captcha.

Requisitos:
    brew install android-platform-tools
    BlueStacks: Settings > Advanced > Android Debug Bridge: ON
    adb connect 127.0.0.1:5555  (o el puerto que muestre BlueStacks)
    adb devices  -> debe listar tu device

Uso:
    python3 evony_boss_scanner_adb.py --check        # verifica ADB
    python3 evony_boss_scanner_adb.py --calibrate    # primera vez
    python3 evony_boss_scanner_adb.py --test-screencap
    python3 evony_boss_scanner_adb.py --test-jump 458 567
    python3 evony_boss_scanner_adb.py --test-drag
    python3 evony_boss_scanner_adb.py --pan-once     # un barrido rapido
    python3 evony_boss_scanner_adb.py                # bucle hasta 30 min
"""

import argparse
import csv
import json
import random
import subprocess
import sys
import threading
import time
from dataclasses import dataclass, asdict
from datetime import datetime
from pathlib import Path

import cv2
import numpy as np


# ====== RUTAS ======
HERE = Path(__file__).parent
TEMPLATES_DIR = HERE / "templates_bosses"
LOG_FILE = HERE / "boss_log_adb.csv"
CONFIG_FILE = HERE / "scanner_config_adb.json"
CAPTCHA_TEMPLATE = HERE / "captcha.png"   # opcional. Si existe, se busca.


# ====== ADB ======
ADB = "adb"
ADB_DEVICE_DEFAULT = "127.0.0.1:5555"
DEFAULT_PORTS = [5555, 5575, 5585, 5595, 5605]

# ====== MATCHING ======
MATCH_THRESHOLD = 0.58         # bajo para aceptar matches parciales / variantes
MATCH_SCALES = [0.30, 0.45, 0.55, 0.70, 0.85, 1.0]   # 6 escalas, banda amplia

# ====== TIMING ======
ACTION_DELAY = (0.06, 0.12)
SCAN_PAUSE = (0.01, 0.03)          # antes de capturar (camara ya asentada)
SETTLE_AFTER_PAN = (0.03, 0.08)    # tras drag
SETTLE_AFTER_JUMP = (1.2, 1.8)    # solo 1 jump inicial
CYCLE_PAUSE = (60, 180)
MAX_RUNTIME_MIN = 30

# ====== PAN ======
# PAN_FRAC < 1.0 = overlap entre captures consecutivas, asegura que cada boss
# aparezca CENTRADO en al menos un viewport (no solo en bordes).
PAN_FRAC_X = 0.50                  # 50% overlap horizontal: cada boss aparece centrado en >=1 capture
PAN_FRAC_Y = 1.0                   # S drag usa zona segura, no PAN_FRAC_Y directo
PAN_MARGIN = 40
PAN_DURATION_MS = 150              # muy rapido; vertical usa max(PAN_DURATION_MS, 250)
PAN_VERT_TOP_FRAC = 0.08           # zona segura vertical: % superior del map_region
PAN_VERT_BOT_FRAC = 0.82           # zona segura vertical: % inferior (~74% span)
ROW_STRIDE_FRAC = 0.85
ANCHOR_EVERY_N_DRAGS = 0           # 0 = sin re-anchor (mas rapido, mas drift). >0 activa.

# ====== SAFETY ======
FREEZE_BYTES_DELTA = 15_000
FREEZE_SIMILAR_FRAMES = 8
CAPTCHA_THRESHOLD = 0.65

# ====== DEBUG ======
DEBUG_CAPTURES = False
DEBUG_DIR = HERE / "debug_snake"


@dataclass
class Calibration:
    device: str
    search_btn: list
    x_field: list
    y_field: list
    go_btn: list
    map_region: dict
    tiles_visible_x: int
    tiles_visible_y: int
    scan_center_x: int
    scan_center_y: int
    scan_radius: int


# ====== ADB HELPERS ======
def adb_check(device, verbose=True):
    try:
        r = subprocess.run([ADB, "version"], capture_output=True, text=True, timeout=5)
        if r.returncode != 0:
            return False, "adb instalado pero falla al ejecutarse"
    except FileNotFoundError:
        return False, "adb no encontrado. Instala: brew install android-platform-tools"
    except subprocess.TimeoutExpired:
        return False, "adb no responde"
    subprocess.run([ADB, "connect", device], capture_output=True, text=True, timeout=8)
    r = subprocess.run([ADB, "devices"], capture_output=True, text=True, timeout=5)
    if device in r.stdout and "offline" not in r.stdout.split(device)[-1].split("\n")[0]:
        if verbose:
            print(f"[OK] adb conectado a {device}")
        return True, "OK"
    return False, (f"Device {device} no esta conectado. "
                   "Activa 'Android Debug Bridge' en BlueStacks Settings > Advanced.")


def adb_devices_list():
    out = subprocess.run([ADB, "devices"], capture_output=True, text=True).stdout
    devs = []
    for line in out.splitlines()[1:]:
        line = line.strip()
        if line and "\tdevice" in line:
            devs.append(line.split("\t")[0])
    return devs


def auto_connect():
    devs = adb_devices_list()
    if devs:
        return devs[0]
    for port in DEFAULT_PORTS:
        subprocess.run([ADB, "connect", f"127.0.0.1:{port}"], capture_output=True, timeout=5)
        if f"127.0.0.1:{port}" in adb_devices_list():
            return f"127.0.0.1:{port}"
    return None


def adb_run(device, args, timeout=10):
    return subprocess.run([ADB, "-s", device] + args, timeout=timeout, capture_output=True)


def adb_tap(device, x, y):
    adb_run(device, ["shell", "input", "tap", str(int(x)), str(int(y))])


def adb_text(device, text):
    safe = str(text).replace(" ", "%s")
    adb_run(device, ["shell", "input", "text", safe])


def adb_key(device, keyevent, count=1):
    for _ in range(count):
        adb_run(device, ["shell", "input", "keyevent", str(keyevent)])


def adb_swipe(device, x1, y1, x2, y2, duration_ms=PAN_DURATION_MS):
    adb_run(device, ["shell", "input", "swipe",
                     str(int(x1)), str(int(y1)),
                     str(int(x2)), str(int(y2)),
                     str(int(duration_ms))], timeout=15)


def adb_screencap(device):
    """Captura PNG via exec-out. Devuelve (BGR numpy, raw_bytes) o (None, b'')."""
    r = subprocess.run([ADB, "-s", device, "exec-out", "screencap", "-p"],
                       capture_output=True, timeout=15)
    if r.returncode != 0 or not r.stdout:
        return None, b""
    arr = np.frombuffer(r.stdout, dtype=np.uint8)
    img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
    return img, r.stdout


# ====== UTILS ======
def jitter(rng):
    return random.uniform(*rng)


def tap_jitter(device, x, y):
    adb_tap(device, x + random.randint(-3, 3), y + random.randint(-3, 3))
    time.sleep(jitter(ACTION_DELAY))


def clear_and_type(device, pos, value):
    """Tap campo, mueve cursor al final, borra ~10 chars, escribe value."""
    tap_jitter(device, *pos)
    time.sleep(0.25)
    adb_key(device, "KEYCODE_MOVE_END")
    time.sleep(0.08)
    adb_key(device, "KEYCODE_DEL", count=10)
    time.sleep(0.12)
    adb_text(device, str(value))
    time.sleep(jitter((0.15, 0.30)))


# ====== CALIBRACION INTERACTIVA ======
def _pick_point(image, prompt):
    """Ventana cv2 con la imagen escalada. Click + Enter/Space para confirmar."""
    win = "Calibracion"
    h, w = image.shape[:2]
    max_h = 900
    scale = min(1.0, max_h / h)
    disp_w, disp_h = int(w * scale), int(h * scale)

    # Pre-escalar la imagen (NO depender de WINDOW_NORMAL para el scaling)
    base = cv2.resize(image, (disp_w, disp_h), interpolation=cv2.INTER_AREA)

    cv2.namedWindow(win, cv2.WINDOW_AUTOSIZE)
    cv2.moveWindow(win, 50, 50)
    try:
        cv2.setWindowProperty(win, cv2.WND_PROP_TOPMOST, 1)
    except Exception:
        pass

    state = {"point": None}   # se guarda en coords originales (image space)

    def cb(event, x, y, flags, _):
        # x, y vienen en coords del display (imagen ya escalada)
        if event == cv2.EVENT_LBUTTONDOWN:
            orig_x = int(x / scale)
            orig_y = int(y / scale)
            state["point"] = (orig_x, orig_y)
            print(f"     click display=({x},{y}) -> android=({orig_x},{orig_y})")

    cv2.setMouseCallback(win, cb)
    print(f"  >> {prompt}")
    print(f"     (CLICK en la ventana, luego ENTER o SPACE; ESC reintenta)")

    while True:
        disp = base.copy()
        banner_h = 60
        cv2.rectangle(disp, (0, 0), (disp.shape[1], banner_h), (0, 0, 0), -1)
        cv2.putText(disp, f"CLICK: {prompt}", (10, 26),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 255, 255), 2)
        cv2.putText(disp, "Click + ENTER/SPACE para confirmar. ESC reintentar.",
                    (10, 50), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
        if state["point"]:
            ox, oy = state["point"]
            dx, dy = int(ox * scale), int(oy * scale)
            cv2.drawMarker(disp, (dx, dy), (0, 0, 255), cv2.MARKER_CROSS, 30, 2)
            cv2.putText(disp, f"({ox},{oy}) -> ENTER", (dx + 12, dy - 10),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 0, 255), 2)
        cv2.imshow(win, disp)
        k = cv2.waitKey(20) & 0xFF
        if k in (13, 10, 32) and state["point"]:
            break
        if k == 27:
            state["point"] = None
    cv2.destroyWindow(win)
    cv2.waitKey(1)
    return state["point"]


def _confirm_screencap(image, message):
    """Muestra screencap. Enter/Space = SI, Esc = NO."""
    win = "Confirmacion"
    h, w = image.shape[:2]
    max_h = 900
    scale = min(1.0, max_h / h)
    base = cv2.resize(image, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
    cv2.namedWindow(win, cv2.WINDOW_AUTOSIZE)
    cv2.moveWindow(win, 50, 50)
    try:
        cv2.setWindowProperty(win, cv2.WND_PROP_TOPMOST, 1)
    except Exception:
        pass
    disp = base.copy()
    cv2.rectangle(disp, (0, 0), (disp.shape[1], 80), (0, 0, 0), -1)
    cv2.putText(disp, message, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 255), 2)
    cv2.putText(disp, "ENTER/SPACE = SI  |  ESC = NO (abortar)", (10, 60),
                cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1)
    while True:
        cv2.imshow(win, disp)
        k = cv2.waitKey(20) & 0xFF
        if k in (13, 10, 32):
            cv2.destroyWindow(win); cv2.waitKey(1); return True
        if k == 27:
            cv2.destroyWindow(win); cv2.waitKey(1); return False


def _pick_region(image, prompt):
    print(f"  >> {prompt}")
    tl = _pick_point(image, "Esquina SUPERIOR IZQUIERDA del mapa")
    br = _pick_point(image, "Esquina INFERIOR DERECHA del mapa")
    return {
        "top": int(tl[1]),
        "left": int(tl[0]),
        "width": int(br[0] - tl[0]),
        "height": int(br[1] - tl[1]),
    }


def calibrate(device):
    print("\n=== CALIBRACION ADB ===\n")
    ok, msg = adb_check(device)
    if not ok:
        print(f"[!] {msg}")
        sys.exit(1)

    print("[1/3] En BlueStacks, ve al MAPA del reino sin dialogo abierto.")
    input("      Pulsa Enter cuando estes en el mapa... ")
    img1, _ = adb_screencap(device)
    if img1 is None:
        print("[!] screencap fallo")
        sys.exit(1)
    print(f"      Captura {img1.shape[1]}x{img1.shape[0]}")
    search_btn = _pick_point(img1, "Icono LUPA que abre el buscador de coords")
    map_region = _pick_region(img1, "Region del MAPA visible (TL y BR)")

    print("\n[2/3] Voy a clicar la lupa via ADB para abrir el dialogo.")
    print(f"      Tap a android coords {search_btn}...")
    time.sleep(0.5)
    adb_tap(device, *search_btn)
    time.sleep(1.6)
    img2, _ = adb_screencap(device)
    if img2 is None:
        print("[!] screencap fallo")
        sys.exit(1)
    cv2.imwrite(str(HERE / "_after_lupa_tap.png"), img2)

    if not _confirm_screencap(img2, "ABRIO el dialogo de coordenadas?"):
        print("\n[!] El tap a la lupa NO abrio el dialogo.")
        print("    Posibles causas:")
        print("    - La coord marcada como 'lupa' no era el icono correcto.")
        print("    - El icono auto-oculta y al taparlo ya no estaba.")
        print(f"    Mira _after_lupa_tap.png para ver que paso.")
        print("    Reintenta: python3 evony_boss_scanner_adb.py --calibrate")
        sys.exit(1)

    x_field = _pick_point(img2, "Campo INPUT de coordenada X")
    y_field = _pick_point(img2, "Campo INPUT de coordenada Y")
    go_btn = _pick_point(img2, "Boton GO/IR/BUSCAR")
    adb_key(device, "KEYCODE_BACK")
    time.sleep(0.5)

    print("\n[3/3] Datos numericos:")
    tx = int(input("      Tiles horizontales visibles [10]: ") or "10")
    ty = int(input("      Tiles verticales visibles [21]: ") or "21")
    cx = int(input("      Centro X del area a escanear [458]: ") or "458")
    cy = int(input("      Centro Y [567]: ") or "567")
    rad = int(input("      Radio en tiles [50]: ") or "50")

    cal = Calibration(
        device=device,
        search_btn=list(search_btn),
        x_field=list(x_field),
        y_field=list(y_field),
        go_btn=list(go_btn),
        map_region=map_region,
        tiles_visible_x=tx, tiles_visible_y=ty,
        scan_center_x=cx, scan_center_y=cy, scan_radius=rad,
    )
    CONFIG_FILE.write_text(json.dumps(asdict(cal), indent=2))
    print(f"\n[+] Calibracion guardada en {CONFIG_FILE}\n")
    return cal


def load_cal():
    if not CONFIG_FILE.exists():
        print(f"[!] Config no existe. Corre: python3 {Path(__file__).name} --calibrate")
        sys.exit(1)
    d = json.loads(CONFIG_FILE.read_text())
    return Calibration(**d)


# ====== NAVEGACION ======
def jump_to(cal, x, y):
    tap_jitter(cal.device, *cal.search_btn)
    time.sleep(jitter((0.7, 1.2)))
    clear_and_type(cal.device, cal.x_field, x)
    time.sleep(jitter(ACTION_DELAY))
    clear_and_type(cal.device, cal.y_field, y)
    time.sleep(jitter(ACTION_DELAY))
    tap_jitter(cal.device, *cal.go_btn)
    time.sleep(jitter(SETTLE_AFTER_JUMP))


def pan_drag(cal, direction):
    """Drag dentro de map_region. Devuelve (dx_tiles, dy_tiles).
    E/W: drag horizontal cubriendo ~PAN_FRAC_X del ancho.
    S/N: drag VERTICAL en zona segura (20%-75% de la altura) para evitar UI inferior."""
    mr = cal.map_region
    L = mr["left"] + PAN_MARGIN
    R = mr["left"] + mr["width"] - PAN_MARGIN
    T = mr["top"] + PAN_MARGIN
    B = mr["top"] + mr["height"] - PAN_MARGIN
    MX = (L + R) // 2
    MY = (T + B) // 2
    ppt_x = mr["width"] / cal.tiles_visible_x
    ppt_y = mr["height"] / cal.tiles_visible_y

    if direction == "E":
        px_x = int((R - L) * PAN_FRAC_X)
        sx, sy, ex, ey = R, MY, R - px_x, MY
        dx_t, dy_t = +px_x / ppt_x, 0
        dur = PAN_DURATION_MS
    elif direction == "W":
        px_x = int((R - L) * PAN_FRAC_X)
        sx, sy, ex, ey = L, MY, L + px_x, MY
        dx_t, dy_t = -px_x / ppt_x, 0
        dur = PAN_DURATION_MS
    elif direction == "S":
        # Zona vertical segura: amplia, evita solo los bordes con UI
        safe_top = T + int((B - T) * PAN_VERT_TOP_FRAC)
        safe_bottom = T + int((B - T) * PAN_VERT_BOT_FRAC)
        sx, sy, ex, ey = MX, safe_bottom, MX, safe_top
        px_y = safe_bottom - safe_top
        dx_t, dy_t = 0, +px_y / ppt_y
        dur = max(PAN_DURATION_MS, 250)
    elif direction == "N":
        safe_top = T + int((B - T) * PAN_VERT_TOP_FRAC)
        safe_bottom = T + int((B - T) * PAN_VERT_BOT_FRAC)
        sx, sy, ex, ey = MX, safe_top, MX, safe_bottom
        px_y = safe_bottom - safe_top
        dx_t, dy_t = 0, -px_y / ppt_y
        dur = max(PAN_DURATION_MS, 250)
    else:
        raise ValueError(direction)

    sx += random.randint(-5, 5); sy += random.randint(-5, 5)
    ex += random.randint(-5, 5); ey += random.randint(-5, 5)
    print(f"    drag {direction}: ({sx},{sy})->({ex},{ey}) {dur}ms", flush=True)
    adb_swipe(cal.device, sx, sy, ex, ey, dur)
    time.sleep(jitter(SETTLE_AFTER_PAN))
    return dx_t, dy_t


# ====== CAPTURA / MATCH ======
def capture_map(cal):
    """Screencap completa + recorte al map_region. Devuelve (crop, raw_bytes)."""
    full, raw = adb_screencap(cal.device)
    if full is None:
        return None, b""
    r = cal.map_region
    crop = full[r["top"]:r["top"] + r["height"], r["left"]:r["left"] + r["width"]]
    return crop, raw


def load_templates():
    TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
    out = {}
    for p in sorted(TEMPLATES_DIR.glob("*.png")):
        img = cv2.imread(str(p), cv2.IMREAD_COLOR)
        if img is not None:
            out[p.stem] = img
    return out


def find_matches(screen, template, thr=MATCH_THRESHOLD):
    """Multi-escala. Devuelve [(cx_center, cy_center, score), ...] deduplicado."""
    if screen is None or template is None:
        return []
    h0, w0 = template.shape[:2]
    sh, sw = screen.shape[:2]
    all_hits = []
    for s in MATCH_SCALES:
        w = max(8, int(round(w0 * s)))
        h = max(8, int(round(h0 * s)))
        if w >= sw or h >= sh:
            continue
        tpl_s = cv2.resize(template, (w, h), interpolation=cv2.INTER_AREA)
        res = cv2.matchTemplate(screen, tpl_s, cv2.TM_CCOEFF_NORMED)
        ys, xs = np.where(res >= thr)
        for y, x in zip(ys.tolist(), xs.tolist()):
            all_hits.append((x + w // 2, y + h // 2, float(res[y, x]), w, h))
    all_hits.sort(key=lambda m: -m[2])
    kept = []
    for cx, cy, sc, w, h in all_hits:
        if any(abs(cx - kx) < max(w, kw) // 2 and abs(cy - ky) < max(h, kh) // 2
               for kx, ky, _, kw, kh in kept):
            continue
        kept.append((cx, cy, sc, w, h))
    return [(cx, cy, sc) for cx, cy, sc, _, _ in kept]


def px_to_game(px, py, cam_x, cam_y, cal):
    mr = cal.map_region
    cx = mr["width"] // 2
    cy = mr["height"] // 2
    ppt_x = mr["width"] / cal.tiles_visible_x
    ppt_y = mr["height"] / cal.tiles_visible_y
    return cam_x + (px - cx) / ppt_x, cam_y + (py - cy) / ppt_y


# ====== LOG ======
def log_hit(boss, cam_x, cam_y, px, py, gx, gy, score):
    new = not LOG_FILE.exists()
    with LOG_FILE.open("a", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        if new:
            w.writerow(["timestamp", "boss", "cam_x", "cam_y",
                        "px", "py", "game_x", "game_y", "score"])
        w.writerow([datetime.now().isoformat(timespec="seconds"),
                    boss, f"{float(cam_x):.1f}", f"{float(cam_y):.1f}", px, py,
                    f"{gx:.1f}", f"{gy:.1f}", f"{score:.3f}"])


# ====== SAFETY ======
class FreezeDetector:
    """Detecta freeze de BlueStacks: si N screencaps consecutivos tienen casi
    el mismo tamano en bytes, el render no avanza."""
    def __init__(self):
        self.last_size = 0
        self.similar = 0

    def update(self, raw_bytes):
        size = len(raw_bytes)
        diff = abs(size - self.last_size)
        if diff < FREEZE_BYTES_DELTA and self.last_size > 0:
            self.similar += 1
        else:
            self.similar = 0
        self.last_size = size
        return self.similar >= FREEZE_SIMILAR_FRAMES


def detect_captcha(full_screen):
    """Si existe captcha.png en HERE, busca match. True si encontrado."""
    if not CAPTCHA_TEMPLATE.exists() or full_screen is None:
        return False
    tpl = cv2.imread(str(CAPTCHA_TEMPLATE), cv2.IMREAD_COLOR)
    if tpl is None or tpl.shape[0] >= full_screen.shape[0] or tpl.shape[1] >= full_screen.shape[1]:
        return False
    res = cv2.matchTemplate(full_screen, tpl, cv2.TM_CCOEFF_NORMED)
    _, max_val, _, _ = cv2.minMaxLoc(res)
    return max_val >= CAPTCHA_THRESHOLD


# ====== ESCANEO PAN-DRAG ======
def scan_at(cal, templates, cur_x, cur_y, freeze):
    time.sleep(jitter(SCAN_PAUSE))
    full, raw = adb_screencap(cal.device)
    if full is None:
        raise RuntimeError("screencap fallo")
    if freeze.update(raw):
        raise RuntimeError("BlueStacks parece congelado (filesize sin cambios)")
    if detect_captcha(full):
        raise RuntimeError("CAPTCHA detectado - abortando")
    mr = cal.map_region
    crop = full[mr["top"]:mr["top"] + mr["height"], mr["left"]:mr["left"] + mr["width"]]
    hits = 0
    for name, tpl in templates.items():
        for px, py, sc in find_matches(crop, tpl):
            gx, gy = px_to_game(px, py, cur_x, cur_y, cal)
            log_hit(name, cur_x, cur_y, px, py, gx, gy, sc)
            hits += 1
            print(f"\n    >>> {name} @ game ({gx:.0f},{gy:.0f}) sc={sc:.2f}",
                  end="", flush=True)
    return hits


_debug_idx = [0]


def _match_and_log(crop, templates, cur_x, cur_y, cal):
    """Matchea crop contra todas las plantillas y loguea hits. Devuelve count."""
    hits = 0
    debug_overlay = None
    if DEBUG_CAPTURES:
        DEBUG_DIR.mkdir(parents=True, exist_ok=True)
        debug_overlay = crop.copy()
        # tambien matches de baja prob para visualizacion
        for name, tpl in templates.items():
            for px, py, sc in find_matches(crop, tpl, thr=0.45):
                color = (0, 255, 0) if sc >= MATCH_THRESHOLD else (0, 165, 255)
                cv2.circle(debug_overlay, (px, py), 50, color, 3)
                cv2.putText(debug_overlay, f"{name[:4]}:{sc:.2f}", (px - 40, py - 55),
                            cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2)
        cv2.putText(debug_overlay, f"cam ({cur_x:.0f},{cur_y:.0f})",
                    (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
        _debug_idx[0] += 1
        cv2.imwrite(str(DEBUG_DIR / f"snake_{_debug_idx[0]:03d}.png"), debug_overlay)

    for name, tpl in templates.items():
        for px, py, sc in find_matches(crop, tpl):
            gx, gy = px_to_game(px, py, cur_x, cur_y, cal)
            log_hit(name, cur_x, cur_y, px, py, gx, gy, sc)
            hits += 1
            print(f"\n    >>> {name} @ game ({gx:.0f},{gy:.0f}) sc={sc:.2f}",
                  end="", flush=True)
    return hits


def _crop_map(full, cal):
    mr = cal.map_region
    return full[mr["top"]:mr["top"] + mr["height"], mr["left"]:mr["left"] + mr["width"]]


def scan_pan_area(cal, templates):
    """Snake-scan PURO con pipeline async: drag y match en paralelo.

    Para cada iteracion:
      - Lanza el siguiente drag en un thread de fondo
      - Mientras tanto matchea la captura anterior en el thread principal
      - Espera al drag (suele terminar antes que el match)
      - Hace screencap (sequential, ~600ms)
    """
    freeze = FreezeDetector()
    x0 = cal.scan_center_x - cal.scan_radius
    x1 = cal.scan_center_x + cal.scan_radius
    y0 = cal.scan_center_y - cal.scan_radius   # norte (y menor)
    y1 = cal.scan_center_y + cal.scan_radius   # sur (y mayor)

    print(f"[+] Area x:{x0}..{x1} y:{y0}..{y1}  (serpiente VERTICAL)")
    print(f"[+] Jump inicial a esquina inf-izq ({x0}, {y1})...")
    jump_to(cal, x0, y1)

    cur_x = float(x0)
    cur_y = float(y1)
    dir_v = "N"   # primera columna sube (norte)
    hits = 0
    n_drags = 0
    t0 = time.time()

    # Captura inicial
    time.sleep(jitter(SCAN_PAUSE))
    last_full, last_raw = adb_screencap(cal.device)
    if freeze.update(last_raw):
        raise RuntimeError("BlueStacks parece congelado")
    if detect_captcha(last_full):
        raise RuntimeError("CAPTCHA detectado")
    last_x, last_y = cur_x, cur_y

    def _decide_next():
        nonlocal dir_v
        if dir_v == "N" and cur_y > y0:
            return "N"
        if dir_v == "S" and cur_y < y1:
            return "S"
        if cur_x >= x1:
            return None
        next_dir = "S" if dir_v == "N" else "N"
        print(f"\n  --- Columna a la derecha (cur_x={cur_x:.1f}), nueva dir {next_dir}")
        dir_v = next_dir
        return "E"

    while True:
        next_dir = _decide_next()
        if next_dir is None:
            # Procesar ultima captura y terminar
            hits += _match_and_log(_crop_map(last_full, cal), templates,
                                   last_x, last_y, cal)
            break

        # Lanzar drag en background
        drag_result = {"dx": 0.0, "dy": 0.0}

        def _do_drag():
            dx, dy = pan_drag(cal, next_dir)
            drag_result["dx"] = dx
            drag_result["dy"] = dy

        drag_th = threading.Thread(target=_do_drag)
        drag_th.start()

        # Matchear captura anterior en paralelo al drag
        hits += _match_and_log(_crop_map(last_full, cal), templates,
                               last_x, last_y, cal)

        # Esperar drag
        drag_th.join()
        n_drags += 1

        # Actualizar posicion segun lo que movio el drag
        if next_dir in ("N", "S"):
            cur_y += drag_result["dy"]   # dy ya viene con signo (N negativo, S positivo)
        elif next_dir in ("E", "W"):
            cur_x += drag_result["dx"]

        # Re-ancla periodica para eliminar drift acumulado
        if ANCHOR_EVERY_N_DRAGS > 0 and n_drags % ANCHOR_EVERY_N_DRAGS == 0:
            ax, ay = int(round(cur_x)), int(round(cur_y))
            print(f"\n  [anchor] jump_to ({ax}, {ay}) tras {n_drags} drags")
            jump_to(cal, ax, ay)
            cur_x, cur_y = float(ax), float(ay)

        # Nueva captura
        time.sleep(jitter(SCAN_PAUSE))
        last_full, last_raw = adb_screencap(cal.device)
        if freeze.update(last_raw):
            raise RuntimeError("BlueStacks parece congelado")
        if detect_captcha(last_full):
            raise RuntimeError("CAPTCHA detectado")
        last_x, last_y = cur_x, cur_y

    elapsed = time.time() - t0
    print(f"\n[+] Snake-scan completo. {n_drags} drags en {elapsed:.1f}s "
          f"({elapsed / max(1, n_drags):.2f}s/drag). Hits: {hits}")
    return hits


# ====== MAIN ======
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--device", default=ADB_DEVICE_DEFAULT,
                    help=f"Device ADB (default {ADB_DEVICE_DEFAULT})")
    ap.add_argument("--check", action="store_true",
                    help="Verifica conexion ADB y guarda una captura de prueba")
    ap.add_argument("--calibrate", action="store_true")
    ap.add_argument("--test-screencap", action="store_true")
    ap.add_argument("--test-jump", nargs=2, type=int, metavar=("X", "Y"))
    ap.add_argument("--test-drag", action="store_true",
                    help="Captura antes/despues de 1 drag E para validar el pan")
    ap.add_argument("--test-drag-s", action="store_true",
                    help="Captura antes/despues de 1 drag S (vertical)")
    ap.add_argument("--pan-once", action="store_true",
                    help="Un barrido completo con pan-drag")
    ap.add_argument("--center", nargs=2, type=int, metavar=("X", "Y"),
                    help="Override scan_center (X Y)")
    ap.add_argument("--radius", type=int,
                    help="Override scan_radius (en tiles)")
    ap.add_argument("--debug-captures", action="store_true",
                    help="Guarda cada captura del snake con overlay de matches a 0.45")
    ap.add_argument("--anchor-every", type=int, default=None,
                    help="Re-jump cada N drags (combate drift, anade ~3-5s por anchor). 0=desactivado.")
    args = ap.parse_args()
    # Globals
    global DEBUG_CAPTURES, ANCHOR_EVERY_N_DRAGS
    DEBUG_CAPTURES = bool(args.debug_captures)
    if args.anchor_every is not None:
        ANCHOR_EVERY_N_DRAGS = args.anchor_every

    if args.check:
        ok, msg = adb_check(args.device)
        print(("[OK] " if ok else "[!] ") + msg)
        if ok:
            img, raw = adb_screencap(args.device)
            if img is not None:
                out = HERE / "adb_check.png"
                cv2.imwrite(str(out), img)
                print(f"[OK] {img.shape[1]}x{img.shape[0]} ({len(raw)} bytes) -> {out.name}")
            else:
                print("[!] Conectado pero screencap fallo")
        return

    if args.calibrate:
        calibrate(args.device)
        return

    cal = load_cal()

    # Override desde CLI si se pasan
    if args.center:
        cal.scan_center_x, cal.scan_center_y = args.center
    if args.radius is not None:
        cal.scan_radius = args.radius

    if args.test_screencap:
        full, raw = adb_screencap(cal.device)
        if full is None:
            print("[!] screencap fallo")
            return
        cv2.imwrite(str(HERE / "test_screencap.png"), full)
        crop, _ = capture_map(cal)
        cv2.imwrite(str(HERE / "test_map_crop.png"), crop)
        print(f"  Full {full.shape[1]}x{full.shape[0]} ({len(raw)} bytes)")
        print(f"  Crop map {crop.shape[1]}x{crop.shape[0]}")
        return

    if args.test_jump:
        x, y = args.test_jump
        print(f"Jump a ({x}, {y})...")
        jump_to(cal, x, y)
        crop, _ = capture_map(cal)
        if crop is not None:
            cv2.imwrite(str(HERE / "test_capture_adb.png"), crop)
            print(f"Captura test_capture_adb.png")
        return

    if args.test_drag:
        print("Test drag E: 1 drag + capturas antes/despues...")
        before, _ = capture_map(cal)
        cv2.imwrite(str(HERE / "test_before_drag.png"), before)
        dx, _ = pan_drag(cal, "E")
        time.sleep(0.4)
        after, _ = capture_map(cal)
        cv2.imwrite(str(HERE / "test_after_drag.png"), after)
        print(f"Drag estimado +{dx:.2f} tiles X")
        print("test_before_drag.png / test_after_drag.png")
        return

    if args.test_drag_s:
        print("Test drag S: 1 drag vertical + capturas antes/despues...")
        before, _ = capture_map(cal)
        cv2.imwrite(str(HERE / "test_before_drag_s.png"), before)
        _, dy = pan_drag(cal, "S")
        time.sleep(0.4)
        after, _ = capture_map(cal)
        cv2.imwrite(str(HERE / "test_after_drag_s.png"), after)
        print(f"Drag S estimado +{dy:.2f} tiles Y (camara va sur)")
        print("test_before_drag_s.png / test_after_drag_s.png")
        return

    tpls = load_templates()
    if not tpls:
        print(f"[!] Sin plantillas en {TEMPLATES_DIR}")
        return

    print(f"[+] Device {cal.device}, plantillas {list(tpls)}")
    print(f"[+] Area ({cal.scan_center_x},{cal.scan_center_y}) radio {cal.scan_radius}")
    print(f"[+] Modo: {'pan-once' if args.pan_once else 'loop'}")
    print(f"[+] BlueStacks puede estar minimizado. Ctrl+C para abortar.")
    time.sleep(2)

    try:
        if args.pan_once:
            scan_pan_area(cal, tpls)
        else:
            start = time.time()
            cycle = 0
            while True:
                cycle += 1
                print(f"\n=== CICLO {cycle} ({datetime.now():%H:%M:%S}) ===")
                scan_pan_area(cal, tpls)
                if (time.time() - start) > MAX_RUNTIME_MIN * 60:
                    break
                pause = jitter(CYCLE_PAUSE)
                print(f"[+] Pausa {pause:.0f}s antes del proximo ciclo")
                time.sleep(pause)
    except KeyboardInterrupt:
        print("\n[+] Detenido (Ctrl+C)")
    except RuntimeError as e:
        print(f"\n[!] {e}")
    print(f"[+] Fin. Log en {LOG_FILE}")


if __name__ == "__main__":
    main()
