"""
pixiv_tags: nombre de personaje -> tag canonico de Pixiv (japones/romaji) y,
opcionalmente, los tags R-18 que mas se repiten junto a el.

Sin token (solo red, cachea en `Archivos/pixiv_tags.json`):
    python Engine/pixiv_tags.py --keys "Furina,Makima_v2,Yelan"
    python Engine/pixiv_tags.py --from-batch "Output/pilots/.../plan.json"
    python Engine/pixiv_tags.py --keys "Furina" --refresh

Corregir a mano lo que no resuelve (nunca se vuelve a consultar):
    python Engine/pixiv_tags.py --set "Princess_Peach=ピーチ姫" --set-en "Princess_Peach=Princess Peach"

Con token (lee obras; necesita PIXIV_REFRESH_TOKEN, ver gppt):
    python Engine/pixiv_tags.py --keys "Furina" --r18

Como resuelve: `/rpc/cps.php` es un autocompletado de **una sola palabra** (con
espacios devuelve vacio), asi que prueba variantes de la clave y puntua por
coincidencia EXACTA con `tag_translation` o `tag_name`. Lo que no llega a exacto
queda `status: revisar` con sus 3 mejores candidatos: no se adivina.

Limites: Pixiv no publica rate limit. 1,2 s entre peticiones, backoff 3/6/12 s.
Solo lee tags; no descarga obras.
"""
import argparse
import json
import re
import sys
import time
import unicodedata
import urllib.parse
from pathlib import Path

import requests

sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace")

BASE_DIR = Path(__file__).resolve().parent.parent

try:                       # el token de Pixiv vive en .env (lo escribe pixiv_token.py)
    from dotenv import load_dotenv
    load_dotenv(BASE_DIR / ".env")
except Exception:
    pass

CACHE = BASE_DIR / "Archivos" / "pixiv_tags.json"
MANUAL = BASE_DIR / "Archivos" / "pixiv_tags_manual.json"
SERIES_MAP = BASE_DIR / "Archivos" / "pixiv_series.json"
CHAINS = BASE_DIR / "Archivos" / "pixiv_tags"
SLEEP = 1.2
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
      "(KHTML, like Gecko) Chrome/124.0 Safari/537.36")

WEB = requests.Session()
WEB.headers.update({
    "User-Agent": UA,
    "Referer": "https://www.pixiv.net/",
    "Accept": "application/json",
})

# palabras que no identifican a nadie
GENERIC = {
    "original", "女の子", "illustration", "r-18", "r18", "ai", "aiイラスト",
    "1girl", "girl", "solo", "女", "裸", "着衣", "kawaii", "anime",
}

NOISE = re.compile(
    r"(lora|illustrious|illust|noob|ponyxl|pony|sdxl|ilxl|illu|nvwls|nochekaiser|"
    r"guy90|richy|nekoforge|konan|hardov|scarxzys|goofy|nyx|janeb|ove|ova|ingame|"
    r"epilogue|character|s\d+s\d+|s\d|v\d+(\.\d+)?|sf\d|xl|[a-z]{1,2}\d+)",
    re.I)


def norm(text: str) -> str:
    """Comparable: minusculas, sin separadores ni acentos."""
    s = unicodedata.normalize("NFKC", text or "").lower()
    s = re.sub(r"[\s_\-・=/\\'\"`.,:;!?()\[\]{}<>~+*&%$#@^|]+", "", s)
    return s


def _clean_tag(tag: str) -> str:
    """Tag de la linea del personaje -> nombre consultable."""
    t = re.sub(r"<lora:[^>]*>", " ", tag)
    t = re.sub(r"\([^)]*\)", " ", t)      # (fate), (genshin_impact)
    t = re.sub(r":[\d.]+", " ", t)        # :1.4
    t = t.replace("\\", " ").replace("_", " ")
    toks = [w for w in t.split() if not NOISE.fullmatch(w)]
    return " ".join(toks).strip()


def clean_key(key: str) -> str:
    """Clave del catalogo -> nombre consultable.

    `mina-ashido-epilogue-illustriousxl-lora-nochekaiser` -> `mina ashido`
    `ChunLiSF6Illustrious` -> `Chun Li`
    """
    s = re.sub(r"<lora:[^>]*>", " ", key)
    s = re.sub(r"\[[^\]]*\]|\([^)]*\)", " ", s)
    s = re.sub(r"[_\-]+", " ", s)
    s = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", s)
    toks = [w for w in s.split() if not NOISE.fullmatch(w)]
    return _clean_tag(" ".join(toks)) or key


