﻿"""
WD14 blocks: convierte cada .txt de tags en UNA LINEA de wildcard, sin clasificar.

No es wd14_harvest.py. Ahi se separaba en buckets (pose/scene/outfit/...) y se
tiraba la anatomia. Esto es lo contrario: se conserva el bloque ORDENADO tal cual
sale del tagger y solo se le quitan DOS cosas:

    1. IDENTIDAD  -> pelo, color de pelo, ojos, piel, species, rasgos faciales,
                     y el tag de personaje (fujiwara_chika, arlecchino_(genshin_impact)...)
    2. ROPA       -> toda. El outfit lo pone la linea del personaje + wildcards.

Se CONSERVAN los conteos (1girl, 1boy, hetero) porqueNeeded para que la pose de
dos personas no pelee con el '1girl, solo' del MASTER, y se conservan TODOS los
tags NSFW (pussy, cum, penetration...) y de pose, que es justo lo que se quiere
transplantar.

Entrada :  Archivos/wd14/tags/**/*.txt   (salida de wd14_tagger.py)
Salida  :  Archivos/wildcards_harvest/bloques.txt   <- 1 linea = 1 wildcard
           Archivos/wildcards_harvest/bloques_*.txt (opciones de ropa extra)
           Archivos/wildcards_harvest/bloques_diff.txt (que se quito de cada uno)

    python Engine/wd14_blocks.py
    python Engine/wd14_blocks.py --min-tokens 4      # descarta bloques<muy cortos
"""
import argparse
import csv
import re
from collections import Counter
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DANBOORU = BASE_DIR / "Archivos" / "Danbooru tags.csv"
TAGS_DIR = BASE_DIR / "Archivos" / "wd14" / "tags"
OUT_DIR = BASE_DIR / "Archivos" / "wildcards_harvest"

# --- lo que NUNCA sale: identidad ------------------------------------------------
DROP_IDENTITY = re.compile(
    r"hair|eye|pupil|sclera|bangs|ponytail|braid|twintail|ahoge|sidelock|"
    r"fringe|ears?$|animal_ear|horn|tails?$|wings?$|"
    r"face|facial|freckle|mole|marking|makeup|lipstick|nail|tips?$|toenail|"
    r"skin|_skin|tan$|colored_skin|veins|veiny|body_fur|fur$|"
    r"breast_size|large_breasts|small_breasts|medium_breasts|huge_breasts|"
    r"flat_chest|busty|bouncing_breasts|hanging_breasts|breasts_squeezed_together|"
    r"breasts_apart|thick_thighs|thin_legs|slender|petite|tall$|curvy|plump|"
    r"muscular$|body_curve|wide_hips|narrow_waist|hourglass|thigh_gap|"
    r"child$|loli|shota|minigirl|toddler|aged_|schoolgirl|"
    r"furry|anthropomorphic|anthro|interspecies|elf$|demon_girl$|android|"
    r"robot$|alien$|monster_girl$|dragon_girl$|fox_girl$|wolf_girl$|"
    r"rabbit_girl$|cat_girl$|horse_girl$|cow_girl$|bunny_girl$|sheep_girl$|"
    r"piercing$|barbell_piercing|labret|ear_gauntlet|forehead_jewel|"
    r"^clenched_teeth$|^teeth$|^tongue$|^soles$|^toenail$",
    re.I,
)

# --- lo que NUNCA sale: TODA la ropa ---------------------------------------------
DROP_OUTFIT = re.compile(
    r"shirt|blouse|dress|skirt|panties|^bra$|.*_bra$|bralette|"
    r"thighhighs|thigh_highs|kneehighs|knee_highs|stockings|pantyhose|"
    r"uniform|gloves|socks|shoes|boots|jacket|coat|sweater|apron|"
    r"lingerie|bikini|swimsuit|leotard|bodysuit|harness|collar|^tie$|necktie|"
    r"bow|ribbon|scarf|^belt$|jewelry|earrings|necklace|neckwear|bracelet|"
    r"anklet|wristband|armband|robe|kimono|miko|armor|top$|top_|bottom$|"
    r"legwear|underwear|^pants$|panty|corset|garter|stocking|camisole|"
    r"cardigan|turtleneck|blazer|vest|waistband|headband|ear_cover|"
    r"choker|maid_apron|maid_headdress|frills?|lace|satin|leather|fur_trim|"
    r"highleg|cropped|crop_top|midriff|open_clothes|see-through|nightgown|"
    r"overalls|^bag$|babydoll|playboy_bunny|^thong$|sleeves|"
    r"^hat$|^cap$|clothing|costume|^sunglasses$|^glasses$|"
    # 'clothed_sex', 'clothed_female_nude_male': estado de ropa, no pose. El
    # outfit lo ponemos nosotros, asi que contradicen. OJO: no tocar ^nude$,
    # completely_nude, uncensored -> esos se CONSERVAN (son NSFW real).
    r"^clothed$|^clothed_.+|^nude_(male|female)_|nude_male_|nude_female_|"
    r"clothed_sex$"
    r"^black_|^white_|^red_|^blue_|^green_|^pink_|^purple_|^yellow_|^orange_|^brown_",
    re.I,
)

