"""
Prueba visual de la limpieza de accesorios (Engine/clean_accesorios.py).

Genera 1 imagen por personaje con una pose NEUTRA para comprobar que:
  - el LoRA sigue cargando (si el personaje sale generico, es que el LoRA
    no esta o el prompt se rompio)
  - los accesorios de CABEZA siguen (sombrero de Furina, X de Rem, trebol de Seed)
  - ya NO sale collar/joyas/tacones/bandas que quitamos

Usa el ensamblador REAL de test_blocks.assemble_prompt, asi que el prompt lleva
la estructura vigente (MASTER / chica / BREAK / bloque de pose).

    python Engine/test_accesorios.py --dry-run     # imprime los prompts
    python Engine/test_accesorios.py               # genera las 3
    python Engine/test_accesorios.py --seed 12345  # A/B reproducible
"""
import argparse
import base64
import json
import re
import sys
import time
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 PromptBuilder, parse_character_line  # noqa: E402
from test_blocks import assemble_prompt  # noqa: E402

PERSO = BASE_DIR / "Archivos" / "personajes.txt"
OUT = BASE_DIR / "Output" / "test_accesorios"
FORGE = "http://127.0.0.1:7860"

# Aviso por Telegram al terminar (Archivos/config.py + notifier.py + .env).
# Si no esta configurado o falla, NO rompe la corrida.
ARCHIVOS = BASE_DIR / "Archivos"
if str(ARCHIVOS) not in sys.path:
    sys.path.insert(0, str(ARCHIVOS))


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)


# Centinela del aviso por tanda: claves YA avisadas (una por linea). Evita que
# los recambios/reejecuciones de la MISMA tanda reaparezcan en Telegram; al
# empezar una tanda con claves NUEVAS, vuelve a avisar. El aviso FINAL de cierre
# (con "Check listo") vive en pendientes_cuerpo.py.
TANDA_FLAG = BASE_DIR / "Output" / ".tandas_avisadas"


def avisar_tanda(targets, n_images, modo, seed, out_dir):
    try:
        ya = (set(TANDA_FLAG.read_text(encoding="utf-8").splitlines())
              if TANDA_FLAG.exists() else set())
    except OSError:
        ya = set()
    nuevas = [t for t in targets if t not in ya]
    if not nuevas:
        print("[i] tanda ya avisada (claves vistas); no se reenvia.")
        return
    notify("✅ Tanda terminada\n%d imagenes — modo %s, seed %s\n"
           "Carpeta: %s\n%s"
           % (n_images, modo, seed, out_dir.name, ", ".join(targets)))
    try:
        TANDA_FLAG.parent.mkdir(parents=True, exist_ok=True)
        TANDA_FLAG.write_text("\n".join(sorted(ya | set(targets))),
                              encoding="utf-8")
    except OSError as e:
        print("[i] no se pudo escribir el centinela de tandas: %s" % e)

# Pose neutra pedida por el usuario para revision visual.
# OJO: NO lleva '1girl, solo'. El MASTER ya aporta '1girl, solo' (tramo 1) y
# meterlos otra vez aqui produce 'solo' duplicado (medido en el dry-run).
# force_nude='never' en el ensamblador: aqui se mira la IDENTIDAD, no el
# outfit, y no queremos que inyecte 'completely_nude'.
POSE = ("front view, standing, smile, simple background, "
        "white background, dynamic pose, dynamic shot")

