#!/usr/bin/env python3
"""
ALMACÉN DEL ESCÁNER — app híbrida (una cuenta, un cliente).

El módulo escáner del agente emite los mismos "kinds" que el escáner de producción
(batch/config/wcfg). Aquí se acumulan y se sirven en el MISMO formato que devolvía
`/api/data` del :8773, para que todo el bot (presets, auto-farm, rally, auto-join)
siga funcionando sin tocar su lógica: sólo cambia de dónde vienen los datos.

Origen del código de familias: evony-scout-pixel-v5/iscout_web.py (líneas 2670-2774).
⚠️ FAMILIES/_id2fam guardan el grupo en MINÚSCULA y group_of() devuelve Mayúscula.
   Ya hubo un bug por mezclarlos: comparar siempre en el mismo caso.
"""
import threading, time, json

# ── Ventanas de frescura (las de producción) ─────────────────────────────────
ONMAP_FARM  = 300     # 5 min: monstruos roaming, se mueven
ONMAP_RALLY = 2700    # 45 min: boss/event son estacionarios
RALLY_GROUPS_ONMAP = ("boss", "event")

FAMILIES = [
    # ── 1) SHADOW OF DAWN (primero: captura "(Shadow of Dawn) X" antes que la criatura base) ──
    ("Shadow of Dawn",  ["shadow of dawn"],      "shadow"),
    ("Dawn Sanctuary",  ["dawn sanctuary"],      "shadow"),
    ("Dawn Temple",     ["dawn temple"],         "shadow"),
    ("Eclipse Stronghold", ["eclipse stronghold"], "shadow"),
    # ── 2) Excepciones de event que contienen keyword de boss (antes que el boss) ──
    ("Flame Cerberus",  ["flame cerberus"],      "event"),   # event, NO boss (pese a "cerberus")
    # ── 3) BOSS — monstruos permanentes del mapa ──
    ("Cerberus",        ["cerberus"],            "boss"),
    ("Knight Bayard",   ["bayard", "bayar knight"], "boss"),
    ("Harpy",           ["harpy"],               "boss"),
    ("Peryton",         ["peryton"],             "boss"),
    ("Minotaur",        ["minotaur"],            "boss"),
    ("Griffin",         ["griffin"],             "boss"),
    ("Ifrit",           ["ifrit"],               "boss"),
    ("Kamaitachi",      ["kamaitachi"],          "boss"),
    ("Fafnir",          ["fafnir"],              "boss"),
    ("Behemoth",        ["behemoth"],            "boss"),
    ("Phoenix",         ["phoenix"],             "boss"),
    ("Jormungandr",     ["jormungandr"],         "boss"),
    ("Typhon",          ["typhon"],              "boss"),
    ("Ammit",           ["ammit"],               "boss"),
    ("Centaur",         ["centaur"],             "boss"),
    ("Werewolf",        ["werewolf"],            "boss"),
    ("Yasha",           ["yasha"],               "boss"),
    ("Redcap",          ["redcap"],              "boss"),
    ("Skeleton Dragon", ["skeleton dragon"],     "boss"),
    ("Manticore",       ["manticore"],           "boss"),
    ("Zombie",          ["zombie"],              "boss"),
    ("Airavata",        ["airavata"],            "boss"),
    ("Ghidorah",        ["ghidorah"],            "boss"),
    ("Nine-tails",      ["nine-tails", "nine tails"], "boss"),
    ("Stymphalian Bird",["stymphalian"],         "boss"),
    ("Kraken",          ["kraken"],              "boss"),
    ("Azazel",          ["azazel"],              "boss"),
    ("Leviathan",       ["leviathan"],           "boss"),
    ("Garmr",           ["garmr"],               "boss"),
    # ── 4) EVENT — monstruos de eventos temporales ──
    # Barbary Pirate: dos monstruos distintos -> dos pills (Normal 24.6M / Elite 130M)
    ("Normal Barbary Pirate", ["normal barbary pirate"], "event"),
    ("Elite Barbary Pirate",  ["elite barbary pirate"],  "event"),
    ("Hydra",           ["hydra"],               "event"),
    ("Warlord",         ["warlord"],             "event"),
    ("Ymir",            ["ymir"],                "event"),
    ("Pan",             ["pan "],                "event"),
    ("Lava Turtle",     ["lava turtle"],         "event"),
    ("Sphinx",          ["sphinx"],              "event"),   # cubre Sphinx y Sphinx Castle
    ("Witch",           ["witch"],               "event"),   # ojo: Mysterious Witch va a Other (antes)
    ("Golem",           ["golem"],               "event"),
    ("Mire Squid",      ["mire squid"],          "event"),
    ("Pumpkin",         ["pumpkin"],             "event"),
    ("Viking",          ["viking"],              "event"),
    ("Golden Goblin",   ["golden goblin"],       "event"),
    ("Arctic Barbarian",["arctic barbarian"],    "event"),
    ("Aglaope",         ["aglaope"],             "event"),
    ("Nasu",            ["nasu"],                "event"),
    ("Gugler Knight",   ["gugler"],              "event"),
    ("Carcinus",        ["carcinus"],            "event"),
    ("Serpopard",       ["serpopard"],           "event"),
    ("Savage Tiger Gladiator", ["savage tiger"], "event"),
    ("Taotie",          ["taotie"],              "event"),
    ("Nian",            ["nian"],                "event"),
    ("Cranky Snowman",  ["cranky snowman", "snowman"], "event"),
    ("Surtr",           ["surtr"],               "event"),
    ("Angry Turkey",    ["angry turkey", "turkey"], "event"),
    ("Fortress Pyramid",["fortress pyramid"],    "event"),
    # ── 5) OTHER — festivos / decorativos ──
    ("Easter Bunny",    ["easter bunny", "bunny"], "other"),
    ("Santa",           ["santa"],               "other"),
    ("Mysterious Witch",["mysterious witch"],    "other"),  # antes que Witch→event (más específico)
    ("Ferris Wheel",    ["ferris wheel"],        "other"),
    ("Celebration Squad",["celebration squad"],  "other"),
    ("Ares Statue",     ["ares statue"],         "other"),
    ("Fire Spirit",     ["fire spirit"],         "other"),
    # ── 6) wildcard genérico de bosses prefijados "(Boss)" (último) ──
    ("Bosses (Boss)",   ["(boss)"],              "boss"),
]
# Orden de precedencia: "Mysterious Witch" debe ir ANTES de "Witch" (event) para no
# clasificarse como event. Lo reordenamos: las entradas Other con substring que colisiona
# con event se evalúan por su posición — movemos Mysterious Witch arriba programáticamente.
_FAM_OTHER_FIRST = [f for f in FAMILIES if f[2] == "other" and "witch" in f[1][0]]
FAMILIES = _FAM_OTHER_FIRST + [f for f in FAMILIES if f not in _FAM_OTHER_FIRST]

