"""
evony_boss_scanner.py
---------------------
Escaner de bosses de evento con AUTO-PANEO en rejilla.

ATENCION (Camino C):
- Este script CLICA, ESCRIBE y MUEVE el raton en BlueStacks/Evony.
- Viola los TOS de Evony. NO usar con cuenta principal.
- El raton se mueve solo: NO USES el ordenador mientras corre.
- FAILSAFE: mueve el cursor a la esquina superior IZQUIERDA para abortar.
- Auto-stop tras 30 minutos.

Uso:
    python3 evony_boss_scanner.py --calibrate         # primera vez (interactivo)
    python3 evony_boss_scanner.py --test-jump 458 567 # prueba navegacion
    python3 evony_boss_scanner.py --once              # un solo barrido
    python3 evony_boss_scanner.py                     # bucle indefinido

Requisitos:
    pip install mss opencv-python numpy pillow pyautogui
    Mac: System Settings > Privacy & Security > Accessibility -> anadir Terminal/Python
    BlueStacks: en Settings > Preferences activar "Android Keyboard input"
"""

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

import cv2
import mss
import numpy as np

try:
    import pyautogui
    pyautogui.FAILSAFE = True
    pyautogui.PAUSE = 0.08
except ImportError:
    print("Falta pyautogui: pip install pyautogui")
    sys.exit(1)


# ====== RUTAS ======
HERE = Path(__file__).parent
TEMPLATES_DIR = HERE / "templates_bosses"
LOG_FILE = HERE / "boss_log.csv"
CONFIG_FILE = HERE / "scanner_config.json"

# ====== PARAMETROS ======
MATCH_THRESHOLD = 0.72
MATCH_SCALES = [0.15, 0.2, 0.25, 0.3, 0.4, 0.5, 0.65, 0.85, 1.0]
JUMP_DELAY = (1.8, 2.6)        # tras navegar a una coord
ACTION_DELAY = (0.2, 0.5)      # entre clicks
SCAN_PAUSE = (0.4, 0.8)        # antes de capturar
CYCLE_PAUSE = (60, 180)        # entre ciclos completos
MAX_RUNTIME_MIN = 30           # auto-stop

# ====== PARAMETROS PAN (modo drag) ======
PAN_DURATION = 0.50            # duracion de cada drag
PAN_FRAC_X = 1.0               # fraccion del map_region.width que cubre un drag
PAN_FRAC_Y = 1.0               # fraccion del map_region.height
PAN_MARGIN = 15                # px de margen interno para evitar bordes
ROW_STRIDE_FRAC = 0.80         # solape vertical entre filas
PAN_SETTLE = (0.45, 0.85)      # pausa tras drag para que la camara asiente


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


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


def human_click(x, y):
    """Click humanizado: movimiento con duracion variable + jitter de pos."""
    dur = random.uniform(0.15, 0.35)
    px = x + random.randint(-3, 3)
    py = y + random.randint(-3, 3)
    pyautogui.moveTo(px, py, duration=dur)
    time.sleep(jitter((0.05, 0.15)))
    pyautogui.click()
    time.sleep(jitter(ACTION_DELAY))


def clear_and_type(x, y, value):
    """Click en un campo, limpia contenido existente y escribe value."""
    pyautogui.moveTo(x, y, duration=random.uniform(0.1, 0.25))
    time.sleep(0.12)
    pyautogui.click()
    time.sleep(0.25)
    # Cursor al final (si end no pasa, no pasa nada), luego backspace varias veces
    pyautogui.press("end")
    time.sleep(0.08)
    for _ in range(10):
        pyautogui.press("backspace")
        time.sleep(0.05)
    time.sleep(0.1)
    for ch in str(value):
        pyautogui.write(ch)
        time.sleep(random.uniform(0.05, 0.12))