# Outfit PERSISTENTE (pedido del usuario). El objetivo de esta prueba es ver si
# los accesorios de cuerpo que quitamos (collar, joyas, pulseras, bandas) se
# cuelan por encima de una ropa definida. Si el jean y la blusa se mantienen y
# no aparece nada de cuerpo encima, la limpieza esta probada.
#
# force_nude='auto' (NO 'never'): wd14_blocks.DROP_OUTFIT detecta 'jeans' como
# prenda, asi que el ensamblador NO inyecta 'completely_nude' y aqui no se
# cuela la desnudez.
# Modo DESNUDEZ (--nude): el outfit NO se pone y force_nude='always' inyecta
# NUDE_TAGS en el tramo de la chica. Los accesorios de CABEZA siguen en su linea
# del personaje, que es donde deben estar: asi se comprueba que el LoRA no
# inventa ropa de cuerpo y que la cabeza conserva sus rasgos.
# Nota: 'dynamic pose, dynamic shot' es solo para pruebas unitarias de UN
# personaje (ver AGENTS.md). Para series de produccion no se usan.
OUTFIT = "jeans, white blouse"

# NEGATIVAS contra ropa que el LoRA trae HORNEADA (sesgo del LoRA, no del prompt).
#
# MEDIDO 09-10-2026: escribir 'no thigh strap:1.3' en el POSITIVE no funciona.
# El text encoder de SD no entiende la negacion: le LEE el concepto 'thigh strap'
# y lo refuerza. Resultado real: a Furina le salio la banda igual Y de paso un
# collar y dos gemas en el pezon que antes no habia. Empeoró.
#
# La negacion va en el NEGATIVE prompt, que es donde SD si repele conceptos.
# Ahi no hace falta peso explicito: el negative ya es de por si una instruccion.
#
# OJO: esto solo tapa el sesgo de ESTOS LoRAs en el piloto. En produccion el
# outfit lo decide el bloque de cada imagen.
NEG_EXTRA = {
    "Furina": ("thigh strap, thigh band, thighhighs, garter, garter belt, "
               "belt, collar, choker, necklace, gemstone, brooch, jewelry, "
               "blue gemstone"),
    # Rem y Seed ya salen limpios tras quitar 'black sleeves' y 'thigh cutout'
    # de la linea del personaje. Sin extra de negative.
    "ReZero_Rem_IlluXL": "",
    "Hoseki_ZenlessZoneZero_Seed_IllustriousXL_v1": "",
}