def fam_match(name, kws):
    n = (name or "").lower()
    return any(k in n for k in kws)

_GROUP_LABEL = {"boss": "Boss", "event": "Event", "shadow": "Shadow of Dawn", "other": "Other"}
_GROUP_CACHE = {}   # name -> label (memo: los nombres se repiten miles de veces)
def group_of(name):
    """Clasifica un monstruo: Boss | Event | Shadow of Dawn | Other | Normal."""
    cached = _GROUP_CACHE.get(name)
    if cached is not None:
        return cached
    n = (name or "").lower()
    res = "Normal"
    for disp, kws, grp in FAMILIES:
        if any(k in n for k in kws):
            res = _GROUP_LABEL.get(grp, "Normal"); break
    else:
        if "(boss)" in n:
            res = "Boss"
    _GROUP_CACHE[name] = res
    return res


# ============================================================================
# ALMACÉN DE OBJETOS DEL MAPA
# ============================================================================
_LOCK = threading.RLock()

OBJS = {}      # (x,y) -> {"t","id","lv","x","y","ts"}   ts = última vez visto
CFG  = {}      # id -> {"name","level","type","power"}   (MonsterConfig del juego)
WCFG = {}      # id -> {"name","level","type"}           (WorldResourceConfig: farms/minas)

STATE = {
    "objs": 0, "batches": 0, "cfg_n": 0, "wcfg_n": 0,
    "last_batch": 0, "server": 0,
    "sweep": {}, "mt": {},          # telemetría del agente (barrido y salud del cliente)
    "scan_on": True,
}