# texto/calidad: nunca aporta nada al bloque
DROP_JUNK = re.compile(
    r"^[:;=8xXA><*_\-]+$|watermark|signature|username|"
    r"text$|_text$|speech_bubble|thought_bubble|comic|monochrome|greyscale|"
    r"grayscale|border$|logo|patreon|twitter|web_address|meme|lowres|"
    r"pixelated|traditional_media|comment|english|korean|japanese_text|"
    r"oekaki|drawn_focus|commentary|spoken_question_mark|spoken_heart|"
    r"^heart$|heart_(hands|censor)$|^broken_heart$|^breath$|"
    r"motion_lines|speech_lines|focus_comic|panel$|spotlight|"
    #Tags sueltos de un caracter o emoticonos que el tagger emite sueltos
    r"^\W+$",
    re.I,
)

# Nunca tiramos conteos: hacen falta para el MASTER (1girl, solo, hetero...).
# OJO: 'solo_focus'/'male_focus' NO van aqui aunque sean de conteo: son tags de
# ENCUADRE (ver DROP_CAMERA) y estar en las dos listas hacia que se cuelen.
KEEP_COUNTS = re.compile(
    r"^1girl$|^1boy$|^hetero$|^couple$|^solo$|^everyone$|^no_humans$|"
    r"^group_sex$|^1other$",
    re.I,
)
# Plurales y otros conteos que SI se tiran: el bloque describe UNA protagonista.
# Antes '2girls'/'multiple_girls' colaban por el patron generico y acababan
# peleando con el '1girl' del tramo de personaje.
DROP_OTHER_COUNTS = re.compile(
    r"^\d*\+?(girls|boys|others|people|humans)$|^twins$|^multiple_|^mixed_|"
    r"^2girls$|^3girls$|^4girls$|^5girls$|^6\+girls$|"
    r"^2boys$|^3boys$|^4boys$|^5boys$|^6\+boys$",
    re.I,
)

DROP_META = {"the_pose", "posing", "pose", "realistic", "photorealistic",
             "no_outline", "no_shadow", "absurdres", "highres", "best_quality",
             "masterpiece", "score_9", "score_8_up", "score_7_up"}

# --- anatomia que se dibuja mal de base en Illustrious -----------------------------
# Medido con Raiden b02 / Yelan b32 (mismo seed, con y sin freno): con estos tags
# a 1.0 el cuerpo se deforma (culo partido en 4, vagina desplazada) y la camara se
# come el cuadro. A 0.7 la pose aguanta y la deformacion baja. NO son bugs del
# bloque: son tags que el checkpoint no dibuja bien solos.
DOWNWEIGHT = {
    # spread_ass es el culpable REAL de la deformacion de pelvis: verificado en
    # el manifest, 01_b02_Raiden_shogun_bloque_v1 y 02_b02_Hoseki_Zenless_bloque_v2
    # usan spread_ass + ass_focus (NO all_fours) y parten la vagina hacia abajo.
    "spread_ass": 0.6,
    "ass_focus": 0.7,
    "pussy_focus": 0.7,
    "spread_pussy": 0.7,
    # medido (Yelan b32, mismo seed): a 1.0 el modelo dibuja esa zona con mas
    # detalle del debido y la desplaza. A 0.5 se relaja.
    "pussy_peek": 0.5,
    "spread_legs": 0.8,
    "spread_hips": 0.8,
    "large_ass": 0.8,
    "huge_ass": 0.75,
    "giant_ass": 0.7,
}

