"""
Lista de personajes de personajes.txt que aun llevan ROPA o ACCESORIOS de
CUERPO en su linea, para auditarlos con test_accesorios.py --nude y limpiarlos.

Regla (usuario, 16-10-2026): personaje base = sin accesorios de ropa/vestimenta
en el cuerpo. Los accesorios de CABEZA (sombrero, lazos, cintas del pelo,
diademas, aureolas, cuernos, orejas, aretes...) son IDENTIDAD y NO se tocan.

Es heuristica por regex, NO verdad: sirve para producir la lista de trabajo.
Cada linea marcada se confirma/descarta mirando la imagen (--nude), igual que
el piloto de clean_accesorios.py.

    python Engine/pendientes_cuerpo.py            # informa y escribe el .txt
    python Engine/pendientes_cuerpo.py --stdout   # solo por pantalla

Salida: Archivos/pendientes_cuerpo.txt
Formato parseable: <clave>|<linea>   (compatible con character_keys/selected_chars)
"""
import argparse
import re
import sys
from datetime import datetime
from pathlib import Path

# Consola Windows (cp1252): un print con 'ō' u otro char fuera de cp1252 aborta
# con UnicodeEncodeError. Reconfigura a UTF-8 tolerante.
try:
    sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace")
except Exception:
    pass

BASE_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(Path(__file__).resolve().parent))

from prompt_builder import compute_character_keys, parse_character_line  # noqa: E402
from character_keys import lora_stems  # noqa: E402

PERSO = BASE_DIR / "Archivos" / "personajes.txt"
OUT = BASE_DIR / "Archivos" / "pendientes_cuerpo.txt"

# Aviso FINAL por Telegram (Archivos/config.py + notifier.py + .env). Si no esta
# configurado o falla, NO rompe la corrida (mismo patron que test_accesorios.py).
ARCHIVOS = BASE_DIR / "Archivos"
if str(ARCHIVOS) not in sys.path:
    sys.path.insert(0, str(ARCHIVOS))

# Centinela: evita repetir el aviso final en cada regeneracion de la lista.
CIERRE_FLAG = BASE_DIR / "Output" / ".cierre_enviado"


def notify(message):
    try:
        from notifier import send_telegram_msg
        send_telegram_msg(message)
    except Exception as e:
        print("[i] aviso Telegram no enviado: %s" % e)


def send_cierre(curados, auto, manual):
    """Aviso FINAL: solo cuando quedan 0 pendientes auto-curables.

    'auto-curable' = pendiente con LoRA en disco. Si quedan 0, el curado
    automatico ha terminado (todo curado, o solo quedan los que exigen
    intervencion manual). El centinela compara la firma (las claves manuales)
    para no reenviar; solo reenvia si esa firma cambia.
    """
    firma = "\n".join(sorted(manual))
    try:
        if CIERRE_FLAG.exists() and CIERRE_FLAG.read_text(encoding="utf-8") == firma:
            print("[i] cierre ya avisado (sin cambios) -> no se reenvia.")
            return
    except OSError:
        pass

    lineas = ["🏁 Tanda terminada (final)",
              "✅ Curados: %d" % len(curados),
              "⚠️ Intervencion manual: %d" % len(manual)]
    if curados:
        lineas += ["", "✅ Check listo:"] + ["  ✅ %s" % c for c in sorted(curados)]
    if manual:
        lineas += ["", "⚠️ Pendientes (manual):"] + \
                  ["  ⚠️ %s" % m for m in sorted(manual)]
    notify("\n".join(lineas))
    try:
        CIERRE_FLAG.parent.mkdir(parents=True, exist_ok=True)
        CIERRE_FLAG.write_text(firma, encoding="utf-8")
    except OSError as e:
        print("[i] no se escribio el centinela de cierre: %s" % e)
    print("[OK] aviso final enviado (curados=%d, manual=%d)"
          % (len(curados), len(manual)))