DESCRIPTOR = re.compile(
    r"\b(hair|eyes?|eyebrows?|skin|breasts?|body|bangs|figure|face|ears?|tail|"
    r"wings?|horns?|lips|sclera|pupils?|bun|ponytail|braid|twintails?|ahoge|"
    r"solo|1girl|girl|screencap|makeup|mole|freckles?|muscular|thighs?)\b",
    re.I)


def line_names(line: str, limit: int = 4) -> list:
    """Primeros tags de la linea del personaje que parezcan un NOMBRE.

    La linea empieza por el trigger del LoRA y sigue con el aspecto
    (`long hair`, `blue eyes`...), asi que se descartan los descriptores.
    """
    out = []
    for tag in re.split(r",", re.sub(r"<lora:[^>]*>", "", line or ""))[:limit]:
        name = _clean_tag(tag)
        if not name or name.isdigit() or DESCRIPTOR.search(name):
            continue
        if re.fullmatch(r"[0-9a-z]*\d[0-9a-z]*", name):    # 4str4, a_e, k1
            continue
        if name not in out:
            out.append(name)
    return out


def query_variants(key: str, line: str = "") -> list:
    """Consultas, de mas a menos probable.

    `cps.php` es un autocompletado de **una sola palabra**: con espacios
    devuelve vacio. Por eso se prueba el nombre pegado (`nicorobin`) y suelto,
    y **no** se baja a una palabra suelta si el nombre tiene varias (eso es lo
    que devolvia `peach`, `momo` o `genshin`).
    """
    names = line_names(line) + [clean_key(key)]
    out = []
    for nm in names:
        toks = nm.split()
        if not toks:
            continue
        if len(toks) > 1:
            out.append("".join(toks))
            out.append(nm)
        else:
            out.append(nm)
    # ultimo recurso: una sola palabra (marcara `revisar`, no `ok`)
    single = [t for nm in names for t in nm.split() if len(t) >= 4]
    out += sorted(set(single), key=len, reverse=True)
    seen, uniq = set(), []
    for q in out:
        n = norm(q)
        if n and n not in seen:
            seen.add(n)
            uniq.append(q.strip())
    return uniq