# ====== CALIBRACION ======
def calibrate():
    print("\n=== CALIBRACION INTERACTIVA (modo cuenta atras) ===\n")
    print("Antes de empezar:")
    print("  - Abre Evony en BlueStacks, ve al mapa del REINO (no ciudad)")
    print("  - Pon BlueStacks en una posicion FIJA (no la muevas mas)")
    print("  - Coloca Terminal y BlueStacks LADO A LADO (ambos visibles)")
    print("  - Manten BlueStacks AL FRENTE durante toda la calibracion")
    print("  - Si el cursor se atasca, mueve a la esquina sup-izq para abortar\n")
    input("Pulsa Enter cuando estes listo... ")

    print("\n  >> Pasa AHORA a BlueStacks (Cmd+Tab o click).")
    print("     A partir de aqui NO pulses Enter: solo mueve el cursor.")
    print("     Cada paso te da 10s para hover sobre el elemento pedido.\n")
    time.sleep(3)

    def get_pos(label, secs=10):
        print(f"\n  >> {label}")
        for i in range(secs, 0, -1):
            sys.stdout.write(f"\r     Capturando en {i}s... (manten cursor sobre el elemento)")
            sys.stdout.flush()
            time.sleep(1)
        p = pyautogui.position()
        print(f"\r     Capturado: ({p.x}, {p.y})                                     ")
        return (p.x, p.y)

    def wait_secs(secs, label):
        print(f"\n  >> {label}")
        for i in range(secs, 0, -1):
            sys.stdout.write(f"\r     Continuando en {i}s...")
            sys.stdout.flush()
            time.sleep(1)
        print("\r     Continuando...                          ")

    search_btn = get_pos("Hover sobre el icono/LUPA que abre el buscador de coordenadas")

    print("\n  >> Voy a clicar ahi para abrir el dialogo en 2s. NO toques el raton.")
    time.sleep(2)
    human_click(*search_btn)
    time.sleep(1.8)

    x_field = get_pos("Hover sobre el campo INPUT de coordenada X (en el dialogo)")
    y_field = get_pos("Hover sobre el campo INPUT de coordenada Y")
    go_btn = get_pos("Hover sobre el boton GO/IR/BUSCAR del dialogo")

    wait_secs(8, "Cierra el dialogo de busqueda MANUALMENTE (X o Esc). 8s.")

    print("\n  >> Ahora la REGION DEL MAPA visible (sin barra superior, sin botones laterales):")
    tl = get_pos("Hover sobre la esquina superior IZQUIERDA del mapa visible")
    br = get_pos("Hover sobre la esquina inferior DERECHA del mapa visible")
    map_region = {
        "top": tl[1],
        "left": tl[0],
        "width": br[0] - tl[0],
        "height": br[1] - tl[1],
    }
    print(f"     Region: {map_region}")

    print("\n  >> Vuelve a Terminal (Cmd+Tab) para escribir numeros.")
    print("     Cuantos TILES caben aproximadamente en pantalla?")
    print("     (Cada cuadricula del mapa = 1 tile. Cuenta a ojo.)")
    tx = int(input("     Tiles horizontales [40]: ") or "40")
    ty = int(input("     Tiles verticales [22]: ") or "22")

    print("\n  >> Area a escanear:")
    cx = int(input("     Centro X [458]: ") or "458")
    cy = int(input("     Centro Y [567]: ") or "567")
    rad = int(input("     Radio en tiles [50]: ") or "50")

    cal = Calibration(
        search_btn=search_btn, x_field=x_field, y_field=y_field, go_btn=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"[!] No hay config. Ejecuta primero: python3 {Path(__file__).name} --calibrate")
        sys.exit(1)
    d = json.loads(CONFIG_FILE.read_text())
    for k in ("search_btn", "x_field", "y_field", "go_btn"):
        d[k] = tuple(d[k])
    return Calibration(**d)


# ====== NAVEGACION EN EL JUEGO ======
def jump_to(cal, x, y):
    """Salta la camara a la coord (x, y) usando el buscador del juego."""
    human_click(*cal.search_btn)
    time.sleep(jitter((0.8, 1.3)))
    clear_and_type(*cal.x_field, x)
    time.sleep(jitter(ACTION_DELAY))
    clear_and_type(*cal.y_field, y)
    time.sleep(jitter(ACTION_DELAY))
    human_click(*cal.go_btn)
    time.sleep(jitter(JUMP_DELAY))


# ====== CAPTURA Y MATCHING ======
def capture(region):
    with mss.mss() as sct:
        shot = sct.grab(region)
        return cv2.cvtColor(np.array(shot), cv2.COLOR_BGRA2BGR)


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):
    """Match multi-escala. Devuelve (px_center, py_center, score) por hit deduplicado."""
    h0, w0 = template.shape[:2]
    sh, sw = screen.shape[:2]
    all_hits = []  # (cx, cy, score, w, h)
    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))
    # Dedupe: ordenar por score descendente, descartar matches solapados con uno mejor ya aceptado
    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):
    """Convierte pixel dentro de la region de mapa a coord de juego."""
    cx = cal.map_region["width"] // 2
    cy = cal.map_region["height"] // 2
    ppt_x = cal.map_region["width"] / cal.tiles_visible_x
    ppt_y = cal.map_region["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, cam_x, cam_y, px, py,
            f"{gx:.1f}", f"{gy:.1f}", f"{score:.3f}",
        ])