# CABEZA = identidad: si un tag matchea esto, NO se cuenta aunque tambien
# matchee BODY (p.ej. 'hair_ribbon' tiene 'ribbon'; 'earrings' es oreja).
HEAD = re.compile(
    r"hat|bow|ribbon|hair|headdress|headband|hairband|halo|crown|tiara|"
    r"circlet|veil|hood|beret|eyepatch|glasses|eyewear|blindfold|earring|"
    r"ear[ _]pierc|hairclip|hairpin|ornament|horn|antler|ears\b|ear_covers|"
    r"scrunchie|headgear|headwear|wreath|braid|ahoge|ponytail|sidelock|"
    r"bangs|mole|makeup|lipstick|nail|freckles|tattoo|marking|pupil|"
    r"eyeshadow|eyebrow|face|facial|mask|x_hair|butterfly|flower|"
    r"forehead|tacet|vision|crown_of_thorns|super_crown|ear_bow",
    re.I,
)

# CUERPO = ropa o accesorio de cuerpo. 'sleeve(?!less)' protege 'sleeveless'
# (que es lo CONTRARIO de manga: quitarlo haria aparecer mangas).
BODY = re.compile(
    r"sleeve(?!less)|shirt|blouse|sweater|cardigan|turtleneck|dress|skirt|shorts|"
    r"pants|jeans|trouser|coat|jacket|cape|robe|cloak|kimono|yukata|hanfu|"
    r"sarong|poncho|tunic|vest|blazer|suit|uniform|serafuku|sailor|gakuran|"
    r"apron|leotard|bodysuit|unitard|swimsuit|bikini|tank_top|crop_top|"
    r"tube_top|halter|camisole|bustier|corset|\bbra\b|underwear|panties|"
    r"panty|thong|boxer|brief|neckwear|necktie|neck_ribbon|\btie\b|bowtie|"
    r"scarf|neckerchief|ascot|cravat|shawl|stole|legwear|thighhigh|pantyhose|"
    r"legging|tight|stocking|sock|kneehigh|footwear|shoe|boot|sandal|heel|"
    r"sneaker|loafer|mary_jane|okobo|geta|\bzori\b|glove|mitten|arm_warmer|"
    r"wrist_guard|gauntlet|bracer|wristband|wrist_cuff|armband|sash|\bobi\b|"
    r"belt|suspender|garter|thigh_strap|thigh_band|thighlet|harness|"
    r"underbust|\bstraps\b|\bstrap\b|clothing|clothes|outfit|garment|"
    r"pelvic_curtain|waist_bow|cutout|hip_vent|side_slit|pilot_suit|plugsuit|"
    r"superhero_suit|herooutfit|bodystocking|bunny_suit|playboy_bunny|"
    r"china_dress|tracen|japanese_clothes|chinese_clothes|covered_navel|"
    r"covered_breast|choker|necklace|jewelry|jewel|brooch|gemstone|bracelet|"
    r"bangle|armlet|\bring\b|pendant|chain|amulet|pauldron|shoulder_armor|"
    r"gorget|armor|epaulette|anklet|zettai|lingerie|bloomers|shimapan",
    re.I,
)

# Falsos positivos conocidos del regex (nombre de personaje que contiene la
# palabra: 'chainsaw'->chain, 'cheelai'->heel; anatomia: ass/navel; skin_tight).
FALSE = re.compile(r"chain|cheelai|\bass\b|navel|skin_tight", re.I)