# --- pose QUE ROMPE la anatomia ---------------------------------------------------
# MEDIDO 07-10-2026 (3 pruebas, mismo personaje Rem, solo lo que cambia es la pose):
#   a) doggystyle, from_behind                        -> FALLA
#   b) all_fours, from_behind, arched_back           -> FUNCIONA
#   c) all_fours, doggystyle, from_behind, kneeling  -> FALLA
# Conclusion: el tag correcto es 'all_fours' (existe, 69.711 usos) PERO necesita
# apoyo: solo, empuja la pelvis y la vagina cae por debajo de donde deberia
# (medido antes en 01_b02_Raiden_shogun_bloque_v1 y 02_b02_Hoseki_Zenless_bloque_v2).
#   - con penetracion            -> doggystyle (implica el acto, no hace falta apoyo)
#   - sin penetracion, 1 persona -> all_fours + from_behind + arched_back
# AVISO: no se puede mezclar 'doggystyle' con una sola persona (implica
# penetracion) ni anadir 'kneeling' (contradice la pose). Medido, no supuesto.
#
# TRAMPA: antes el sustituto era el tag 'doggy', que NO EXISTE en el catalogo de
# Danbooru. El modelo no lo leia como pose y se立的 fallo era invisible: no daba
# error, solo salia otra cosa. Antes de inventar un tag, comprueba que existe en
# Archivos/Danbooru tags.csv.
FLAG_DROP = {"all_fours"}
# OJO: 'penis' NO va aqui. Aparece en cualquier fellatio y haria que un bloque
# de chupada saliera como penetracion. Solo marcadores de penetracion real.
PENETRATION = ("sex", "vaginal", "anal", "penetration", "cum_in_pussy",
               "cum_in_vagina", "creampie", "implied_penetration")
ORAL = ("fellatio", "oral", "blowjob", "paizuri", "titjob", "cum_in_mouth")

# prefijos de COLOR en ropa: se quitan para el pool vestido ('sin color')
CLOTH_COLOR = re.compile(
    r"^(black|white|grey|gray|silver|gold|golden|navy|beige|cream|ivory|"
    r"red|pink|orange|brown|tan|yellow|green|blue|purple|violet|teal|"
    r"multicolored|colored|two-tone|dark|light|bright|deep|pastel|neon)_",
    re.I,
)


def strip_color(tag):
    """'light_blue_thighhighs' -> 'thighhighs'. Repite: el color puede ser doble."""
    prev = None
    t = tag
    while prev != t:
        prev = t
        t = CLOTH_COLOR.sub("", t, count=1)
    return t


# --- ATREZO Y ESTILO: ruido de una ilustracion concreta ----------------------------
# Salido de la muestra de Danbooru (07-10-2026). Son props y recursos artisticos
# que describen ESE dibujo, no una pose reutilizable. La escena va aparte; aqui
# solo queda lo que sirve para trasplantar la postura.
DROP_EJEMPLO = re.compile(
    # atrasos de un dibujo concreto
    r"^virtual_youtuber$|^vp$|^live2d|^nijisanji|^hololive$|"
    r"^median_furrow$|^armpit_crease$|^butterfly_wing$|^collarbone$|"
    # utilidades y rasgos, no postura
    r"^genderswap|^two-sided_|^impossible_clothes$|^alternate_costume$|"
    r"^alternate_breast_size|^jacked_off$|"
    # atrezo de una escena concreta
    r"^halloween|^jack-o-lantern$|^jack-o'-lantern$|^trick_or_treat$|^syringe$|"
    r"^nurse_cap$|^nurse$|^barcode$|^id_card$|^wedding$|^gown$|"
    r"^candy$|^food$|^bread$|^cake$|^pudding$|^dango$|"
    # estilo de dibujo, no contenido
    r"^outline$|^white_outline$|^blue_outline$|^outline_only$|^simple_lineart$|"
    r"^no_lineart$|^sketch$|^monochrome$|^greyscale$|^lineart$",
    re.I,
)

# --- ENCUADRE que NO es contenido --------------------------------------------------
# 'solo_focus' dice "un personaje en foco" y PELA con 1boy+hetero+sex_from_behind:
# el modelo dibuja una sola persona o parte el cuerpo (medido: 17 de 27 bloques
# explicit lo traian; b09 salio 'roto' por esto). Es info de encuadre, no de
# pose, asi que se tira SIEMPRE y deja la camara al CAMERA_BRAKE.
# 'cowboy_shot'/'pov_crotch'/'*_focus' tambien: son encuadre.
DROP_CAMERA = re.compile(
    r"^solo_focus$|^male_focus$|^solo$|^pov_crotch$|"
    r".*_focus$|^focus$|^focused$|"
    r"^cross-section$|^internal_cumshot$|^doll_view$|^x-ray$|^cutaway$",
    re.I,
)