# LoRA de los 5 pendientes: aun NO se ha limpiado su ropa de la linea, asi que
# aqui el negative tiene que tapar lo que brought el prompt. Al limpiar la linea
# este extra deberia poder vaciarse (como pas{o con Rem y Seed).
NEG_PENDIENTES = {
    "YelanGenshin_IXL_v2":
        "necklace, pendant, collar, choker, jewelry, dice, dice pendant",
    "eris-boreas-greyrat-s1-ponyxl-lora-nochekaiser":
        "sleeves, long sleeves, puffy sleeves, detached sleeves, cutout, "
        "shoulder cutout, clothing cutout, collar, jewelry, neck ribbon, "
        "necktie, bowtie, choker, necklace, pendant, gorget, bow, ribbon, "
        "black ribbon, corset",
    "MelonyPokemon": "shorts, short shorts, side slit shorts, thighhighs",
    "agnes-tachyon-nekoforge": "sleeves, puffy sleeves, puffy short sleeves, "
                               "detached sleeves, blouse, shirt",
    "elinalise-dragonroad-s2-illustriousxl-lora-nochekaiser":
        "sleeves, detached sleeves, shorts, collar, jewelry",
    "domina_overwatch_ilxl_goofy":
        "cutout, cleavage cutout, clothing cutout, cleavage, collar, jewelry",
    # Venti (16-10-2026): tras limpiar la linea seguia saliendo el traje de
    # Arconte (bikini celeste + gargantilla) HORNEADO en el LoRA. Aqui va
    # negative dirigido + LoRA bajado a 0.6 (antes 0.8).
    "VentiIL": "bikini, swimsuit, one-piece swimsuit, bikini top, bikini "
               "bottom, leotard, bodysuit, choker, collar, necklace, "
               "jewelry, clothing cutout, cutout",
    # Sigrid (training_8576657-…, 17-10-2026): tras limpiar la linea seguian saliendo el
    # gorget (banda metalica del cuello) y el single pauldron (brazalete/armlet
    # en el brazo) HORNEADOS. Negative dirigido + LoRA bajado 1 -> 0.8.
    "training_8576657-20260726000628564":
        "metal gorget, gorget, single pauldron, pauldron, armor, shoulder "
        "armor, armlet, arm band, armband, collar, necklace, choker, jewelry",
    # Sense (17-10-2026): tras limpiar la linea (capelet/lazo/zapatos) quedo una
    # gema amarilla horneada en la ingle. Negative dirigido (la linea ya era base).
    "Sense illu": "gem, jewel, gemstone, brooch, yellow gem, pendant, jewelry",
    # Angewomon (17-10-2026): el traje entero (bodysuit + coraza dorada + guantes
    # + sash) va HORNEADO ya en 0.8. Negative dirigido; si persiste, bajar a 0.6.
    "AngewomonDigimonIXL":
        "bodysuit, leotard, swimsuit, one-piece swimsuit, armor, breastplate, "
        "chest armor, gold armor, bra, bikini top, chest cups, glove, white "
        "glove, thigh strap, sash, ribbon, pink ribbon, collar, neck ring",
    # yunli (17-10-2026): tras limpiar la linea (traje/joyas) quedo un collar-
    # cadena oscura horneada en el cuello. Negative dirigido (la linea es base).
    "Robertlu1021_yunli_honkai_star_rail_v2.0":
        "necklace, choker, collar, jewelry, chain, pendant, neck ring",
    # Asa (17-10-2026, tanda 8): tras limpiar manga/cuello/calzado la linea
    # quedo base, pero salio un CORAZON NEGRO tapando la ingle (censor
    # horneado). Negative dirigido.
    "Asa":
        "censor bar, heart censor bar, heart, pasties, underwear, panties, "
        "bikini bottom, thong, black heart",
    # Ellen Joe (17-10-2026, tanda 8): tras limpiar brazalete/mangas/panty sale
    # un collar de pinchos HORNEADO en el cuello (el maid headdress y el spiked
    # hairband son de CABEZA -> se conservan). Negative dirigido.
    "JN_Ellen_Joe_Illus":
        "collar, choker, spiked collar, neck ring, necklace, studded collar",
    # Tayama (17-10-2026, tanda 10): gargantilla negra HORNEADA (con hebilla).
    "Tayama__Smoking_Behind_the_Supermarket_with_You__Illustrious":
        "choker, black choker, collar, neck ring, necklace, jewelry",
    # mirrormaiden (17-10-2026, tanda 10): straps azules del traje horneadas
    # (arnes/tirantes). El eye mask y el headdress son de CABEZA (se conservan).
    "mirrormaiden-gi-richy-v1_ixl":
        "straps, strap, harness, bra, bra straps, bodysuit, leotard, swimsuit, "
        "clothing, dress, detached collar, collar",
    # Sparkle (17-10-2026, tanda 10): campanilla del cuello + pulsera con
    # campanilla + resto de guante HORNEADOS. 'mask on head' = CABEZA (se deja).
    "Char-HonkaiSR-Sparkle-V4-IL":
        "bell choker, choker, collar, bell, bells, wrist bells, bracelet, "
        "glove, black glove, neck ring",
}


def build_block(nude=False, lora=None, outfit=None):
    """Bloque de pose (tramo normal, SIN pesos: strip_weights_of los borra).

    La negacion por sesgo del LoRA NO va aqui, va al negative prompt: meter
    'no X' en el positivo refuerza X (medido 09-10-2026 con Furina).

    'outfit' permite forzar una prenda concreta (p.ej. escolar / bikini) en vez
    del OUTFIT por defecto; solo se usa cuando NO es modo --nude.
    """
    parts = []
    if not nude:
        parts.append(outfit or OUTFIT)
    parts.append(POSE)
    return ", ".join(p for p in parts if p)


def neg_for(lora, base_neg):
    """Une el negative del master con el extra del personaje. Solo en --nude."""
    extra = NEG_EXTRA.get(lora) or NEG_PENDIENTES.get(lora, "")
    if not extra:
        return base_neg
    base = base_neg.strip().rstrip(",")
    return "%s, %s" % (base, extra) if base else extra