MONSTER_T  = 2      # tipo de mapinfo que son monstruos (verificado en vivo 2026-08-13)
# Etiquetas del resto de tipos de tile. El bot sólo roba objetivos cuyo "tgroup" está en
# {Boss, Event, Normal, Shadow, Other}, así que estas etiquetas los excluyen por sí solas.
T_LABEL = {1: "castle", 3: "farm", 4: "garrison", 5: "ruins", 6: "king_city",
           7: "sub_city", 8: "snowberg", 9: "guild_city", 10: "guild_store",
           11: "guild_farm", 12: "boss_tile"}
RESOURCE_T = 3      # tiles de recurso (farms/minas)


def ingest_batch(items, server=0):
    """Lote de objetos del mapa que manda el agente cada 1,5 s."""
    now = int(time.time())
    with _LOCK:
        if server:
            STATE["server"] = int(server)
        for it in items or []:
            try:
                x = int(it.get("wx", 0) or 0); y = int(it.get("wy", 0) or 0)
                if not x or not y:
                    continue
                OBJS[(x, y)] = {"t": int(it.get("t", 0) or 0), "id": int(it.get("id", 0) or 0),
                                "lv": int(it.get("lv", 0) or 0), "x": x, "y": y, "ts": now}
            except Exception:
                continue
        STATE["objs"] = len(OBJS)
        STATE["batches"] += 1
        STATE["last_batch"] = now


def ingest_config(cfg):
    with _LOCK:
        for k, v in (cfg or {}).items():
            CFG[str(k)] = v
        STATE["cfg_n"] = len(CFG)


def ingest_wcfg(wcfg):
    with _LOCK:
        for k, v in (wcfg or {}).items():
            WCFG[str(k)] = v
        STATE["wcfg_n"] = len(WCFG)


def ingest_metrics(p):
    with _LOCK:
        STATE["sweep"] = p.get("sweep", {}) or {}
        STATE["mt"] = p.get("mt", {}) or {}
        STATE["scan_on"] = bool(p.get("on", True))
        if p.get("server"):
            STATE["server"] = int(p["server"])


def name_of(oid, t=MONSTER_T):
    """Nombre legible del objeto. Sin config del juego devolvemos 'id<N>', que es
    exactamente lo que el bot ya sabe descartar (no es un monstruo nombrable)."""
    c = CFG.get(str(oid)) if t != RESOURCE_T else WCFG.get(str(oid))
    if c and c.get("name") and c["name"] != "?":
        return c["name"]
    return "id%d" % int(oid or 0)


def map_rows(monsters_only=True):
    """Filas en el MISMO formato que devolvía /api/data del escáner externo:
    {x, y, name, level, group, id, power, seen_age}.

    seen_age = segundos desde la última vez que el barrido vio el tile. El bot lo usa
    para no marchar a fantasmas: filtra con ventanas distintas según el grupo (los
    boss/event son estacionarios y toleran 45 min; los roaming, 5 min).
    Devolvemos TODO el histórico on-map y que filtre el bot, igual que hacía el V4.
    """
    now = int(time.time())
    out = []
    with _LOCK:
        for (x, y), o in OBJS.items():
            t = o["t"]
            if monsters_only and t != MONSTER_T:
                continue
            age = now - o["ts"]
            if age > ONMAP_RALLY:          # más viejo que la ventana más laxa: ya no sirve
                continue
            nm = name_of(o["id"], t)
            c = CFG.get(str(o["id"])) or {}
            lv = o["lv"] or int(c.get("level", 0) or 0)      # nivel del CFG si el mapa no lo trae
            out.append({
                "x": x, "y": y, "name": nm, "level": lv,
                "group": group_of(nm), "id": o["id"],
                "power": float(c.get("power", 0) or 0),
                "seen_age": age,
            })
    return out