# --- DESCARTE: bloques que no van a ningun pool ------------------------------------
# Flageados por nombre de .txt de ORIGEN (no por indice), porque el indice cambia
# segun el pool que mires y asi el descarte es univoco.
#
# OJO al anadir entradas: el 'b06' que aparece en el nombre de un PNG es el indice
# DENTRO DE SU POOL en el momento de generar, NO el indice global de
# bloques_indice.txt. Son dos numeraciones distintas. Para saber que bloque es una
# imagen, mira su campo 'original_txt' en el manifest, no el numero del nombre.
REJECT = {
    # --- spicy ---
    "sample_288905b07701b49561080c51ec453f46.txt":
        "DESCARTADO (07-10-2026): pose de rodillas + camara desde atras; "
        "deformacion en pies/piernas/culo",

    # --- explicit: veredicto unico DESCARTADO ---
    "sample_1f32d22df91aaf31bbb86f5fdbebe5cf.txt":
        "DESCARTADO (07-10-2026): incoherencia de escena, no sale el acto pedido",
    "c647558aa7dd46f804cd259e2350e710.txt":
        "DESCARTADO (07-10-2026): anatomia ilegible / pose no reconocible",
    "abbaf67647d6b5124dcb079cdf8306ca.txt":
        "DESCARTADO (07-10-2026): no sale el acto sexual pedido",
    "sample_306c6d5fe0bf2506246890a3a81434ef.txt":
        "DESCARTADO (07-10-2026): fusion de piernas, anatomia rota",
    "sample_66af8aa182f78331319562743d935232.txt":
        "DESCARTADO (07-10-2026): veredicto 'Descartado' del usuario al revisar "
        "el prompt (07_b15 / 16_b15, Seed)",
}
REJECT.update({
    # --- muestra de Danbooru (07-10-2026) ---
    # Descartado en la revision visual del usuario. Eveno con la limpieza solo
    # sobrevive 'bent_over'; el resto es atrezo de Halloween
    # (halloween_bucket, syringe, nurse) que no es una pose reutilizable.
    "12348708_bent_over_s.txt":
        "DESCARTADO (07-10-2026): unica pose util (bent_over), resto es atrezo "
        "de Halloween",
})


def replace_flagged(tag, whole_block):
    """Sustituye all_fours por tags que EXISTEN en el vocabulario Danbooru.

    Verificado el 07-10-2026 con 3 pruebas sobre el mismo personaje (Rem):
      a) doggystyle, from_behind                        -> FALLA
      b) all_fours, from_behind, arched_back           -> FUNCIONA
      c) all_fours, doggystyle, from_behind, kneeling  -> FALLA

    Dos cosas que costaron una medicion:
      - 'doggy' NO existe en el catalogo. Antes se devolvia ese tag y el modelo
        no lo leia como pose. Se comprobo contra Archivos/Danbooru tags.csv:
        all_fours existe (69.711 usos), doggy no aparece.
      - 'doggystyle' NO se puede mezclar con una sola persona: implica
        penetracion y contradice la pose. Con 'kneeling' tambien falla.
    Por eso una pose a cuatro patas SIN acte es: all_fours + los tags de apoyo.
    """
    t = tag.lower()
    if t == "doggystyle":
        return []                      # ya decia doggystyle: all_fours sobra
    if any(p in whole_block for p in PENETRATION):
        return ["doggystyle"]          # hay penetracion: ese es el tag real
    # sin penetracion: la postura a cuatro patas, con apoyo de camara y arco
    extra = ["all_fours"]
    if "from_behind" not in whole_block:
        extra.append("from_behind")
    if "arched_back" not in whole_block:
        extra.append("arched_back")
    return extra