# El outfit va en el bloque de pose, NO en el tramo de la chica: el tramo de la
# chica lleva su propio orden (1girl + linea + posible nude) y meter ropa ahi
# rompe la estructura de AGENTS.md.
BLOCK = OUTFIT + ", " + POSE

# Los 3 del piloto de clean_accesorios.py. La clave es el nombre del LoRA
# (AGENTS.md: PromptBuilder indexa por LoRA, no por nombre de personaje).
TARGETS = ["Furina", "ReZero_Rem_IlluXL",
           "Hoseki_ZenlessZoneZero_Seed_IllustriousXL_v1"]

# Los 5 que quedan de las 42 lineas con ropa horneada, para validar el mismo
# criterio antes de aplicarlo a las 24 que tienen LoRA en disco. Elegidos por
# numero de tags de ropa y por tener el .safetensors presente.
PENDIENTES_5 = [
    "eris-boreas-greyrat-s1-ponyxl-lora-nochekaiser",
    "MelonyPokemon",
    "agnes-tachyon-nekoforge",
    "elinalise-dragonroad-s2-illustriousxl-lora-nochekaiser",
    "domina_overwatch_ilxl_goofy",
]
# Cuantos tags de ropa tiene cada uno (para la ficha del manifiesto)
PENDIENTES_ROPA = {
    "eris-boreas-greyrat-s1-ponyxl-lora-nochekaiser":
        ["long sleeves", "shoulder cutout", "wide sleeves"],
    "MelonyPokemon": ["short shorts", "white shorts", "side slit shorts"],
    "agnes-tachyon-nekoforge": ["puffy short sleeves", "puffy sleeves"],
    "elinalise-dragonroad-s2-illustriousxl-lora-nochekaiser":
        ["detached sleeves", "shorts"],
    "domina_overwatch_ilxl_goofy": ["cleavage cutout", "clothing cutout"],
}

# Lo que debe verse en la imagen (cabeza) y lo que ya NO debe salir (cuerpo).
DEBE_TENER = {
    "Furina": ["hat", "top hat", "hat bow"],
    "ReZero_Rem_IlluXL": ["x hair ornament", "hair ribbon"],
    "Hoseki_ZenlessZoneZero_Seed_IllustriousXL_v1": ["clover hair ornament"],
}
NO_DEBE_TENER = {
    "Furina": ["ascot", "jewelry", "brooch", "gemstone", "high heels"],
    "ReZero_Rem_IlluXL": ["detached collar", "neck ribbon", "black ribbon"],
    "Hoseki_ZenlessZoneZero_Seed_IllustriousXL_v1":
        ["bare shoulders", "stirrup legwear", "armlet", "wrist cuffs"],
}


def find_lines(targets=None):
    """-> {nombre_lora: linea_original}"""
    targets = set(targets or TARGETS)
    rows = {}
    for line in PERSO.read_text(encoding="utf-8").splitlines():
        if not line.strip() or line.strip().startswith("#"):
            continue
        lora, _ = parse_character_line(line)
        if lora in targets:
            # Red de seguridad: el fix de '1girl solo' -> '1girl, solo' YA esta
            # aplicado en personajes.txt (L139). Se deja por si alguien restaura
            # un backup viejo: parse_character_line leeria '1girl solo' como un
            # solo tag y el dedupe global no lo veria.
            fixed = re.sub(r"(?<![\w,])1girl solo(?![\w])", "1girl, solo",
                           line.strip())
            if fixed != line.strip():
                print("[fix] %s: '1girl solo' -> '1girl, solo'" % lora)
            rows[lora] = fixed
    return rows