def prune(max_age=None):
    """Descarta lo que lleva demasiado tiempo sin verse (evita crecer sin límite)."""
    cutoff = int(time.time()) - int(max_age or ONMAP_RALLY * 2)
    with _LOCK:
        dead = [k for k, o in OBJS.items() if o["ts"] < cutoff]
        for k in dead:
            del OBJS[k]
        for xy in [k for k, ts in _UNKNOWN.items() if ts and time.time() - ts > UNKNOWN_RETRY * 4]:
            _UNKNOWN.pop(xy, None)      # ya resuelto o irrelevante
        STATE["objs"] = len(OBJS)
    return len(dead)


def status():
    with _LOCK:
        return {
            "objs": len(OBJS), "batches": STATE["batches"],
            "cfg": STATE["cfg_n"], "wcfg": STATE["wcfg_n"],
            "server": STATE["server"], "last_batch": STATE["last_batch"],
            "scan_on": STATE["scan_on"],
            "sweep": dict(STATE["sweep"]), "mt": dict(STATE["mt"]),
        }


# ============================================================================
# MARCHAS EN VUELO -> ATAQUES ACTIVOS
# ============================================================================
# El reply de worldmap trae las marchas (map_target_info). Agrupadas por objetivo y
# cruzadas con los objetos del mapa dan la vista de "ataques activos", que además es
# lo que alimenta el motor de STEAL del bot (robar el kill a otro jugador).
MARCHES = {}   # troop_id -> marcha + ts_recv
PLAYERS = {}   # uid -> {name, tag, gid}

ATTACK_MTYS = {2, 10, 19, 20, 21, 43}   # 2=monstruo, 10=ataque a jugador, 19/20/21=fases de rally, 43=scout
ALLY_MTYS   = {19, 20, 21}
PHASE_NAME  = {2: "march", 10: "way", 19: "wait", 20: "way", 21: "combat", 43: "scout"}

# Una marcha activa debería refrescarse cada vuelta del barrido. Si lleva demasiado sin
# actualizarse, lo más probable es que el rally se cancelara o ya aterrizara y el
# servidor dejó de anunciarla.
MARCH_STALE = 120
# Seguimos mostrando una marcha unos segundos DESPUÉS de aterrizar: una marcha de 2 s
# aterriza entre dos lecturas y, sin esto, no se vería nunca.
LANDED_GRACE = 12


# Tiles a los que alguien marcha pero que el barrido aún no ha visto. Se piden con un
# scan DIRIGIDO: sin el nombre del monstruo la fila no pasa el filtro de grupo y Rally
# Steals se los pierde (visto con el Ymir @867,627 que DaSea sí tenía y nosotros no).
_UNKNOWN = {}          # (x,y) -> ts de la última petición
UNKNOWN_RETRY = 120    # no volver a pedir el mismo tile antes de esto


def take_unknown_tiles(max_n=3):
    """Devuelve hasta max_n tiles pendientes de resolver y los marca como pedidos.
    El tope es a propósito: los scans dirigidos viajan por el MISMO canal que las
    marchas, así que pedir muchos de golpe reproduce la saturación que nos costó que
    no saliera ninguna marcha."""
    now = time.time()
    out = []
    with _LOCK:
        for xy, ts in sorted(_UNKNOWN.items(), key=lambda kv: kv[1]):
            if ts and now - ts < UNKNOWN_RETRY:
                continue
            _UNKNOWN[xy] = now
            out.append(xy)
            if len(out) >= max_n:
                break
    return out


def ingest_marches(items):
    now = int(time.time())
    with _LOCK:
        for m in items or []:
            try:
                trp = int(m.get("trp", 0) or 0)
                if not trp:
                    continue
                m = dict(m); m["ts_recv"] = now
                MARCHES[trp] = m
                # destino desconocido -> apuntarlo para pedir un scan dirigido
                tx, ty = int(m.get("tx", 0) or 0), int(m.get("ty", 0) or 0)
                if tx and ty and (tx, ty) not in OBJS and (tx, ty) not in _UNKNOWN:
                    _UNKNOWN[(tx, ty)] = 0.0        # 0 = nunca pedido -> sale en la próxima tanda
            except Exception:
                continue
        # purga: marchas aterrizadas hace rato o sin refresco
        dead = [k for k, v in MARCHES.items()
                if int(v.get("te", 0) or 0) <= now - LANDED_GRACE and (now - v["ts_recv"]) > MARCH_STALE]
        for k in dead:
            del MARCHES[k]