# ====== GRID ======
def build_grid(cal):
    """Lista de (cam_x, cam_y) en orden serpiente para cubrir el area."""
    step_x = max(1, int(cal.tiles_visible_x * 0.85))
    step_y = max(1, int(cal.tiles_visible_y * 0.85))
    cells = []
    x0 = cal.scan_center_x - cal.scan_radius
    x1 = cal.scan_center_x + cal.scan_radius
    y0 = cal.scan_center_y - cal.scan_radius
    y1 = cal.scan_center_y + cal.scan_radius
    y = y0
    row_idx = 0
    while y <= y1:
        row = []
        x = x0
        while x <= x1:
            row.append((max(0, x), max(0, y)))
            x += step_x
        if row_idx % 2 == 1:
            row.reverse()
        cells.extend(row)
        y += step_y
        row_idx += 1
    return cells


def scan_grid(cal, templates):
    cells = build_grid(cal)
    print(f"[+] {len(cells)} celdas en la rejilla")
    hits = 0
    for i, (cx, cy) in enumerate(cells, 1):
        print(f"  [{i:3d}/{len(cells)}] cam -> X:{cx} Y:{cy}", end="", flush=True)
        try:
            jump_to(cal, cx, cy)
            time.sleep(jitter(SCAN_PAUSE))
            screen = capture(cal.map_region)
        except pyautogui.FailSafeException:
            raise
        except Exception as e:
            print(f"  [error: {e}]")
            continue

        cell_hits = 0
        for name, tpl in templates.items():
            for px, py, score in find_matches(screen, tpl):
                gx, gy = px_to_game(px, py, cx, cy, cal)
                log_hit(name, cx, cy, px, py, gx, gy, score)
                cell_hits += 1
                hits += 1
                print(f"\n    >>> {name} @ game ({gx:.0f},{gy:.0f}) score={score:.2f}", end="")
        print(" ok" if cell_hits == 0 else "")
    print(f"[+] Barrido completo. Total hits: {hits}")
    return hits


# ====== MODO PAN (drag) ======
def pan_drag(cal, direction):
    """Click-and-drag dentro del map_region para mover camara.
    direction in {'E','W','S','N'}. Devuelve (dx_tiles, dy_tiles) en coords de juego.
    """
    mr = cal.map_region
    margin = PAN_MARGIN
    L = mr["left"] + margin
    R = mr["left"] + mr["width"] - margin
    T = mr["top"] + margin
    B = mr["top"] + mr["height"] - margin
    MX = (L + R) // 2
    MY = (T + B) // 2
    span_x = R - L
    span_y = B - T
    px_x = int(span_x * PAN_FRAC_X)
    px_y = int(span_y * PAN_FRAC_Y)

    ppt_x = mr["width"] / cal.tiles_visible_x
    ppt_y = mr["height"] / cal.tiles_visible_y

    if direction == "E":
        sx, sy = R, MY; ex, ey = R - px_x, MY
        dx_t, dy_t = +px_x / ppt_x, 0
    elif direction == "W":
        sx, sy = L, MY; ex, ey = L + px_x, MY
        dx_t, dy_t = -px_x / ppt_x, 0
    elif direction == "S":
        sx, sy = MX, B; ex, ey = MX, B - px_y
        dx_t, dy_t = 0, +px_y / ppt_y
    elif direction == "N":
        sx, sy = MX, T; ex, ey = MX, T + px_y
        dx_t, dy_t = 0, -px_y / ppt_y
    else:
        raise ValueError(direction)

    sx += random.randint(-3, 3); sy += random.randint(-3, 3)
    ex += random.randint(-3, 3); ey += random.randint(-3, 3)

    pyautogui.moveTo(sx, sy, duration=0.18)
    time.sleep(0.08)
    pyautogui.mouseDown()
    time.sleep(0.12)  # asegura registro como drag, no tap
    pyautogui.moveTo(ex, ey, duration=PAN_DURATION)
    time.sleep(0.18)
    pyautogui.mouseUp()
    time.sleep(jitter(PAN_SETTLE))
    return dx_t, dy_t