# --- severidad: NO usar la carpeta de origen, no dice nada ------------------------
# Medido: hay 1 imagen en NSFW/ que es spicy y 1 en SFW/ que es explicit. La
# severidad se saca de los TAGS, no del nombre de la carpeta.
#   explicit -> hay ACTO (penetracion o fellatio/paizuri) o fluido
#   spicy    ->suggestivo / desnudo / anatomia, pero sin acto
#   sfw      -> nada de lo anterior
SEVERITY_EXPLICIT = (
    "sex", "vaginal", "penetration", "anal", "anal_sex", "creampie",
    "fellatio", "blowjob", "paizuri", "titjob", "handjob", "footjob",
    "cum_in_mouth", "cum_in_pussy", "cum_in_vagina", "cum_overflow",
    "ejaculation", "cumshot", "deepthroat", "after_sex", "netorare",
    "group_sex", "threesome", "standing_sex", "cowgirl_position",
    "reverse_cowgirl_position", "missionary", "doggystyle", "doggy",
    "69_position", "sex_from_behind", "spread_legs", "pov_crotch",
)
# OJO: 'pussy_juice' NO es un acto. Es un fluido: puede salir en un pose solo
# deadexna sin penetracion. Con el b26 (solo, underboob, covered_nipples) se
# colaba en explicit cuando es spicy. Los fluidos van aqui, no arriba.
SEVERITY_SEXUAL = (
    "penis", "pussy", "anus", "nipples", "testicles", "clitoris", "erection",
    "uncensored", "topless", "bottomless", "ahegao", "spread_ass", "ass_focus",
    "pussy_peek", "between_breasts", "sideboob", "underboob", "pussy_juice",
    "cum", "covered_nipples", "cum_on_body", "cum_on_breasts", "internal_cumshot",
    "cross-section",
)
SEVERITY_NUDITY = ("nude", "completely_nude", "partially_nude", "undressed")


def severity(block):
    """'explicit' | 'spicy' | 'sfw' a partir de los tags del bloque."""
    s = {t.lower() for t in block}
    if s & set(SEVERITY_EXPLICIT):
        return "explicit"
    if s & set(SEVERITY_SEXUAL):
        return "spicy"
    if s & set(SEVERITY_NUDITY):
        return "spicy"
    return "sfw"


def apply_weight(tag):
    """(tag:peso) si el tag esta en DOWNWEIGHT. Idempotente."""
    w = DOWNWEIGHT.get(tag)
    return f"({tag}:{w})" if w else tag


def strip_weights_of(block):
    """Invierte apply_weight sobre un bloque entero: '(ass_focus:0.7), nude'
    -> 'ass_focus, nude'. Para comparar bloques y para buscar tags."""
    return re.sub(r"\(([^:()]+):[\d.]+\)", r"\1", block)


def load_character_tags():
    """Tags que el CSV Danbooru marca como personaje(4) o copyright(3)."""
    chars, copies, alias = set(), set(), {}
    if not DANBOORU.exists():
        return chars, copies, alias
    with DANBOORU.open(newline="", encoding="utf-8") as f:
        for row in csv.DictReader(f):
            tag, cat = row["tag"].lower(), row["type"]
            if cat == "4":
                chars.add(tag)
            elif cat == "3":
                copies.add(tag)
            if row.get("aliases"):
                for a in row["aliases"].split(","):
                    a = a.strip().lower()
                    if a and a != tag:
                        alias[a] = tag
    return chars, copies, alias


def clean_block(tags, chars, copies, keep_outfit=False):
    """Quita identidad + basura + (por defecto) toda la ropa. KEEP conteos/pose/NSFW.

    Respeta el orden original del tagger. keep_outfit=True deja la ropa pero SIN
    color (para el A/B focalizado en bloques que si traian ropa).
    """
    # el contexto se decide sobre el bloque COMPLETO, no tag a tag
    joined = " ".join(t.lower() for t in tags)
    keep, dropped = [], []
    for tag in tags:
        t = tag.strip()
        low = t.lower()
        if not t:
            continue
        # el tag de personaje (fujiwara_chika) o copyright: fuera siempre
        if low in chars or low in copies:
            dropped.append((t, "personaje"))
            continue
        # conteos: NUNCA se tiran (los necesita el MASTER para 2 personas)
        if KEEP_COUNTS.match(low) or low in DROP_META:
            if KEEP_COUNTS.match(low):
                keep.append(t)
            else:
                dropped.append((t, "meta"))
            continue
        if low in FLAG_DROP:
            for rep in replace_flagged(low, joined):
                keep.append(rep)
            dropped.append((t, "POSE ROTA -> sustituida"))
            continue
        if DROP_OTHER_COUNTS.match(low):
            dropped.append((t, "conteo de otras personas"))
            continue
        if DROP_EJEMPLO.search(low):
            dropped.append((t, "atrezo/estilo del dibujo"))
            continue
        if DROP_CAMERA.search(low):
            dropped.append((t, "encuadre (no contenido)"))
            continue
        if DROP_JUNK.search(low):
            dropped.append((t, "basura"))
            continue
        if DROP_IDENTITY.search(low):
            dropped.append((t, "identidad"))
            continue
        if DROP_OUTFIT.search(low):
            if keep_outfit:
                c = strip_color(low)
                if c and c != low:
                    dropped.append((t, f"color fuera -> {c}"))
                    keep.append(c)
                else:
                    keep.append(low)
            else:
                dropped.append((t, "ropa"))
            continue
        keep.append(t)
    return keep, dropped