def ingest_players(items):
    with _LOCK:
        for p in items or []:
            try:
                uid = int(p.get("uid", 0) or 0)
                if not uid:
                    continue
                prev = PLAYERS.get(uid) or {}
                # no pisar un nombre bueno con uno vacío: el reply no siempre trae todo
                PLAYERS[uid] = {"uid": uid,
                                "name": p.get("name") or prev.get("name") or "",
                                "tag": p.get("tag") or prev.get("tag") or "",
                                "gid": int(p.get("gid", 0) or 0) or prev.get("gid", 0),
                                "wx": int(p.get("wx", 0) or 0) or prev.get("wx", 0),
                                "wy": int(p.get("wy", 0) or 0) or prev.get("wy", 0)}
            except Exception:
                continue


def attacks(own_uid=0, own_gid=0, include_helps=False, skip_relocations=True):
    """Ataques activos en el formato que consume el bot.

    Cada fila: mty, tx, ty, uid, tag, name, tg_id, tlevel, tname, tgroup, eta, phase,
    kind, count. Es una versión directa de lo que servía `/api/attacks` del escáner,
    sin sus filtros de relocation/SVS (dependían de su censo completo de jugadores).
    """
    now = int(time.time())
    out = []
    with _LOCK:
        # índice tile -> jugador: identifica de quién es el castillo atacado
        tile_owner = {}
        for p in PLAYERS.values():
            wx, wy = int(p.get("wx", 0) or 0), int(p.get("wy", 0) or 0)
            if wx and wy:
                tile_owner[(wx, wy)] = p
        # agrupar por objetivo: varias marchas al mismo tile son UN ataque con count>1
        by_tgt = {}
        for m in MARCHES.values():
            mty = int(m.get("mty", 0) or 0)
            if mty not in ATTACK_MTYS:
                continue
            te = int(m.get("te", 0) or 0)
            if te <= now - LANDED_GRACE:            # ya aterrizó hace rato
                continue
            if (now - m["ts_recv"]) > MARCH_STALE:  # sin refrescar: probablemente cancelada
                continue
            tx, ty = int(m.get("tx", 0) or 0), int(m.get("ty", 0) or 0)
            if not tx or not ty:
                continue
            by_tgt.setdefault((tx, ty), []).append(m)

        for (tx, ty), ms in by_tgt.items():
            # la marcha que ANTES llega manda: es la que define el eta a batir
            lead = min(ms, key=lambda z: int(z.get("te", 0) or 0))
            uid = int(lead.get("ow", 0) or 0)
            pl = PLAYERS.get(uid) or {}
            gid = int(pl.get("gid", 0) or 0)
            if own_uid and uid == own_uid:
                continue      # mis propias marchas no son ataques ajenos
            owner_here = tile_owner.get((tx, ty))
            if skip_relocations and owner_here and uid and int(owner_here.get("uid", 0) or 0) == uid:
                continue   # RELOCATION: el jugador vuelve/refuerza su propia ciudad, no ataca
            o = OBJS.get((tx, ty)) or {}
            ot = int(o.get("t", 0) or 0)
            oid = int(o.get("id", 0) or 0)
            c = CFG.get(str(oid)) or {}
            tlevel = int(o.get("lv", 0) or 0) or int(c.get("level", 0) or 0)
            if ot == MONSTER_T and oid:
                tname = name_of(oid, ot)
                tgroup = group_of(tname) if not tname.startswith("id") else ""
            else:
                # No es un monstruo: casi siempre un castillo de jugador (ataque PvP).
                # Mostramos el nombre del DUEÑO, que es lo útil, en vez de un id críptico.
                owner = tile_owner.get((tx, ty))
                tname = (owner or {}).get("name", "") if owner else ""
                tgroup = T_LABEL.get(ot, "")
                if not tname and ot:
                    tname = T_LABEL.get(ot, "") or ""

            mty = int(lead.get("mty", 0) or 0)
            if (not include_helps) and own_gid and gid and gid == own_gid and ot != MONSTER_T:
                continue      # refuerzo a un castillo de la propia alianza: no es un ataque
            out.append({
                "mty": mty,
                "tx": tx, "ty": ty,
                "uid": uid,
                "tag": pl.get("tag", ""),
                "name": pl.get("name", ""),
                "tg_id": oid or int(lead.get("tsub", 0) or 0),
                "tlevel": tlevel,
                "tname": tname,
                "tgroup": tgroup,
                "eta": max(0, int(lead.get("te", 0) or 0) - now),
                "phase": PHASE_NAME.get(mty, ""),
                "kind": "alliance" if mty in ALLY_MTYS else "solo",
                "count": len(ms),
                "landed": int(lead.get("te", 0) or 0) <= now,
                "war_id": int(lead.get("war", 0) or 0),
                "sx": int(lead.get("sx", 0) or 0), "sy": int(lead.get("sy", 0) or 0),
            })
    out.sort(key=lambda a: a["eta"])
    return out