def scan_at(cal, templates, cur_x, cur_y):
    """Captura el viewport y matchea. Devuelve numero de hits."""
    time.sleep(jitter(SCAN_PAUSE))
    screen = capture(cal.map_region)
    hits = 0
    for name, tpl in templates.items():
        for px, py, sc in find_matches(screen, 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 scan_pan_row(cal, templates, start_x, y, end_x):
    """Escanea una fila: jump al inicio, luego drags E hasta cubrir end_x."""
    print(f"\n[+] Fila y={y}: x={start_x}..{end_x}")
    jump_to(cal, start_x, y)
    cur_x, cur_y = start_x, y
    hits = scan_at(cal, templates, cur_x, cur_y)
    safety = 0
    max_drags = int(2 * (end_x - start_x) / max(1, cal.tiles_visible_x * PAN_FRAC_X)) + 2
    while cur_x < end_x and safety < max_drags:
        dx, _ = pan_drag(cal, "E")
        cur_x += dx
        hits += scan_at(cal, templates, cur_x, cur_y)
        safety += 1
    return hits


def scan_pan_area(cal, templates):
    """Escaneo serpiente: una fila a la vez, jump por fila + drags E dentro."""
    x0 = cal.scan_center_x - cal.scan_radius
    x1 = cal.scan_center_x + cal.scan_radius
    y0 = cal.scan_center_y - cal.scan_radius
    y1 = cal.scan_center_y + cal.scan_radius
    row_stride = max(1, int(cal.tiles_visible_y * ROW_STRIDE_FRAC))
    print(f"[+] Area x:{x0}..{x1} y:{y0}..{y1} (stride filas {row_stride}t)")
    hits = 0
    y = y0
    while y <= y1:
        hits += scan_pan_row(cal, templates, x0, y, x1)
        y += row_stride
    print(f"\n[+] Pan-scan completo. Hits totales: {hits}")
    return hits


# ====== MAIN ======
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--calibrate", action="store_true")
    ap.add_argument("--once", action="store_true", help="Un barrido con jump-grid (lento, preciso)")
    ap.add_argument("--pan-once", action="store_true", help="Un barrido con pan-drag (rapido)")
    ap.add_argument("--test-jump", nargs=2, type=int, metavar=("X", "Y"))
    ap.add_argument("--test-drag", action="store_true", help="Drag horizontal de prueba (1 drag E + captura)")
    args = ap.parse_args()

    if args.calibrate:
        calibrate()
        return

    cal = load_cal()

    if args.test_jump:
        print(f"Test: salto a X:{args.test_jump[0]} Y:{args.test_jump[1]} en 3s...")
        time.sleep(3)
        jump_to(cal, *args.test_jump)
        cv2.imwrite(str(HERE / "test_capture.png"), capture(cal.map_region))
        print(f"Captura guardada en test_capture.png")
        return

    if args.test_drag:
        print("Test drag: en 3s hace 1 drag E + captura. NO TOQUES EL RATON.")
        time.sleep(3)
        cv2.imwrite(str(HERE / "test_before_drag.png"), capture(cal.map_region))
        dx, _ = pan_drag(cal, "E")
        cv2.imwrite(str(HERE / "test_after_drag.png"), capture(cal.map_region))
        print(f"Capturas: test_before_drag.png / test_after_drag.png")
        print(f"Drag movio camara estimadamente +{dx:.2f} tiles en X")
        return

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

    print(f"[+] Plantillas: {list(tpls)}")
    print(f"[+] Area: X:{cal.scan_center_x} Y:{cal.scan_center_y} radio {cal.scan_radius}")
    print(f"[+] Modo: {'pan-once' if args.pan_once else ('jump-once' if args.once else 'jump-loop')}")
    print(f"[+] Aborta: cursor a esquina superior izquierda")
    print(f"[+] Empezando en 5s... NO TOQUES EL RATON")
    time.sleep(5)

    try:
        if args.pan_once:
            scan_pan_area(cal, tpls)
        elif args.once:
            print(f"\n=== CICLO 1 ({datetime.now():%H:%M:%S}) ===")
            scan_grid(cal, tpls)
        else:
            start = time.time()
            cycle = 0
            while True:
                cycle += 1
                print(f"\n=== CICLO {cycle} ({datetime.now():%H:%M:%S}) ===")
                scan_grid(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 pyautogui.FailSafeException:
        print("\n[!] FAILSAFE: cursor a esquina. Stop.")
    print(f"[+] Fin. Log en {LOG_FILE}")


if __name__ == "__main__":
    main()