def main():
    p = argparse.ArgumentParser(description="Tags WD14 -> 1 linea de wildcard por imagen")
    p.add_argument("--tags", default=str(TAGS_DIR))
    p.add_argument("--out", default=str(OUT_DIR))
    p.add_argument("--min-tokens", type=int, default=4,
                   help="descarta bloques con menos tokens utiles (default 4)")
    args = p.parse_args()

    src = Path(args.tags)
    if not src.exists():
        raise SystemExit(f"[!] No existe {src}. Ejecuta antes wd14_tagger.py")
    files = sorted(src.rglob("*.txt"))
    if not files:
        raise SystemExit(f"[!] No hay .txt en {src}")

    chars, copies, alias = load_character_tags()
    print(f"[-] {len(files)} bloques | personaje:{len(chars)} copyright:{len(copies)}")

    OUT = Path(args.out)
    OUT.mkdir(parents=True, exist_ok=True)

    blocks, dressed, diffs = [], [], []
    rejected = []
    for f in files:
        raw = [t.strip() for t in f.read_text(encoding="utf-8").split(",") if t.strip()]
        canon = [alias.get(t.lower(), t.lower()) for t in raw]
        origin = f.relative_to(src).as_posix()
        name = f.name

        def dedupe(seq):
            seen, out = set(), []
            for t in seq:
                if t not in seen:
                    seen.add(t)
                    out.append(t)
            return out

        keep, dropped = clean_block(canon, chars, copies, keep_outfit=False)
        uniq = dedupe(keep)
        # el pool vestido se calcula sobre el MISMO bloque, sin filtrar por
        # min-tokens, para que el indice i coincida en los dos ficheros (A/B)
        keep_d, _ = clean_block(canon, chars, copies, keep_outfit=True)
        uniq_d = dedupe(keep_d)

        # descarte por nombre de origen
        if name in REJECT:
            reason = REJECT[name]
            pending = reason.startswith("REVISAR")
            rejected.append((origin, reason, uniq, pending))
            continue

        if len(uniq) < args.min_tokens:
            continue
        blocks.append(uniq)
        dressed.append(uniq_d)
        # la correspondencia con el .txt original es SIN pesos: el manifest y
        # find_original() comparan el bloque crudo, no el ponderado
        diffs.append((origin, len(raw), len(uniq), dropped))

    # una linea = un bloque = una opcion de wildcard
    with (OUT / "bloques.txt").open("w", encoding="utf-8", newline="\n") as fh:
        fh.write("# WD14 bloques: 1 linea = 1 opcion de wildcard\n")
        fh.write(f"# {len(blocks)} bloques de {len(files)} imagenes "
                 f"(min-tokens={args.min_tokens})\n")
        fh.write("# identidad (pelo/ojos/piel/species) y TODA la ropa fuera; "
                 "pose+NSFW+escena conservados\n")
        if DOWNWEIGHT:
            fh.write("# anatomia de foco a peso bajo: "
                     + ", ".join(f"{k}:{v}" for k, v in DOWNWEIGHT.items()) + "\n")
        if FLAG_DROP:
            fh.write("# POSE ROTA (flag, sustituida): " + ", ".join(sorted(FLAG_DROP)) + "\n")
        fh.write("\n")
        for b in blocks:
            fh.write(", ".join(apply_weight(t) for t in b) + "\n")

    # pool gemelo: MISMA ropa pero sin color, para A/B focalizado
    with (OUT / "bloques_vestidos.txt").open("w", encoding="utf-8", newline="\n") as fh:
        fh.write("# WD14 bloques VESTIDOS: 1 linea = 1 opcion de wildcard\n")
        fh.write(f"# {len(dressed)} bloques - MISMO orden que bloques.txt "
                 f"(indice i comparable para A/B)\n")
        fh.write("# identidad fuera; ropa conservada pero SIN color\n")
        if FLAG_DROP:
            fh.write("# POSE ROTA (flag, sustituida): " + ", ".join(sorted(FLAG_DROP)) + "\n")
        fh.write("\n")
        for b in dressed:
            fh.write(", ".join(apply_weight(t) for t in b) + "\n")

    # --- pools por severidad -------------------------------------------------------
    # La carpeta de origen NO es fiable (hay spicy en NSFW/ y explicit en SFW/),
    # asi que la severidad sale de los tags. Un bloque vive en UN solo pool.
    SEV_LABEL = {
        "explicit": "ACTO SEXUAL explicito (penetracion / fellatio / fluido)",
        "spicy": "SUGGESTIVO: desnudo o anatomia, pero SIN acto",
        "sfw": "SEGURO: nada de desnudo ni acto",
    }
    sev_count = Counter()
    for i, b in enumerate(blocks):
        sev_count[severity(b)] += 1
    sfw_idx = [i for i, b in enumerate(blocks) if severity(b) == "sfw"]

    for sev in ("sfw", "spicy", "explicit"):
        idxs = [i for i, b in enumerate(blocks) if severity(b) == sev]
        if not idxs:
            continue
        name = OUT / f"bloques_{sev}.txt"
        with name.open("w", encoding="utf-8", newline="\n") as fh:
            fh.write(f"# WD14 bloques -- {sev.upper()}: {SEV_LABEL[sev]}\n")
            fh.write(f"# {len(idxs)} bloques (de {len(blocks)})\n")
            fh.write("# 1 linea = 1 opcion de wildcard. identidad fuera, ropa fuera.\n")
            if DOWNWEIGHT:
                fh.write("# anatomia a peso bajo: "
                         + ", ".join(f"{k}:{v}" for k, v in DOWNWEIGHT.items()) + "\n")
            if FLAG_DROP:
                fh.write("# POSE ROTA (flag): " + ", ".join(sorted(FLAG_DROP)) + "\n")
            fh.write("\n")
            for i in idxs:
                fh.write(", ".join(apply_weight(t) for t in blocks[i]) + "\n")

    # indice bloque -> severidad, para saber de donde salio cada linea
    with (OUT / "bloques_indice.txt").open("w", encoding="utf-8", newline="\n") as fh:
        fh.write("# indice de bloques.txt -> severidad y .txt de origen\n")
        fh.write("# formato: <idx>\t<severidad>\t<origen>\n\n")
        for i, b in enumerate(blocks):
            fh.write(f"{i}\t{severity(b)}\t{diffs[i][0]}\n")

    # --- pools VESTIDOS por severidad ---------------------------------------------
    # Solo para los bloques donde la version vestida APORTA algo: si no hay ropa
    # que recuperar la linea seria identica al desnudo y no daria un A/B util.
    # La severidad se hereda del bloque DESNUDO (poner ropa no decide si un
    # bloque es explicit), asi que el indice i sigue siendo comparable.
    clothed_per_sev = Counter()
    for sev in ("sfw", "spicy", "explicit"):
        idxs = [i for i in range(len(blocks))
                if severity(blocks[i]) == sev and len(dressed[i]) > len(blocks[i])]
        clothed_per_sev[sev] = len(idxs)
        if not idxs:
            continue
        with (OUT / f"bloques_{sev}_vestidos.txt").open(
                "w", encoding="utf-8", newline="\n") as fh:
            fh.write(f"# WD14 bloques -- {sev.upper()} VESTIDOS: ropa del WD14 sin color\n")
            fh.write(f"# {len(idxs)} bloques (solo los que ropa aporta algo)\n")
            fh.write("# 1 linea = 1 opcion de wildcard. identidad fuera.\n")
            if DOWNWEIGHT:
                fh.write("# anatomia a peso bajo: "
                         + ", ".join(f"{k}:{v}" for k, v in DOWNWEIGHT.items()) + "\n")
            if FLAG_DROP:
                fh.write("# POSE ROTA (flag): " + ", ".join(sorted(FLAG_DROP)) + "\n")
            fh.write("\n")
            for i in idxs:
                fh.write(", ".join(apply_weight(t) for t in dressed[i]) + "\n")

    # --- pools VESTIDOS por severidad ---------------------------------------------
    # Solo para los bloques donde la version vestida APOSTA algo: si no hay ropa
    # que recuperar, la linea seria identica al desnudo y no aporta un A/B.
    # La severidad se hereda del bloque DESNUDO (poner ropa no cambia si un
    # bloque es explicit), asi que el indice i sigue siendo comparable.
    clothed_per_sev = Counter()
    for sev in ("sfw", "spicy", "explicit"):
        idxs = [i for i in range(len(blocks))
                if severity(blocks[i]) == sev and len(dressed[i]) > len(blocks[i])]
        clothed_per_sev[sev] = len(idxs)
        if not idxs:
            continue
        with (OUT / f"bloques_{sev}_vestidos.txt").open(
                "w", encoding="utf-8", newline="\n") as fh:
            fh.write(f"# WD14 bloques -- {sev.upper()} VESTIDOS: ropa del WD14 sin color\n")
            fh.write(f"# {len(idxs)} bloques (solo los que ropa aportan algo)\n")
            fh.write("# 1 linea = 1 opcion de wildcard. identidad fuera.\n")
            if DOWNWEIGHT:
                fh.write("# anatomia a peso bajo: "
                         + ", ".join(f"{k}:{v}" for k, v in DOWNWEIGHT.items()) + "\n")
            if FLAG_DROP:
                fh.write("# POSE ROTA (flag): " + ", ".join(sorted(FLAG_DROP)) + "\n")
            fh.write("\n")
            for i in idxs:
                fh.write(", ".join(apply_weight(t) for t in dressed[i]) + "\n")

    with (OUT / "bloques_diff.txt").open("w", encoding="utf-8", newline="\n") as fh:
        fh.write("# que se quito de cada bloque (revisar a mano)\n")
        fh.write(f"# formato: <archivo>  (<quedado>/<original>)\n\n")
        for name, raw_n, keep_n, dropped in diffs:
            fh.write(f"{name}  ({keep_n}/{raw_n})\n")
            if dropped:
                fh.write("  - " + ", ".join(f"{t}[{r}]" for t, r in dropped) + "\n")
            fh.write("\n")

    n_reject = sum(1 for r in rejected if not r[3])
    n_pending = sum(1 for r in rejected if r[3])

    with (OUT / "bloques_descartados.txt").open("w", encoding="utf-8", newline="\n") as fh:
        fh.write("# bloques DESCARTADOS / PENDIENTES: no entran a ningun pool\n")
        fh.write(f"# {len(rejected)} de {len(files)} imagenes "
                 f"({n_reject} descartados, {n_pending} pendientes de revisar)\n")
        fh.write("# flageados por auditoria visual (Output/test_blocks/*/)\n\n")
        for origin, why, blk, pending in rejected:
            fh.write(f"{'[PENDIENTE] ' if pending else ''}{origin}\n")
            fh.write(f"  motivo: {why}\n")
            fh.write(f"  bloque: {', '.join(blk)}\n\n")

    kept = sum(len(b) for b in blocks)
    total = sum(raw_n for _, raw_n, _, _ in diffs)
    print(f"[-] {len(blocks)} bloques | {kept}/{total} tokens conservados "
          f"({kept/total*100:.0f}%)")
    print("\nPOOLS POR SEVERIDAD (sacada de los tags, NO de la carpeta):")
    for sev in ("sfw", "spicy", "explicit"):
        n = sev_count.get(sev, 0)
        cn = clothed_per_sev.get(sev, 0)
        print(f"  {sev:9} {n:2} desnudos  +  {cn:2} vestidos  "
              f"-> bloques_{sev}.txt / bloques_{sev}_vestidos.txt")
    print(f"\n[OK] bloques.txt            ({len(blocks)} lineas, union)")
    print(f"[OK] bloques_vestidos.txt   ({len(dressed)} lineas, ropa sin color, mismo indice)")
    print(f"[OK] bloques_{{sfw,spicy,explicit}}.txt  (pools separados)")
    print(f"[OK] bloques_indice.txt      (idx -> severidad + .txt origen)")
    print(f"[OK] bloques_diff.txt        (que se quito de cada bloque)")
    if rejected:
        print(f"[--] {len(rejected)} fuera de los pools: "
              f"{n_reject} descartados + {n_pending} pendientes de revisar "
              f"(bloques_descartados.txt)")
    if sfw_idx:
        ej = ", ".join(apply_weight(t) for t in blocks[sfw_idx[0]])[:110]
        print(f"\n     ejemplo sfw: {ej}...")


if __name__ == "__main__":
    main()