# Ya curados: aunque el regex los siga marcando (p.ej. Bowsette por 'jewelry',
# que son sus aretes de cabeza; mikoto por 'short sleeves' que no se ve), NO
# deben volver a la lista de pendientes.
CURADOS = {
    # piloto clean_accesorios.py (limpios antes de generar la lista)
    "Furina", "ReZero_Rem_IlluXL",
    "Hoseki_ZenlessZoneZero_Seed_IllustriousXL_v1",
    # tanda 1 (16-10-2026)
    "Char-ZZZ-Belle-V2-IL", "mina-ashido-epilogue-illustriousxl-lora-nochekaiser",
    "Delia_Ketchum_IL", "VentiIL", "domina_overwatch_ilxl_goofy",
    # tanda 2 (16-10-2026)
    "zhao_1", "P-IL-Bowsette", "mikoto-misaka-s3-illustriousxl-lora-nochekaiser",
    "ER_MarikaV1-IL", "keqing-gi-richy-v1_ixl",
    # tanda 3 (17-10-2026)
    "DC_PowerGirl3D-IL", "agnes-tachyon-nekoforge",
    "training_8576657-20260726000628564",
    "Hoseki_ZenlessZoneZero_AstraYao_IllustriousXL_v2",
    "elinalise-dragonroad-s2-illustriousxl-lora-nochekaiser",
    # tanda 4 (17-10-2026)
    "JN_Reina_Illus", "training_565609-20260702044914529",
    "ayase_momo-koto-Illustrious v01", "Lauma_ILv3.1",
    # MomoYaoyorozu-IL-v1: linea base reemplazada por la que sobrevivio/funciona
    # (antes Momo_Yaoyorozu-My_Hero_Academia-ilibibu-IL-v1).
    "MomoYaoyorozu-IL-v1",
    # tanda 5 (17-10-2026)
    "Sense illu", "NM_AW_re4remake",
    "training_4293681-20260708121102976", "Atomic_EvaIL",
    "ReneeGravesNoobXL_byKonan",
    # tanda 6 (17-10-2026). Angewomon: sash+collar son identidad (como la
    # chistera de Furina); LoRA en 0.6, torso desnudo + casco/alas intactos.
    "se_beatrice_re_zero", "dbz-bulma-anime-illustriousxl-lora-nochekaiser",
    "AngewomonDigimonIXL", "18ill", "blackCatRivals_illustrious_byJaneB",
    # tanda 7 (17-10-2026)
    "Lily", "Mavis_DraculaIL", "SSO_mizushima_reikoXLIllustrious001",
    "Robertlu1021_yunli_honkai_star_rail_v2.0", "fftifa-illu-nvwls-v1",
    # tanda 8 (17-10-2026): pre-limpiado por TAGS, sin "antes" en GPU.
    # Asa: corazon-censor horneado -> negative + LoRA 0.7->0.6.
    # Ellen Joe: el tag 'maid' horneaba el conjunto; fuera 'maid' (headdress =
    # cabeza se conserva) + LoRA 0.8->0.6. El collar de pinchos es firma
    # INSEPARABLE del personaje -> se conserva (como el sash de Angewomon).
    "mitsuri-kanroji-s3s4-illustriousxl-lora-nochekaiser", "Asa", "himekoilxl",
    "JN_Ellen_Joe_Illus",
    "Hoseki_ZenlessZoneZero_EvelynChevalier_IllustriousXL_v2",
    # tanda 9 (17-10-2026): pre-limpiado por TAGS, 5/5 a la primera.
    "aino_Noob10", "oninatsu_izo_IL", "MOMO2IL", "KaraZorelNoobXL_byKonan",
    "nobara-kugisaki-s1s2-illustriousxl-lora-nochekaiser",
    # tanda 10 (17-10-2026): pre-limpiado por TAGS, 5/5 (2 con inseparable).
    # Tayama: gargantilla horneada -> negative + LoRA 1->0.7.
    # mirrormaiden: bandas azules del headdress (CABEZA) inseparables -> 0.6.
    # Sparkle: collar+campanilla = firma INSEPARABLE -> 0.6 (como Ellen).
    "Tayama__Smoking_Behind_the_Supermarket_with_You__Illustrious",
    "mirrormaiden-gi-richy-v1_ixl", "ju fufu_ilv1.0_xl",
    "chisa_wuwa_ilxl_goofy", "Char-HonkaiSR-Sparkle-V4-IL",
    # tanda 11 (17-10-2026): pre-limpiado por TAGS, 5/5 a la primera.
    "psylocke_Marvel_Rivals_IL_r1", "Paladins_Io-IL", "Paladins_Maeve-IL",
    "c_sunna_(afternoon_tea_break)_(zenless_zone_zero)_ilv1.0-xl",
    "MelonyPokemon",
    # tanda 12 (17-10-2026): pre-limpiado por TAGS. Yelan/eris con negative
    # dirigido (pendiente/oro de cuello); eris ademas peso 1->0.8.
    "velina airgid", "Brunhilde__Record_of_Ragnarok",
    "YelanGenshin_IXL_v2",
    "eris-boreas-greyrat-s1-ponyxl-lora-nochekaiser",
    # sin <lora:> (17-10-2026): identificados por tags del checkpoint.
    r"reze \(chainsaw man\)", "rias gremory", "himejima akeno",
    # manuales con LoRA confirmado en SD (17-10-2026), limpiados por TAGS.
    # magik: shorts (horns = cabeza). quetzalcoatl: poncho+joyas (headdress =
    # cabeza). Hex: mangas+lazo de cuello (earrings = cabeza). Peni: plugsuit.
    # oo/nahida: joyas+top (hoja/flower = cabeza). Diona: shorts+mangas+lazo de
    # la cola (hat = cabeza; cola = identidad).
    "Magik_MARVEL_Tōkon_Fighting_Souls",
    "quetzalcoatl-babylonia-illustriousxl-lora-nochekaiser", "Hex_Maniac_A-Z",
    "PeniMR", "oo", "COMMISSION_Genshin_Impact_-_Diona_-_illustrious",
    # Jue (Arcane): el fichero en disco es `Arcane_AE.safetensors` pero SD lo
    # resuelve como <lora:JueV1-Nuclear1811-IL:1>. capelet/ropa asimetrica fuera.
    "JueV1-Nuclear1811-IL",
    # NM_AG_butcha-u: LoRA de ESTILO del artista Danbooru 'butcha-u'
    # (Civitai 346443 "Butcha Art Style", trigger `butcha`). Tags de cuerpo
    # fuera; nombre <lora:...> a confirmar por el usuario.
    "NM_AG_butcha-u",
}