def marches_status():
    now = int(time.time())
    with _LOCK:
        return {"marches": len(MARCHES), "players": len(PLAYERS),
                "active": sum(1 for m in MARCHES.values() if int(m.get("te", 0) or 0) > now)}


def uid_at(wx, wy):
    """uid del jugador cuyo castillo está en ese tile. Sirve para deducir el uid propio a
    partir de las coordenadas de la ciudad, que es lo único que expone la cuenta."""
    wx, wy = int(wx or 0), int(wy or 0)
    if not wx or not wy:
        return 0, 0
    with _LOCK:
        for p in PLAYERS.values():
            if int(p.get("wx", 0) or 0) == wx and int(p.get("wy", 0) or 0) == wy:
                return int(p.get("uid", 0) or 0), int(p.get("gid", 0) or 0)
    return 0, 0


# ============================================================================
# CONTINUIDAD DEL BARRIDO (persistir el índice entre freezes/reinicios)
# ============================================================================
# El sweep del agente vive en memoria: al reengancharse tras un freeze/reinicio, su
# índice volvía a 0 y el barrido re-empezaba por el núcleo una y otra vez, sin llegar
# nunca a la periferia profunda. Aquí guardamos el índice en disco y se lo devolvemos al
# agente en su arranque (mensaje scan_boot -> resume_idx), de modo que las vueltas al
# mapa se completan aunque el agente reinicie muchas veces.
import os as _os
_PROG_PATH = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "scan_progress.json")
_prog = {"idx": 0, "order": 0, "pass": 0, "ts": 0}
_prog_last_save = 0.0

try:
    with open(_PROG_PATH) as _f:
        _prog.update(json.load(_f))
except Exception:
    pass


def save_progress(idx, order, passes, min_interval=15):
    """Guarda el índice del barrido (throttled). Escritura atómica: no dejar un JSON a
    medias si el proceso muere durante el volcado."""
    global _prog_last_save
    now = time.time()
    idx = int(idx or 0); order = int(order or 0)
    if idx <= 0:
        return
    _prog["idx"] = idx; _prog["order"] = order; _prog["pass"] = int(passes or 0); _prog["ts"] = int(now)
    if now - _prog_last_save < min_interval:
        return
    _prog_last_save = now
    try:
        tmp = _PROG_PATH + ".tmp"
        with open(tmp, "w") as f:
            json.dump(_prog, f)
        _os.replace(tmp, _PROG_PATH)
    except Exception:
        pass


def resume_index(order=0):
    """Índice desde el que el agente debe retomar el barrido. Se clampa al tamaño de la
    vuelta actual por si los parámetros del sweep cambiaron (order distinto)."""
    idx = int(_prog.get("idx", 0) or 0)
    order = int(order or _prog.get("order", 0) or 0)
    if order and idx >= order:
        return 0
    return max(0, idx)