def load_json(path, default):
    if path.exists():
        try:
            return json.loads(path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            pass
    return default


def save_json(path, data):
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")


def _get(url, params, tries=4):
    for i in range(tries):
        try:
            r = WEB.get(url, params=params, timeout=20)
            r.raise_for_status()
            return r.json()
        except (requests.RequestException, ValueError) as e:
            if i == tries - 1:
                raise
            wait = 3 * (2 ** i)
            print(f"    ! {type(e).__name__}; reintento en {wait}s")
            time.sleep(wait)


def cps(keyword: str, lang: str = "en") -> list:
    """Candidatos de tag. `lang=en` trae `tag_translation` (el nombre en ingles)."""
    doc = _get("https://www.pixiv.net/rpc/cps.php",
               {"keyword": keyword, "lang": lang})
    return doc.get("candidates", []) or []


def score(cand: dict, query: str) -> int:
    """3 = el nombre coincide exacto; 2 = coincide + apellido/serie; 1 = parcial."""
    q = norm(query)
    if not q:
        return 0
    names = [norm(cand.get("tag_translation") or ""),
             norm(cand.get("tag_name") or "")]
    if q in names:
        return 3
    for n in names:
        if n.startswith(q) and len(n) > len(q):
            return 2
        if q in n and len(q) >= 4:
            return 1
    return 0


AMBIGUOUS = re.compile(r"曖昧さ回避|以下の項目")


def tag_info(tag: str) -> dict:
    """Ficha publica del tag: descripcion de pixpedia + tags hermanos.

    Es lo que convierte una coincidencia de texto en una **verificacion**: la
    descripcion dice de que obra es el personaje, asi que se puede comprobar
    sin abrir la pagina. Un tag ambiguo (`心海`) lo dice explicitamente.
    """
    if not tag:
        return {}
    try:
        doc = _get("https://www.pixiv.net/ajax/search/tags/" + tag,
                   {"lang": "ja"}, tries=3)
    except Exception as e:
        print(f"    ! tags({tag!r}): {type(e).__name__}")
        return {}
    body = doc.get("body") or {}
    abstract = ((body.get("pixpedia") or {}).get("abstract") or "").strip()
    return {
        "abstract": re.sub(r"\s+", " ", abstract)[:300],
        "ambiguous": bool(AMBIGUOUS.search(abstract)),
        "siblings": [t.get("tag") for t in (body.get("siblingsTags") or [])][:8],
    }


def series_of(abstract: str) -> str:
    """Saca la obra de la descripcion (`『ダンダダン』の…` -> `ダンダダン`)."""
    for pat in (r"『([^』]{2,30})』", r"「([^」]{2,30})」"):
        m = re.search(pat, abstract or "")
        if m:
            return m.group(1)
    return ""


def resolve_key(key: str, line: str = "") -> dict:
    """Prueba variantes (y ambos idiomas) y elige la mejor coincidencia."""
    seen, pool = set(), []
    for q in query_variants(key, line)[:8]:
        for lang in ("en", "ja"):
            try:
                cands = cps(q, lang)
            except Exception as e:
                print(f"    ! cps({q!r},{lang}): {type(e).__name__}")
                cands = []
            for c in cands:
                name = c.get("tag_name") or ""
                if name in seen:
                    continue
                seen.add(name)
                pool.append({"tag": name,
                             "en": c.get("tag_translation") or "",
                             "access": int(c.get("access_count") or 0),
                             "score": score(c, q),
                             "query": q})
            time.sleep(SLEEP)
        if any(c["score"] == 3 for c in pool):
            break

    if not pool:
        return {"key": key, "status": "sin_resultado", "cands": []}

    pool.sort(key=lambda c: (-c["score"], -c["access"]))
    best = pool[0]
    exact = [c for c in pool if c["score"] == 3]
    chosen = max(exact, key=lambda c: c["access"]) if exact else best

    status = "ok" if chosen["score"] == 3 else "revisar"
    # `peach`/`momo`/`genshin` no son el personaje: nombre de una sola palabra
    # sacado de una clave compuesta -> que lo confirme una persona
    multiword_key = len(clean_key(key).split()) > 1
    if status == "ok" and multiword_key and len(chosen["query"].split()) == 1:
        status = "revisar"
    if status == "ok" and norm(chosen["tag"]) in {norm(g) for g in GENERIC}:
        status = "revisar"

    info = tag_info(chosen["tag"])
    series = series_of(info.get("abstract", ""))
    if status == "ok" and info.get("ambiguous"):
        status = "revisar"      # `心海` es una pagina de desambiguacion
    return {
        "key": key,
        "jp": chosen["tag"],
        "en": chosen["en"],
        "access": chosen["access"],
        "query": chosen["query"],
        "status": status,
        "series": series,
        **info,
        "cands": pool[:5],
    }


def is_cjk(text: str) -> bool:
    return bool(re.search(r"[\u3040-\u30ff\u3400-\u9fff]", text or ""))


_BUILDER = None


def character_line(key: str) -> str:
    """Linea base del personaje en `personajes.txt` (para sacar su nombre)."""
    global _BUILDER
    if _BUILDER is None:
        sys.path.insert(0, str(BASE_DIR / "Engine"))
        sys.path.insert(0, str(BASE_DIR / "Archivos"))
        try:
            from prompt_builder import PromptBuilder
            _BUILDER = PromptBuilder(str(BASE_DIR))
        except Exception:
            _BUILDER = False
    if not _BUILDER:
        return ""
    try:
        return (_BUILDER.get_character(key) or {}).get("base", "") or ""
    except Exception:
        return ""


def field(obj, key, default=None):
    """Compatible con JsonDict (pixivpy 3.7.5) y pydantic (3.13)."""
    if isinstance(obj, dict):
        return obj.get(key, default)
    return getattr(obj, key, default)


def r18_tags(tag: str, pages: int, top: int, pool: dict | None = None) -> dict:
    import os
    from collections import Counter
    try:
        from pixivpy3 import AppPixivAPI
    except ImportError:
        raise SystemExit('[!] falta pixivpy: pip install "git+https://github.com/upbit/pixivpy.git"')

    token = os.environ.get("PIXIV_REFRESH_TOKEN")
    if not token:
        raise SystemExit("[!] define PIXIV_REFRESH_TOKEN (gppt login) para --r18")

    api = AppPixivAPI()
    api.auth(refresh_token=token)
    api.set_accept_language("en-us")

    counter, total = Counter(), 0
    qs = {"word": f"{tag} R-18", "search_target": "partial_match_for_tags",
          "sort": "date_desc", "filter": "for_ios"}
    for _ in range(max(1, pages)):
        res = api.search_illust(**qs)
        illusts = field(res, "illusts") or []
        total += len(illusts)
        for ill in illusts:
            for t in (field(ill, "tags") or []):
                # pixivpy 3.7.5 devuelve {"name": ..., "translated_name": ...};
                # "tag" es la forma vieja. Sin esto el contador sale vacio.
                name = field(t, "tag") or field(t, "name")
                if (not name or name == tag or name in GENERIC
                        or name.endswith("users入り")):
                    continue
                counter[name] += 1
        qs = api.parse_qs(field(res, "next_url"))
        if not qs:
            break
        time.sleep(SLEEP)

    # `r18_top` es para mirar (mezcla personajes y series); `r18_pool` es solo
    # el vocabulario NSFW que de verdad aparece en sus obras, y es lo que usa
    # la cadena. Sin separarlos, el top-15 se llena de `原神` y compañia.
    pool_hits = ([(t, n) for t, n in counter.most_common() if t in pool]
                 if pool else [])
    return {"r18_works": total, "r18_top": counter.most_common(top),
            "r18_pool": pool_hits}


# --------------------------------------------------------------- cadenas
#
# La cadena de 10 (D1.3): 2 personaje (JP+EN) + 2 franquicia (JP+EN)
# + 5 NSFW/escena + 1 libre. `R-18` va aparte (`extra`), no cuenta.
#
# La co-ocurrencia sale de `/ajax/search/artworks/{tag}`, que devuelve
# `relatedTags`: los tags que de verdad acompanan a ese personaje. No necesita
# token, y el `total` da popularidad en obras (mejor que access_count).

CHAIN_NOISE = {
    "オリジナル", "イラスト", "漫画", "うごイラ", "小説", "R-18", "R-18G",
    "AIイラスト", "AI生成", "StableDiffusion", "NovelAI", "pixiv今日のお題",
    "ロリ", "ショタ", "ふたなり", "レイプ", "輪姦", "獣姦", "グロ", "リョナ",
    "女の子", "美少女", "1girl", "solo", "チラシの裏", "落書き", "練習",
    "オリジナル1000users入り", "描き方", "講座",
}


def works_of(tag: str) -> dict:
    """Obras con ese tag: total y tags que co-ocurren (`relatedTags`)."""
    if not tag:
        return {"total": 0, "related": []}
    q = urllib.parse.quote(tag)
    try:
        doc = _get("https://www.pixiv.net/ajax/search/artworks/" + q,
                   {"word": tag, "order": "date_d", "mode": "all", "p": 1,
                    "s_mode": "s_tag_full", "type": "all", "lang": "ja"}, tries=3)
    except Exception as e:
        print(f"    ! obras({tag!r}): {type(e).__name__}")
        return {"total": 0, "related": []}
    body = doc.get("body") or {}
    return {
        "total": int((body.get("illustManga") or {}).get("total") or 0),
        "related": [(t.get("tag") if isinstance(t, dict) else t)
                    for t in (body.get("relatedTags") or [])
                    if (t.get("tag") if isinstance(t, dict) else t)],
    }


def load_series() -> dict:
    return load_json(SERIES_MAP, {})


def series_entry(raw: str, smap: dict):
    """Obra tal como la escribe pixpedia -> ficha del mapa (`jp`/`en`)."""
    n = norm(raw)
    if not n:
        return None
    for jp, val in smap.items():
        if jp.startswith("_") or not isinstance(val, dict):
            continue
        if n == norm(jp):
            return {"jp": jp, **val}
        for alias in (val.get("aliases") or []):
            if n == norm(alias):
                return {"jp": jp, **val}
    return None


def pick_nsfw(tag: str, pool: dict, n: int = 5, meas=None) -> tuple:
    """Los `n` generales del pool, ordenados por co-ocurrencia **medida**.

    `meas` = [(tag, veces)] sacado de leer obras reales (requiere token). Si no
    hay token, se usa `relatedTags`, que mezcla personajes y series: sirve para
    filtrar el pool, no para sustituirlo. Los huecos se completan por categoria.
    """
    ranked = [t for t, _ in (meas or []) if t in pool]
    if not ranked:
        related = works_of(tag)["related"]
        rank = {}
        for i, t in enumerate(related):
            rank.setdefault(t, i)
        ranked = sorted((t for t in rank if t in pool), key=lambda t: rank[t])

    # Hasta 3 de "cuerpo" (pechos/muslos/caderas); el resto, acto/estado/ropa.
    # Sin esto el top medido puede ser 5 variantes de pecho y la obra no
    # aparece en las busquedas de acto.
    chosen, body = [], 0
    for t in ranked:
        if len(chosen) >= n:
            break
        if pool[t].get("cat") == "cuerpo":
            if body >= 3:
                continue
            body += 1
        chosen.append(t)

    if len(chosen) < n:
        prio = ["cuerpo", "ropa", "estado", "acto", "encuadre"]
        for cat in prio:
            if len(chosen) >= n:
                break
            for t, meta in pool.items():
                if meta.get("cat") != cat or t in chosen:
                    continue
                if cat == "cuerpo" and sum(
                        1 for c in chosen if pool[c].get("cat") == "cuerpo") >= 3:
                    break
                chosen.append(t)
                break
        if len(chosen) < n:      # ultimo recurso: lo que quede
            chosen += [t for t in pool if t not in chosen][: n - len(chosen)]
    return chosen, len([t for t in ranked if t in chosen])


def build_chain(key: str, entry: dict, smap: dict, pool: dict,
                nsfw_n: int = 5, meas=None) -> dict:
    """Ficha publicable de un personaje -> dict con los 10 tags."""
    jp = entry.get("jp") or ""
    raw_series = entry.get("series") or ""
    se = series_entry(raw_series, smap) or {}
    nsfw, co_hits = pick_nsfw(jp, pool, nsfw_n, meas) if jp else ([], 0)
    # D1.1: lo de cuerpo vale para todas las imagenes del personaje; ropa,
    # estado y acto dependen de la imagen concreta y se sustituyen con el prompt.
    fijos = [t for t in nsfw if (pool.get(t) or {}).get("cat") == "cuerpo"]
    por_imagen = [t for t in nsfw if t not in fijos]
    head = [t for t in (jp, entry.get("en") or "",
                        se.get("jp") or raw_series, se.get("en") or "") if t]
    tags = (head + fijos + por_imagen + [""])          # el ultimo es el libre
    tags = (tags + [""] * 10)[:10]
    return {
        "char": key,
        "tags": tags,
        "fijos": head + fijos,
        "por_imagen": por_imagen,
        "libre": "",
        "extra": ["R-18", "AIイラスト"],
        "jp": {"char": jp, "series": se.get("jp") or raw_series},
        "en": {"char": entry.get("en") or "", "series": se.get("en") or ""},
        "nsfw": nsfw,
        "nsfw_measured": co_hits,
        "series_ok": bool(se),
        "works_total": entry.get("access") or 0,
        "verified": entry.get("status") in ("ok", "manual"),
        "status": entry.get("status") or "",
    }


def chain_line(chain: dict) -> str:
    """La cadena tal cual se pega en Pixiv (`R-18` incluido al final)."""
    return ", ".join([t for t in chain["tags"] if t] + chain["extra"])


def chain_path(key: str) -> Path:
    """Ruta de la ficha de un personaje (misma sanea que `save_chain`)."""
    safe = re.sub(r"[^\w.\- ]+", "_", key).strip() or "char"
    return CHAINS / f"{safe}.json"


def save_chain(chain: dict) -> Path:
    CHAINS.mkdir(parents=True, exist_ok=True)
    path = chain_path(chain["char"])
    path.write_text(json.dumps(chain, ensure_ascii=False, indent=1), encoding="utf-8")
    return path


def to_markdown(rows) -> str:
    out = ["| Clave | Tag Pixiv | Inglés | Obra | Accesos | Estado |",
           "|---|---|---|---|---|---|"]
    for r in rows:
        out.append("| `{}` | {} | {} | {} | {:,} | {} |".format(
            r["key"], r.get("jp") or "—", r.get("en") or "—",
            r.get("series") or ("ambiguo" if r.get("ambiguous") else "—"),
            r.get("access") or 0,
            {"ok": "✅", "revisar": "⚠️ revisar", "manual": "✅ manual"}
            .get(r.get("status"), r.get("status") or "—")))
    return "\n".join(out)


def apply_set(manual, spec: str, field_name: str) -> None:
    if "=" not in spec:
        raise SystemExit(f"[!] formato incorrecto: {spec!r} (usa Clave=valor)")
    key, value = spec.split("=", 1)
    key, value = key.strip(), value.strip()
    manual.setdefault(key, {})[field_name] = value
    save_json(MANUAL, manual)
    print(f"[OK] {key} -> {field_name}={value!r}")


def main():
    p = argparse.ArgumentParser(description="Tags de Pixiv por personaje")
    p.add_argument("--keys", default=None, help="claves del catálogo separadas por coma")
    p.add_argument("--from-batch", default=None,
                   help="ruta a un plan.json: resuelve los personajes del lote")
    p.add_argument("--refresh", action="store_true", help="ignora la caché")
    p.add_argument("--verify", action="store_true",
                   help="re-consulta la ficha del tag (pixpedia) para confirmarlo")
    p.add_argument("--r18", action="store_true",
                   help="añade top tags R-18 (necesita PIXIV_REFRESH_TOKEN)")
    p.add_argument("--pages", type=int, default=5,
                   help="páginas de 30 obras por personaje en --r18 (default 5 = 150 obras)")
    p.add_argument("--top", type=int, default=15, help="nº de tags R-18 a listar")
    p.add_argument("--md", default=None, help="escribe el volcado markdown en esa ruta")
    p.add_argument("--chain", action="store_true",
                   help="genera la cadena de 10 tags publicable de cada personaje")
    p.add_argument("--nsfw-n", type=int, default=5,
                   help="cuantos generales NSFW entran en la cadena (default 5)")
    p.add_argument("--set", action="append", default=[],
                   help='fija el tag japonés a mano: "Clave=タグ" (repetible)')
    p.add_argument("--set-en", action="append", default=[],
                   help='fija el nombre inglés a mano: "Clave=Name" (repetible)')
    args = p.parse_args()

    manual = load_json(MANUAL, {})

    for spec in args.set or []:
        apply_set(manual, spec, "jp")
    for spec in args.set_en or []:
        apply_set(manual, spec, "en")
    if (args.set or args.set_en) and not (args.keys or args.from_batch):
        return

    keys = []
    if args.keys:
        keys += [x.strip() for x in args.keys.split(",") if x.strip()]
    if args.from_batch:
        doc = json.loads(Path(args.from_batch).read_text(encoding="utf-8"))
        keys += [it["char"] for it in doc.get("plan", [])]
    if not keys:
        raise SystemExit("[!] pasa --keys o --from-batch")

    cache = load_json(CACHE, {})
    pool = load_json(BASE_DIR / "Archivos" / "pixiv_nsfw_pool.json", {})
    rows = []
    for i, key in enumerate(keys, 1):
        if key in manual:
            if not manual[key].get("jp"):
                entry = {"key": key, "status": "sin_tag", **manual[key]}
                print(f"[{i}/{len(keys)}] {key} -> sin tag en Pixiv "
                      f"({entry.get('note', '')})")
                rows.append(entry)
                continue
            entry = {**cache.get(key, {}), **manual[key], "key": key, "status": "manual"}
            if args.verify or not entry.get("abstract"):
                # el access/en de la cache son de la resolucion vieja: se
                # re-consulta el propio tag para tener el numero real
                if not manual[key].get("access"):
                    for c in cps(entry["jp"], "ja"):
                        if c.get("tag_name") == entry["jp"]:
                            entry["access"] = int(c.get("access_count") or 0)
                            entry["en"] = (entry.get("en")
                                           or c.get("tag_translation") or "")
                            break
                    time.sleep(SLEEP)
                info = tag_info(entry["jp"])
                entry.update(info)
                entry["series"] = manual[key].get("series") or series_of(
                    info.get("abstract", ""))
                manual[key].update({k: entry[k] for k in ("abstract", "series")
                                    if k in entry})
                save_json(MANUAL, manual)
                cache[key] = entry
                save_json(CACHE, cache)
                time.sleep(SLEEP)
            print(f"[{i}/{len(keys)}] {key} -> {entry['jp']} (manual)"
                  f" · {entry.get('series') or 'obra ?'} · {entry.get('abstract', '')[:70]}")
        elif key in cache and not args.refresh:
            entry = cache[key]
            if args.verify and entry.get("jp") and not entry.get("abstract"):
                entry.update(tag_info(entry["jp"]))
                entry["series"] = series_of(entry.get("abstract", ""))
                cache[key] = entry
                save_json(CACHE, cache)
                time.sleep(SLEEP)
            print(f"[{i}/{len(keys)}] {key} -> {entry.get('jp')} "
                  f"({entry.get('status')}, caché)")
        else:
            print(f"[{i}/{len(keys)}] {key} ...", flush=True)
            entry = resolve_key(key, character_line(key))
            cache[key] = entry
            save_json(CACHE, cache)
            tag = entry.get("jp") or "—"
            print(f"    {entry.get('query', '?')!r} -> {tag} "
                  f"[{entry.get('status')}] {entry.get('access', 0):,} accesos")
            if entry.get("status") != "ok":
                for c in entry.get("cands", [])[:3]:
                    print(f"      ? {c['tag']} ({c['en']}) "
                          f"{c['access']:,} score={c['score']}")
            time.sleep(SLEEP)

        row = dict(entry)
        row["key"] = key
        if args.r18 and row.get("jp"):
            try:
                row.update(r18_tags(row["jp"], args.pages, args.top, pool))
                cache[key] = {**cache.get(key, {}), **row}
                save_json(CACHE, cache)
                print("    R-18: {} obras | {}".format(
                    row["r18_works"],
                    ", ".join(f"{t}({n})" for t, n in row["r18_top"][:8])))
            except SystemExit as e:
                if not args.chain:
                    raise
                print(f"    [!] {e} -> sigo sin co-ocurrencia medida")
                args.r18 = False
            except Exception as e:
                print(f"    [!] R-18 falló: {type(e).__name__}: {e}")
        rows.append(row)

    if args.chain:
        smap = load_series()
        if not pool:
            raise SystemExit("[!] falta Archivos/pixiv_nsfw_pool.json "
                             "(genera: python Engine/pixiv_nsfw_pool.py)")
        chains = []
        for row in rows:
            if not row.get("jp"):
                print(f"[--] {row['key']}: sin tag de personaje, no hay cadena")
                continue
            chain = build_chain(row["key"], row, smap, pool, args.nsfw_n,
                                row.get("r18_pool") or row.get("r18_top") or None)
            save_chain(chain)
            chains.append(chain)
            print(f"\n{row['key']}")
            print(f"  {chain_line(chain)}")
            print(f"  co-ocurrencia medida: {chain['nsfw_measured']}/"
                  f"{len(chain['nsfw'])} generales")
            if not chain["series_ok"]:
                print(f"  [!] obra sin mapear: {chain['jp']['series']!r} "
                      f"-> anade '{chain['jp']['series']}' a {SERIES_MAP.name}")
            if len([t for t in chain["tags"] if t]) < 9:
                print(f"  [!] solo {len([t for t in chain['tags'] if t])} tags "
                      f"resueltos (faltan generales o el libre)")
            time.sleep(SLEEP)
        if chains:
            md = ["| Clave | Personaje | Obra | Obras | Cadena |", "|---|---|---|---|---|"]
            for c in chains:
                md.append("| `{}` | {} / {} | {} / {} | {:,} | {} |".format(
                    c["char"], c["jp"]["char"], c["en"]["char"],
                    c["jp"]["series"], c["en"]["series"] or "¿?",
                    c["works_total"], chain_line(c)))
            Path(CHAINS / "_index.md").write_text("\n".join(md) + "\n",
                                                  encoding="utf-8")
            print(f"\n[OK] {len(chains)} cadenas -> {CHAINS}")
            print(f"[OK] indice -> {CHAINS / '_index.md'}")
        return

    print()
    print(to_markdown(rows))
    pend = [r["key"] for r in rows if r.get("status") not in ("ok", "manual", "sin_tag")]
    if pend:
        print(f"\n[!] {len(pend)} sin resolver: {', '.join(pend)}")
        print('    Corrige con: --set "Clave=タグ" --set-en "Clave=Name"')
    if args.md:
        Path(args.md).write_text(to_markdown(rows) + "\n", encoding="utf-8")
        print(f"\n[OK] markdown -> {args.md}")
    print(f"[OK] caché: {CACHE}")


if __name__ == "__main__":
    main()