def norm(t):
    return t.strip().lower().replace(" ", "_")


def body_tokens(line):
    _lora, tags = parse_character_line(line)
    out = []
    for t in tags:
        n = norm(t)
        if not n or FALSE.search(n):
            continue
        if BODY.search(n) and not HEAD.search(n):
            out.append(n)
    return out


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--stdout", action="store_true", help="no escribe el .txt")
    args = ap.parse_args()

    rows = [l.strip() for l in PERSO.read_text(encoding="utf-8-sig").splitlines()
            if l.strip() and not l.strip().startswith("#")]
    items = compute_character_keys(rows)
    available = lora_stems()

    hits = []
    for key, line, lora in items:
        if key in CURADOS:
            continue
        toks = body_tokens(line)
        if toks:
            on_disk = bool(lora and lora.lower() in available)
            hits.append((key, line, lora, on_disk, toks))

    # indice de linea dentro de personajes.txt (1-based, contando todas)
    line_no = {line: i for i, line in enumerate(rows, 1)}

    print("PENDIENTES DE CUERPO: %d de %d lineas" % (len(hits), len(items)))
    sin_lora = [h for h in hits if not h[2]]
    off = [h for h in hits if h[2] and not h[3]]
    print("  con LoRA en disco : %d" % sum(1 for h in hits if h[3]))
    print("  LoRA ausente      : %d" % len(off))
    print("  sin <lora:>       : %d" % len(sin_lora))
    for key, line, lora, on_disk, toks in hits:
        print("  %-46s %s" % (key[:46], ", ".join(toks)))
    if args.stdout:
        return

    stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    with OUT.open("w", encoding="utf-8", newline="\n") as f:
        f.write("# PENDIENTES DE ROPA/ACCESORIOS DE CUERPO | generado %s\n" % stamp)
        f.write("# regla: personaje base = sin ropa ni accesorios de CUERPO.\n")
        f.write("#        los accesorios de CABEZA son identidad y NO se tocan.\n")
        f.write("# %d de %d lineas.\n" % (len(hits), len(items)))
        f.write("# heuristica por regex: confirmar cada una mirando la imagen (--nude).\n")
        f.write("#\n")
        f.write("# ===== LISTA (parseable: <clave>|<linea>) =====\n")
        for key, line, lora, on_disk, toks in hits:
            f.write("%s|%s\n" % (key, line))
        f.write("#\n")
        f.write("# ===== DETALLE =====\n")
        for key, line, lora, on_disk, toks in hits:
            n = line_no.get(line, "?")
            estado = ("LoRA en disco" if on_disk else
                      ("SIN <lora:>" if not lora else "LoRA AUSENTE en disco"))
            f.write("# L%-3s %s  [%s]\n" % (n, key, estado))
            f.write("#     cuerpo: %s\n" % ", ".join(toks))
    print("\n[OK] -> %s" % OUT.relative_to(BASE_DIR))

    # Aviso final SOLO cuando no queda ningun pendiente auto-curable (con LoRA
    # en disco). No se envia en modo --stdout (dry): eso no cambia el estado.
    auto = [h[0] for h in hits if h[3]]
    manual = [h[0] for h in hits if not h[3]]
    if auto:
        print("[i] quedan %d auto-curables; el aviso final saldra al llegar a 0."
              % len(auto))
    else:
        send_cierre(CURADOS, auto, manual)


if __name__ == "__main__":
    main()