def load_keys(path):
    """Lee claves de un fichero <clave>|<linea> (seccion LISTA de
    pendientes_cuerpo.txt). Ignora comentarios y lineas sin '|'."""
    p = Path(path)
    if not p.is_absolute():
        p = BASE_DIR / p
    keys = []
    for ln in p.read_text(encoding="utf-8-sig").splitlines():
        ln = ln.strip()
        if not ln or ln.startswith("#") or "|" not in ln:
            continue
        keys.append(ln.split("|", 1)[0].strip())
    return keys


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--dry-run", action="store_true")
    ap.add_argument("--seed", type=int, default=-1)
    ap.add_argument("--steps", type=int, default=28)
    ap.add_argument("--nude", action="store_true",
                    help="desnudez al 100%%: sin outfit y force_nude='always'")
    ap.add_argument("--pendientes", action="store_true",
                    help="prueba los 5 con ropa pendiente en vez del piloto")
    ap.add_argument("--keys",
                    help="claves (LoRA) separadas por coma; sobreescribe el set")
    ap.add_argument("--lista",
                    help="fichero de claves (formato <clave>|<linea>, p.ej. "
                         "Archivos/pendientes_cuerpo.txt)")
    ap.add_argument("--n", type=int,
                    help="con --lista: cuantas claves al azar (por defecto todas)")
    ap.add_argument("--pick-seed", type=int, default=42,
                    help="semilla del sorteo con --n (no afecta a la imagen)")
    ap.add_argument("--outfit",
                    help="outfit concreto para el modo outfit (p.ej. "
                         "'school uniform, pleated skirt' o 'bikini')")
    ap.add_argument("--tag",
                    help="nombre de la subcarpeta de salida (con --outfit)")
    args = ap.parse_args()

    if args.keys:
        targets = [k.strip() for k in args.keys.split(",") if k.strip()]
    elif args.lista:
        keys = load_keys(args.lista)
        # Solo se puede auditar lo que find_lines resuelve por nombre de LoRA.
        # El listado incluye lineas sin <lora:> o packs multi-personaje cuya
        # clave NO es el nombre del LoRA: se ignoran (aviso) en vez de abortar.
        all_rows = find_lines(keys)
        missing = [k for k in keys if k not in all_rows]
        if missing:
            print("[i] %d claves sin LoRA resoluble (se ignoran), p.ej.: %s"
                  % (len(missing), ", ".join(missing[:5])))
        valid = [k for k in keys if k in all_rows]
        if args.n:
            import random
            random.seed(args.pick_seed)
            valid = random.sample(valid, min(args.n, len(valid)))
        targets = valid
    elif args.pendientes:
        targets = PENDIENTES_5
    else:
        targets = TARGETS
    force = "always" if args.nude else "auto"

    rows = find_lines(targets)
    faltan = [t for t in targets if t not in rows]
    if faltan:
        raise SystemExit("[!] no encontradas en personajes.txt: %s" % faltan)

    B = PromptBuilder(str(BASE_DIR))
    master = B.master_prompt.strip().rstrip(",")
    neg = B.negative_prompt

    import requests
    try:
        ck = requests.get(f"{FORGE}/sdapi/v1/options", timeout=15).json()
        print("[-] checkpoint: %s" % ck.get("sd_model_checkpoint", "?"))
    except Exception as e:
        raise SystemExit("[!] Forge no responde: %s" % e)

    outfit = args.outfit or OUTFIT
    OUT.mkdir(parents=True, exist_ok=True)
    modo = "nude" if args.nude else "outfit"
    if args.outfit and not args.nude:
        import re as _re
        slug = (args.tag or _re.sub(r"[^a-z0-9]+", "_", args.outfit.lower())
                .strip("_"))[:24]
        modo = "outfit_" + slug
    if args.pendientes:
        modo += "_pend"
    elif args.lista:
        modo += "_lista"
    elif args.keys:
        modo += "_keys"
    out_dir = OUT / modo
    out_dir.mkdir(parents=True, exist_ok=True)
    manifest = []

    for lora in targets:
        # char_line = la linea tal cual, con su <lora:...> al principio
        block = build_block(nude=args.nude, lora=lora, outfit=outfit)
        pos, segs = assemble_prompt(master, rows[lora], block,
                                    force_nude=force)
        # el negative se amplia por personaje para tapar el sesgo del LoRA.
        # NUNCA 'no X' en el positivo: refuerza X (medido con Furina).
        neg_personaje = neg_for(lora, neg) if args.nude else neg
        # el modo --nude DEBE llevar NUDE_TAGS; el modo outfit NO debe llevarlos
        if args.nude:
            assert "completely_nude" in pos, \
                "[ABORTO] se pidio --nude pero no salio NUDE_TAGS"
        else:
            assert "completely_nude" not in pos, \
                "[ABORTO] el outfit no se detecto como ropa y saldria desnuda"
        print("\n" + "=" * 78)
        print("%s" % lora)
        print("=" * 78)
        for i, s in enumerate(segs, 1):
            print("  [%d] %s" % (i, s))
        if lora in PENDIENTES_ROPA:
            print("  ROPA EN LINEA: %s" % ", ".join(PENDIENTES_ROPA[lora]))
        if lora in DEBE_TENER:
            print("  conserva : %s" % ", ".join(DEBE_TENER[lora]))
        if lora in NO_DEBE_TENER:
            print("  quitado  : %s" % ", ".join(NO_DEBE_TENER[lora]))
        if neg_personaje != neg:
            print("  NEG+     : %s" % neg_personaje[len(neg.strip().rstrip(",")) + 2:])

        if args.dry_run:
            continue

        payload = {
            "prompt": pos, "negative_prompt": neg_personaje,
            "width": 832, "height": 1216, "steps": args.steps,
            "cfg_scale": 5.0, "sampler_name": "DPM++ 2M Karras",
            "batch_size": 1, "n_iter": 1, "seed": args.seed, "clip_skip": 2,
        }
        t0 = time.time()
        r = requests.post(f"{FORGE}/sdapi/v1/txt2img", json=payload, timeout=300)
        r.raise_for_status()
        data = r.json()
        try:
            seed = json.loads(data.get("info", "{}")).get("seed", "?")
        except Exception:
            seed = "?"
        safe = lora[:34].replace(" ", "_")
        out = out_dir / ("%s.png" % safe)
        out.write_bytes(base64.b64decode(data["images"][0]))
        print("  [OK] %.0fs seed=%s -> %s" % (time.time() - t0, seed, out.name))
        manifest.append({
            "file": out.name, "lora": lora, "seed": seed,
            "pose": POSE, "outfit": outfit, "prompt": pos, "negative_prompt": neg,
            "conserva": DEBE_TENER.get(lora, []),
            "quitado": NO_DEBE_TENER.get(lora, []),
            "ropa_en_linea": PENDIENTES_ROPA.get(lora, []),
            "char_line_original": rows[lora],
        })

    if args.dry_run:
        print("\n[dry-run] nada generado. Quita --dry-run para generar.")
        return

    stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    man = out_dir / f"manifest_{stamp}.json"
    man.write_text(json.dumps(manifest, ensure_ascii=False, indent=1),
                   encoding="utf-8")
    print("\n[OK] %d imagenes -> %s" % (len(manifest), out_dir))
    print("[OK] manifiesto: %s" % man.name)
    # Aviso al terminar la tanda, UNA vez por claves (los recambios con las
    # mismas claves no repiten). El aviso FINAL de cierre vive en
    # pendientes_cuerpo.py; el de FALLO, en los except de abajo.
    avisar_tanda(targets, len(manifest), modo, args.seed, out_dir)


if __name__ == "__main__":
    try:
        main()
    except SystemExit as e:
        # code 0 = argparse (--help); str = aborto con mensaje (p.ej. Forge caido)
        if isinstance(e.code, str):
            notify("❌ Fallo en test_accesorios: %s" % e.code)
        raise
    except Exception as e:
        notify("❌ Fallo en test_accesorios: %s" % e)
        raise
