"""
pack_builder: PLANIFICADOR y GENERADOR de packs de contenido.

Coge el banco de poses validadas (pose_bank) + los pools de exploracion
(wildcards_harvest / wildcards_danbooru / danbooru) y compone la lista de trabajos
(quotas por nivel, poses aprobadas + exploracion controlada, un angulo de
camara por job). El prompt de cada job se monta con build_prompt() de
test_blocks.py, asi que el MASTER, el BREAK y los 5 LoRAs de estilo quedan
intactos.

    # Planificar (no toca Forge): imprime el plan y sale.
    python Engine/pack_builder.py --char Furina --theme playa --dry-run
    python Engine/pack_builder.py --char Furina --theme playa --target 45 \
        --levels 15,16,14 --explore 0.2 --max-variants 2 --dry-run

    # Generar un piloto pequeno (N imagenes) en Output/pilots/<stamp>/
    python Engine/pack_builder.py --pilot 3 --char Furina

    # Generar un pack completo en Output/packs/<clave>_<tema>/
    python Engine/pack_builder.py --pack --char Furina --theme playa --target 45

    # La seed es aleatoria por imagen. --seed N la fija: usalo solo para
    # reproducir o corregir una imagen concreta, no para un pack entero.

El banco se lee de Archivos/pose_bank/ salvo que exista PLUMLOOM_BANK_DIR
(lo usan las verificaciones para no tocar el banco real). Forge se lee de
PLUMLOOM_FORGE (default http://127.0.0.1:7860).
"""
import argparse
import base64
import json
import os
import random
import re
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path

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

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

# Aviso por Telegram (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))

import pose_bank  # noqa: E402
from danbooru_harvest import strip_scene_tags  # noqa: E402
from prompt_builder import PromptBuilder  # noqa: E402
from pose_scene import load_affinity, validate_pose_scene  # noqa: E402
from stages import (STAGE_LEVEL, STAGES, dress_for_stage, dress_negative,
                    force_nude_for, load_stage_cameras, parse_stage_quotas,
                    stage_allows_pose, stage_for_record)  # noqa: E402
from test_blocks import build_prompt  # noqa: E402
from watermark import watermark_dir  # noqa: E402

LEVELS = ("sugerente", "spicy", "explicito")

FORGE = os.environ.get("PLUMLOOM_FORGE", "http://127.0.0.1:7860")

PILOTS_DIR = BASE_DIR / "Output" / "pilots"
PACKS_DIR = BASE_DIR / "Output" / "packs"

HARVEST = BASE_DIR / "Archivos" / "wildcards_harvest"
DANBOORU = BASE_DIR / "Archivos" / "wildcards_danbooru"
EJE_A = BASE_DIR / "Archivos" / "danbooru"

# Los pools de exploracion por nivel: cosecha (WD14) + muestra Danbooru.
LEVEL_POOLS = {
    "sugerente": [HARVEST / "bloques_sfw.txt", DANBOORU / "bloques_sfw.txt", EJE_A / "bloques_sfw.txt"],
    "spicy": [HARVEST / "bloques_spicy.txt", DANBOORU / "bloques_spicy.txt", EJE_A / "bloques_spicy.txt"],
    "explicito": [HARVEST / "bloques_explicit.txt", DANBOORU / "bloques_explicit.txt", EJE_A / "bloques_explicit.txt"],
}

CAMERAS_FILE = BASE_DIR / "Archivos" / "progression_wildcards" / "cameras.txt"

THEMES_FILE = BASE_DIR / "Archivos" / "danbooru" / "themes.json"

DEFAULT_QUOTAS = {"sugerente": 15, "spicy": 16, "explicito": 14}

# Eje de vestuario por nivel para `--dress auto` (escalada 1->7 de la progresion).
LEVEL_DRESS = {"sugerente": "dressed", "spicy": "stripping", "explicito": "nude"}

# Se anade al NEGATIVE (nunca al positive) con `--dress dressed|stripping`.
# Sin `nude`/`naked`/`nsfw`/`explicit`: el modelo se resiste (AGENTS.md). Sin
# `undressing`: pelea con el nivel `stripping`.
DRESS_NEG = "nipples, topless"

# `--clean-poses`: tags de la pool line que pelean con el stage o con el tema.
# Solo se RETIRAN (nunca se anaden): estado de ropa si el stage trae outfit,
# encuadre si el stage fija camara, y fondo/lugar si el tema fija escena.
_CLEAN_STATE = frozenset({
    "nude", "completely nude", "naked", "topless", "bottomless", "nipples",
    "breasts out", "covered nipples", "clothed", "clothed sex", "underwear",
    "panties", "bra", "no bra", "no panties", "naked towel", "towel",
    "bikini", "swimsuit", "undressing", "partially undressed",
})
_CLEAN_FRAMING = frozenset({
    "cowboy shot", "upper body", "lower body", "close-up", "closeup",
    "portrait", "thighs", "midriff", "cleavage focus", "ass focus",
    "foot focus", "full body", "wide shot", "dutch angle", "from above",
    "from below", "from side", "from behind", "from front",
})
_CLEAN_PLACE = frozenset({
    "simple background", "white background", "grey background",
    "black background", "gradient background", "indoors", "outdoors",
    "on bed", "bed", "pillow", "blanket", "night", "day", "wall", "tree",
    "grass", "floor", "window", "door", "ceiling", "sky", "water", "fire",
    "candle", "curtains", "bathtub",
})
_WEIGHT_RE = re.compile(r"\(([^():]+):[0-9.]+\)")


def _bare_tag(token) -> str:
    """Tag sin pesos ni parentesis, `_`->espacio y en minusculas."""
    text = _WEIGHT_RE.sub(r"\1", str(token))
    text = text.strip().strip("()[]{}").strip()
    return text.replace("_", " ").lower()


def clean_pose_line(job) -> str:
    """Pose sin los tags que contradicen el stage o el tema (solo quita)."""
    drop = set()
    if job.camera:
        drop |= _CLEAN_FRAMING
    if job.outfit_line:
        drop |= _CLEAN_STATE
    if job.scene_line:
        drop |= _CLEAN_PLACE
    if not drop:
        return job.pose_line
    kept = []
    for token in (job.pose_line or "").split(","):
        text = token.strip()
        if not text or _bare_tag(text) in drop:
            continue
        kept.append(text)
    return ", ".join(kept)


@dataclass
class Job:
    nivel: str
    pose_line: str
    origin: str               # "bank" | "explore"
    pose_id: str | None       # None si origin == "explore"
    tema: list[str]           # [theme] (o el tema de la entrada del banco)
    camera: str
    # Campos de tema (Task 6). Defaults para que el planner sin tema siga valido.
    outfit_line: str = ""     # ropa -> tramo de la chica (segmento 2)
    scene_line: str = ""      # escena -> bloque de pose
    light_line: str = ""      # luz -> bloque de pose, antes de la camara
    dress: str = "auto"       # auto | dressed | stripping | nude
    variant_keys: dict = field(default_factory=dict)  # {outfit,scene,lighting,props}
    props_line: str = ""      # decoracion (props) -> bloque de pose, tras scene
    stage: str | None = None  # sugerente|spicy1|spicy2|explicit1|explicit2
    outfit_base: str | None = None
    outfit_key: str | None = None
    outfit_fallback: bool = False
    pose_scene_warnings: list = field(default_factory=list)
    clean_poses: bool = False  # --clean-poses: limpia la pose de contradicciones
    seed: int | None = None    # --seed-per-stage: semilla propia del job


def load_cameras() -> list:
    """Angulos/encuadres de cameras.txt, sin comentarios ni lineas vacias."""
    if not CAMERAS_FILE.exists():
        return []
    out = []
    for line in CAMERAS_FILE.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if line and not line.startswith("#"):
            out.append(line)
    return out


def load_pool_lines(nivel) -> list:
    """Lineas de bloque de los dos pools del nivel (dedup, sin # ni vacias)."""
    out = []
    seen = set()
    for path in LEVEL_POOLS.get(nivel, []):
        if not path.exists():
            continue
        for line in path.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            if line not in seen:
                seen.add(line)
                out.append(line)
    return out


def _take_bank(bank, nivel, n, max_variants, rng, used=None) -> list:
    """Hasta n entradas del banco (nivel), cada pose reutilizable max_variants
    veces. `sample` ya excluye avoid y los ids que se le pasan. `used` permite
    compartir el contador entre varias llamadas (stages del mismo nivel)."""
    if n <= 0:
        return []
    max_variants = max(1, int(max_variants))
    chosen = []
    usage = used if used is not None else {}
    while len(chosen) < n:
        exclude = {eid for eid, c in usage.items() if c >= max_variants}
        need = n - len(chosen)
        got = pose_bank.sample(bank, nivel=nivel, exclude_ids=exclude,
                               n=need, rng=rng)
        if not got:
            break
        for e in got:
            chosen.append(e)
            usage[e.get("id")] = usage.get(e.get("id"), 0) + 1
    return chosen


def _take_explore(lines, avoid, n, rng) -> list:
    """n lineas al azar (sin repetir si alcanza; ciclando si no). Excluye avoid."""
    if n <= 0:
        return []
    eligible = [ln for ln in lines if not pose_bank.is_avoided(ln, avoid)]
    if not eligible:
        return []
    if n <= len(eligible):
        return rng.sample(eligible, n)
    out = []
    while len(out) < n:
        chunk = list(eligible)
        rng.shuffle(chunk)
        out.extend(chunk)
    return out[:n]


def _pinned_by_level(bank, bank_ids) -> dict:
    """`--bank-ids` -> {nivel: [entrada, ...]} en el orden pedido."""
    if not bank_ids:
        return {}
    by_id = {e.get("id"): e for e in bank}
    out = {}
    for pid in bank_ids:
        entry = by_id.get(pid)
        if not entry:
            raise SystemExit(f"[!] --bank-ids: '{pid}' no existe en el banco")
        out.setdefault(entry.get("nivel"), []).append(entry)
    return out


def _norm_tag(text):
    return text.lower().replace("_", " ").strip()


CAMERA_BLOCKS = {
    "lying": {"back view", "from behind", "ass focus"},
    "on side": {"back view", "from behind", "ass focus"},
    "on back": {"back view", "from behind", "ass focus"},
    "missionary": {"back view", "from behind", "over the shoulder", "ass focus"},
    "against wall": {"back view", "from above", "ass focus", "legs"},
    "pov": {"from side", "from behind", "back view", "profile"},
}
FORCE_SIDE = {"lying", "on side", "missionary", "against wall"}


def camera_ok(camera, pose_line):
    """Evita que el angulo final anule la pose (p.ej. back_view sobre lying)."""
    toks = {_norm_tag(t) for t in pose_line.split(",")}
    cam = _norm_tag(camera)
    if toks & FORCE_SIDE and "from side" in cam:
        return True
    for tag, blocked in CAMERA_BLOCKS.items():
        if tag in toks and cam in blocked:
            return False
    return True


def assign_cameras(jobs, cameras, stage_cameras=None):
    """Asigna camara por stage (o general) sin forzar `from side`."""
    for i, job in enumerate(jobs):
        pool = cameras or []
        if job.stage and stage_cameras is not None and job.stage in stage_cameras:
            pool = stage_cameras[job.stage]
        if not pool:
            job.camera = ""
            continue
        start = i % len(pool)
        for k in range(len(pool)):
            cam = pool[(start + k) % len(pool)]
            if camera_ok(cam, job.pose_line):
                job.camera = cam
                break
        else:
            job.camera = pool[start]


def _assign_cameras_legacy(jobs, cameras):
    """Comportamiento legacy de `compose_jobs`: prioriza from side en ciertas poses."""
    if not cameras:
        return
    for i, job in enumerate(jobs):
        toks = {_norm_tag(t) for t in job.pose_line.split(",")}
        if toks & FORCE_SIDE:
            for cam in cameras:
                if _norm_tag(cam) == "from side":
                    job.camera = cam
                    break
            else:
                job.camera = cameras[i % len(cameras)]
            continue
        start = i % len(cameras)
        for k in range(len(cameras)):
            cam = cameras[(start + k) % len(cameras)]
            if camera_ok(cam, job.pose_line):
                job.camera = cam
                break
        else:
            job.camera = cameras[start]


def compose_jobs(target, quotas, explore, bank, avoid, pools, cameras,
                 max_variants, rng, bank_ids=None) -> list:
    """Compone la lista de Job: quotas por nivel, poses del banco + exploracion.

    Regla: por nivel, count = round(target * quotas[nivel] / sum(quotas));
    explore_n = round(count * explore); bank_n = count - explore_n. Si el banco
    da menos de bank_n, el resto se rellena desde pools[nivel] (explore).
    """
    quotas = {lv: int(quotas.get(lv, 0)) for lv in LEVELS}
    total = sum(quotas.values())
    pinned = _pinned_by_level(bank, bank_ids)
    jobs = []
    for nivel in LEVELS:
        if total > 0:
            count = round(target * quotas[nivel] / total)
        else:
            count = 0
        if count <= 0:
            continue
        explore_n = round(count * explore)
        bank_n = count - explore_n

        if pinned.get(nivel):
            cycle = pinned[nivel]
            bank_entries = [cycle[i % len(cycle)] for i in range(bank_n)]
        else:
            bank_entries = _take_bank(bank, nivel, bank_n, max_variants, rng)
        for e in bank_entries:
            jobs.append(Job(
                nivel=nivel,
                pose_line=e.get("pose_line", ""),
                origin="bank",
                pose_id=e.get("id"),
                tema=list(e.get("tema") or []),
                camera="",
            ))

        shortfall = bank_n - len(bank_entries)
        n_explore = explore_n + max(0, shortfall)
        for line in _take_explore(pools.get(nivel, []), avoid, n_explore, rng):
            jobs.append(Job(
                nivel=nivel,
                pose_line=line,
                origin="explore",
                pose_id=None,
                tema=[],
                camera="",
            ))

    _assign_cameras_legacy(jobs, cameras)
    return jobs


def compose_stage_jobs(target, stage_quotas, explore, bank, avoid, pools,
                       max_variants, rng, bank_ids=None) -> list:
    """Compone jobs por stage (5 etapas), compartiendo el uso del banco por nivel.

    No asigna camara: eso lo hace `assign_cameras` en un paso posterior.
    """
    quotas = {stage: int((stage_quotas or {}).get(stage, 0))
              for stage in STAGES}
    total = sum(quotas.values())
    pinned = _pinned_by_level(bank, bank_ids)
    used = {}
    jobs = []
    for stage in STAGES:
        count = round(target * quotas[stage] / total) if total > 0 else 0
        if count <= 0:
            continue
        level = STAGE_LEVEL[stage]
        explore_n = round(count * explore)
        bank_n = count - explore_n

        eligible_bank = [entry for entry in bank
                         if stage_allows_pose(stage, entry.get("pose_line", ""))]
        pinned_entries = [entry for entry in pinned.get(level, [])
                          if stage_allows_pose(stage, entry.get("pose_line", ""))]
        if pinned_entries:
            cycle = pinned_entries
            bank_entries = [cycle[i % len(cycle)] for i in range(bank_n)]
        else:
            bank_entries = _take_bank(eligible_bank, level, bank_n,
                                      max_variants, rng, used=used)
        for entry in bank_entries:
            jobs.append(Job(
                nivel=level,
                pose_line=entry.get("pose_line", ""),
                origin="bank",
                pose_id=entry.get("id"),
                tema=list(entry.get("tema") or []),
                camera="",
                stage=stage,
            ))

        shortfall = bank_n - len(bank_entries)
        n_explore = explore_n + max(0, shortfall)
        eligible_pools = [line for line in pools.get(level, [])
                          if stage_allows_pose(stage, line)]
        for line in _take_explore(eligible_pools, avoid, n_explore, rng):
            jobs.append(Job(
                nivel=level,
                pose_line=line,
                origin="explore",
                pose_id=None,
                tema=[],
                camera="",
                stage=stage,
            ))
    return jobs


def _compose_stage_theme_jobs(target, stage_quotas, variation, variants, dress,
                             explore, bank, avoid, pools, rng, resolved,
                             max_variants, bank_ids, allow_legacy_outfit,
                             strict_scene, affinity, cameras, stage_cameras):
    jobs = compose_stage_jobs(target, stage_quotas, explore, bank, avoid,
                              pools, max_variants, rng, bank_ids=bank_ids)
    free = (dress == "free")
    variants = variants or {}

    if not free and dress != "auto":
        print("[!] --dress %s se aplica a todos los stages" % dress)
    if affinity is None:
        affinity = load_affinity()

    outfit_state = {}
    for name, theme in resolved:
        if free:
            outfit_state[name] = (None, None, False)
            continue
        story = theme.get("outfit_story")
        if not isinstance(story, dict) or not story:
            if allow_legacy_outfit:
                outfit_state[name] = (None, None, True)
                continue
            raise SystemExit(
                "[!] tema '%s' sin outfit_story; usa --allow-legacy-outfit "
                "o define outfit_story en themes.json" % name)
        try:
            base, entry = choose_outfit_story(theme, variants.get("outfit"), rng)
        except ValueError as exc:
            raise SystemExit("[!] outfit_story de '%s': %s" % (name, exc))
        outfit_state[name] = (base, entry, False)

    cache = {}
    kept = []
    for i, job in enumerate(jobs):
        name, theme = resolved[i % len(resolved)]
        base, entry, fallback = outfit_state[name]
        if free:
            job_dress = "auto"
        else:
            job_dress = dress_for_stage(
                job.stage, None if dress == "auto" else dress)

        outfit_key = None
        outfit_line = ""
        if free:
            pass
        elif job_dress == "nude":
            # Un stage desnudo no lleva ropa: chocaria con `force_nude`
            # (bikini + nude en el positive). Los temas con outfit_story ya
            # devuelven "" en explicit2; esto cubre el path legacy.
            pass
        elif fallback:
            mapping = _outfit_mapping(theme, job.nivel)
            outfit_key = _choose_key(mapping, variants.get("outfit"), rng,
                                     warn=True)
            outfit_line = (", ".join(mapping.get(outfit_key) or [])
                           if outfit_key else "")
            base = outfit_key
        else:
            outfit_key = base
            outfit_line = outfit_line_for_stage(theme, base, job.stage, entry)

        scene_line, scene_key = _axis_line(
            theme, "scene", variants.get("scene"), variation, cache,
            (name, "scene"), rng)
        if free:
            props_line, props_key = "", None
        else:
            props_line, props_key = _axis_line(
                theme, "props", variants.get("props"), variation, cache,
                (name, "props"), rng)
        light_line, light_key = _axis_line(
            theme, "lighting", variants.get("lighting"), variation, cache,
            (name, "lighting"), rng)

        job.tema = [name]
        job.outfit_line = outfit_line
        job.outfit_base = base
        job.outfit_key = outfit_key
        job.outfit_fallback = fallback
        job.scene_line = scene_line
        job.props_line = props_line
        job.light_line = light_line
        job.dress = job_dress
        job.variant_keys = {"outfit": outfit_key, "scene": scene_key,
                            "lighting": light_key, "props": props_key}
        job.pose_scene_warnings = validate_pose_scene(
            job.pose_line, scene_line, affinity)
        if strict_scene and job.pose_scene_warnings:
            continue
        kept.append(job)
    assign_cameras(kept, cameras, stage_cameras or {})
    return kept


def load_themes(path=THEMES_FILE) -> dict:
    """Lee `themes.json` -> `{nombre: tema}`. `{}` si falta o es ilegible.

    No aborta: los flags de tema son los que dan el error claro si el tema
    pedido no aparece.
    """
    path = Path(path)
    if not path.exists():
        print(f"[i] no existe {path}; sin temas disponibles")
        return {}
    try:
        data = json.loads(path.read_text(encoding="utf-8-sig"))
    except Exception as e:
        print(f"[!] no se pudo leer {path}: {e}")
        return {}
    if not isinstance(data, dict):
        print(f"[!] {path} no es un objeto JSON de temas")
        return {}
    return data


def _outfit_mapping(theme, level) -> dict:
    """Variantes de outfit del nivel; si no hay, cae al primer nivel con ropa."""
    outfit = theme.get("outfit") or {}
    if outfit.get(level):
        return outfit[level]
    for lv in LEVELS:
        if outfit.get(lv):
            return outfit[lv]
    return {}


def _story_base_tags(theme, base):
    outfit = theme.get("outfit") or {}
    for level in ("sugerente", "spicy"):
        mapping = outfit.get(level) or {}
        if base in mapping:
            return list(mapping[base] or [])
    raise ValueError("outfit base '%s' no tiene tags" % base)


def _dedupe_tags(tags):
    out = []
    seen = set()
    for tag in tags or []:
        text = str(tag).strip()
        key = text.lower()
        if text and key not in seen:
            seen.add(key)
            out.append(text)
    return out


def _tag_key(tag) -> str:
    """Clave de comparacion: `_` y espacios son lo mismo (`fur_trim`==`fur trim`)."""
    return str(tag).strip().replace("_", " ").lower()


def _story_delta(entry, stage):
    """(add, remove) de un stage del outfit_story.

    Acepta la forma legacy (lista = solo anadir) y la forma con retirada:
    `{"add": [...], "remove": [...]}`. `remove` se aplica sobre los tags
    acumulados hasta ese stage, asi que cada stage solo declara su delta.
    """
    value = (entry or {}).get(stage)
    if value is None:
        return [], []
    if isinstance(value, list):
        return list(value), []
    if isinstance(value, dict):
        add = value.get("add") or []
        remove = value.get("remove") or []
        if not isinstance(add, list) or not isinstance(remove, list):
            raise ValueError(
                "outfit_story['%s']: 'add'/'remove' deben ser listas" % stage)
        return list(add), list(remove)
    raise ValueError(
        "outfit_story['%s'] debe ser lista o {add, remove}" % stage)


def choose_outfit_story(theme, preferred=None, rng=None):
    story = theme.get("outfit_story") or {}
    if not isinstance(story, dict) or not story:
        raise ValueError("el tema no tiene outfit_story")
    if preferred is not None:
        if preferred not in story:
            raise ValueError("outfit base desconocido: %r" % preferred)
        base = preferred
    elif rng is None:
        base = next(iter(story))
    else:
        base = rng.choice(list(story))
    entry = story.get(base)
    if not isinstance(entry, dict):
        raise ValueError("outfit_story['%s'] no es un objeto" % base)
    for stage in ("spicy1", "spicy2", "explicit1"):
        add, remove = _story_delta(entry, stage)
        if not add and not remove:
            raise ValueError("outfit_story['%s'] no tiene %s" % (base, stage))
    _story_base_tags(theme, base)
    return base, entry


def outfit_line_for_stage(theme, base, stage, story_entry):
    """Ropa del stage: base + deltas del stage.

    `explicit2` devuelve "" (el stage va desnudo y el ensamblador anade los
    NUDE_TAGS). Por defecto solo se aplica el delta del stage actual (forma
    legacy: `oficina` y los packs ya generados no cambian). Con
    `"inherit": true` en la entrada del outfit los `add`/`remove` se acumulan
    desde `sugerente` hasta el stage pedido, que es lo que hace falta para
    desnudar por capas.
    """
    if stage not in STAGES:
        raise ValueError("stage desconocido: %r" % stage)
    if stage == "explicit2":
        return ""
    entry = story_entry or {}
    tags = _story_base_tags(theme, base)
    if entry.get("inherit"):
        sequence = [s for s in STAGES if s != "explicit2"]
    else:
        sequence = [stage]
    for current in sequence:
        add, remove = _story_delta(entry, current)
        tags = tags + add
        if remove:
            drop = {_tag_key(t) for t in remove}
            tags = [t for t in tags if _tag_key(t) not in drop]
        if current == stage:
            break
    return ", ".join(_dedupe_tags(tags))


def _choose_key(mapping, key, rng, warn) -> str | None:
    """Clave de variante: `key` si existe, si no la primera (aviso opcional) o
    una al azar con `rng` (determinista con `rng=None`)."""
    keys = list(mapping or {})
    if not keys:
        return None
    if key is not None:
        if key in mapping:
            return key
        if warn:
            print(f"[!] variante '{key}' no existe; uso '{keys[0]}'")
        return keys[0]
    if rng is None:
        return keys[0]
    return rng.choice(keys)


def _axis_key(mapping, fixed_key, variation, cache, cache_key, rng):
    """Resuelve la clave de un eje segun el modo de variacion.

    per-image: cada job tira la suya. per-pack: una sola tirada compartida
    (cache). fixed: la de `--variant eje=clave`.
    """
    if not mapping:
        return None
    if variation == "fixed":
        return _choose_key(mapping, fixed_key, None, warn=True)
    if variation == "per-pack":
        if cache_key not in cache:
            cache[cache_key] = _choose_key(mapping, fixed_key, rng, warn=True)
        return cache[cache_key]
    return _choose_key(mapping, fixed_key, rng, warn=True)


def _axis_line(theme, axis, fixed_key, variation, cache, cache_key, rng):
    """-> (linea de tags, clave elegida) para un eje plano (scene/lighting)."""
    mapping = theme.get(axis) or {}
    if not mapping:
        return "", None
    key = _axis_key(mapping, fixed_key, variation, cache, cache_key, rng)
    return (", ".join(mapping.get(key) or []) if key else ""), key


def compose_theme_jobs(theme_names, target, quotas, variation, variants, dress,
                       explore, bank, avoid, pools, cameras, rng,
                       themes=None, max_variants=2, bank_ids=None,
                       stage_quotas=None, allow_legacy_outfit=False,
                       strict_scene=False, affinity=None,
                       stage_cameras=None) -> list:
    """Como `compose_jobs`, pero pega los ejes del tema a cada Job.

    `theme_names` = [--theme NAME] + [--themes a,b]; con varios temas se reparten
    por job (round-robin). `dress` es el flag `--dress` o el centinela `"free"`.
    `variants` = {eje: clave} de `--variant eje=clave`. Lanza `SystemExit(1)` si
    un tema pedido no existe (no inventa un pack sin tema). Con `stage_quotas`
    entra el modo de 5 etapas; sin el, se mantiene el modo legacy por nivel.
    """
    free = (dress == "free")
    if themes is None:
        themes = load_themes()

    resolved = []
    for name in (theme_names or []):
        name = (name or "").strip()
        if not name:
            continue
        theme = themes.get(name)
        if not theme:
            raise SystemExit(f"[!] tema '{name}' no encontrado en {THEMES_FILE}")
        resolved.append((name, theme))
    if not resolved:
        if stage_quotas is not None:
            jobs = compose_stage_jobs(target, stage_quotas, explore, bank,
                                      avoid, pools, max_variants, rng,
                                      bank_ids=bank_ids)
            for job in jobs:
                job.dress = ("auto" if free else
                             dress_for_stage(job.stage,
                                             None if dress == "auto" else dress))
            assign_cameras(jobs, cameras, stage_cameras or {})
            return jobs
        return compose_jobs(target, quotas, explore, bank, avoid, pools, cameras,
                            max_variants, rng, bank_ids=bank_ids)

    if stage_quotas is not None:
        return _compose_stage_theme_jobs(
            target, stage_quotas, variation, variants, dress, explore, bank,
            avoid, pools, rng, resolved, max_variants, bank_ids,
            allow_legacy_outfit, strict_scene, affinity, cameras,
            stage_cameras)

    variants = variants or {}
    jobs = compose_jobs(target, quotas, explore, bank, avoid, pools, cameras,
                        max_variants, rng, bank_ids=bank_ids)

    cache = {}
    for i, job in enumerate(jobs):
        name, theme = resolved[i % len(resolved)]
        level = job.nivel

        if free:
            job_dress = "auto"
        elif dress == "auto":
            job_dress = LEVEL_DRESS.get(level, "auto")
        else:
            job_dress = dress

        outfit_line, outfit_key = "", None
        # La ropa solo tiene sentido si el job va vestido o stripping; en nude
        # chocaria con `force_nude="always"` (bikini + nude en el positive).
        if not free and job_dress in ("dressed", "stripping"):
            mapping = _outfit_mapping(theme, level)
            if variation == "per-pack":
                # Una sola tirada de outfit para TODO el pack, compartida entre
                # niveles. Si el nivel actual no tiene esa clave, cae a su
                # primera variante (determinista).
                pack_key = (name, "outfit")
                if pack_key not in cache:
                    cache[pack_key] = _choose_key(
                        mapping, variants.get("outfit"), rng, warn=True)
                outfit_key = cache[pack_key]
                if outfit_key not in mapping:
                    outfit_key = next(iter(mapping), None)
            else:
                outfit_key = _axis_key(
                    mapping, variants.get("outfit"), variation, cache,
                    (name, "outfit", level), rng)
            if outfit_key:
                outfit_line = ", ".join(mapping.get(outfit_key) or [])

        scene_line, scene_key = _axis_line(theme, "scene", variants.get("scene"),
                                           variation, cache, (name, "scene"), rng)
        # Los props decoran el slot "entorno" junto a la escena, pero `--free`
        # solo sesga escena/luz: sin props (como sin outfit).
        if free:
            props_line, props_key = "", None
        else:
            props_line, props_key = _axis_line(
                theme, "props", variants.get("props"), variation, cache,
                (name, "props"), rng)
        light_line, light_key = _axis_line(
            theme, "lighting", variants.get("lighting"), variation, cache,
            (name, "lighting"), rng)

        job.tema = [name]
        job.outfit_line = outfit_line
        job.scene_line = scene_line
        job.props_line = props_line
        job.light_line = light_line
        job.dress = job_dress
        job.variant_keys = {"outfit": outfit_key, "scene": scene_key,
                            "lighting": light_key, "props": props_key}
    return jobs


def _force_nude_for(dress) -> str:
    """`--dress`/nivel -> force_nude del ensamblador (nude=always, resto never)."""
    return force_nude_for(dress)


def theme_negative(job, base_neg) -> str:
    """Negative del job: anade el bloque de vestuario segun el dress."""
    extra = dress_negative(job.dress)
    if not extra:
        return base_neg
    return f"{base_neg}, {extra}" if base_neg else extra


def parse_variants(items) -> dict:
    """`["eje=clave", ...]` -> `{eje: clave}`."""
    out = {}
    for item in items or []:
        if "=" not in item:
            raise SystemExit(f"[!] --variant espera eje=clave, recibido '{item}'")
        axis, key = item.split("=", 1)
        out[axis.strip().lower()] = key.strip()
    return out


def build_run_prompt(master, char_line, job) -> str:
    """Prompt positivo ensamblado: ropa en la chica, escena+props+luz en el bloque.

    Sin tema (`job.outfit_line`/`scene_line`/`props_line`/`light_line` vacios y
    dress auto) el resultado es identico al de antes: pose + camara.
    """
    girl = char_line
    if job.outfit_line:
        girl = f"{char_line}, {job.outfit_line}"

    block = job.pose_line
    if getattr(job, "clean_poses", False):
        block = clean_pose_line(job)
    if job.scene_line:
        # el tema manda: fuera las escenas que traiga la pool line. Solo si el
        # tema aporta escena; un tema de solo luz no debe borrar la del pool.
        block = strip_scene_tags(block)
    parts = [block, job.scene_line, job.props_line, job.light_line, job.camera]
    block = ", ".join(p for p in parts if p)
    return build_prompt(master, girl, block, age_up=False,
                        force_nude=_force_nude_for(job.dress))


def parse_levels(text) -> dict:
    """'18,20,17' -> dict por nivel, en el orden sugerente,spicy,explicito."""
    if text is None:
        return dict(DEFAULT_QUOTAS)
    parts = [p.strip() for p in text.split(",")]
    if len(parts) != len(LEVELS):
        raise SystemExit(
            f"[!] --levels necesita {len(LEVELS)} valores "
            f"(sugerente,spicy,explicito), recibidos {len(parts)}")
    try:
        values = [int(p) for p in parts]
    except ValueError:
        raise SystemExit(f"[!] --levels debe ser enteros: '{text}'")
    return dict(zip(LEVELS, values))


def notify(message):
    """Aviso por Telegram; nunca rompe la corrida si no esta configurado."""
    try:
        from notifier import send_telegram_msg
        send_telegram_msg(message)
    except Exception as e:
        print("[i] aviso Telegram no enviado: %s" % e)


def forge_available() -> bool:
    """True si Forge responde a /sdapi/v1/options (timeout 15s)."""
    import requests
    try:
        r = requests.get(f"{FORGE}/sdapi/v1/options", timeout=15)
        return r.status_code == 200
    except Exception:
        return False


def _lora_weight(char_line) -> float:
    """Peso del <lora:...:W> de la linea (1.0 si falta). Solo informativo."""
    m = re.search(r"<lora:[^:>]+:([^>]*)>", char_line or "")
    if not m:
        return 1.0
    try:
        return float(m.group(1))
    except ValueError:
        return 1.0


def _char_name(char, char_line) -> str:
    """Clave del personaje; si no se pasa, el nombre del LoRA de la linea."""
    if char:
        return char
    m = re.search(r"<lora:([^:>]+):[^>]*>", char_line or "")
    return m.group(1).strip() if m else ""


def _image_name(order, job, seed) -> str:
    tag = job.stage or job.nivel
    poseid = job.pose_id if (job.origin == "bank" and job.pose_id) else "new"
    return f"{order:02d}_{tag}_{poseid}_s{seed}.png"


def compute_metrics(records, planned=None, failed=0, budget_hit=False,
                    planned_stage=None) -> dict:
    """Metricas de la corrida a partir de los records generados.

    `records` solo lleva las imagenes guardadas; `failed` se pasa aparte (una
    imagen fallida no tiene fichero, asi que no genera record). `planned` es el
    reparto por nivel deseado (de los jobs) para calcular el faltante.
    `planned_stage` anade el reparto por stage y su faltante.
    """
    by_level = {lv: 0 for lv in LEVELS}
    by_stage = {stage: 0 for stage in STAGES}
    for rec in records:
        lv = rec.get("nivel")
        by_level[lv] = by_level.get(lv, 0) + 1
        stage = stage_for_record(rec)
        by_stage[stage] = by_stage.get(stage, 0) + 1
    shortfall = None
    if planned is not None:
        shortfall = {lv: int(planned.get(lv, 0)) - by_level.get(lv, 0)
                     for lv in LEVELS}
    shortfall_stage = None
    if planned_stage is not None:
        shortfall_stage = {stage: int(planned_stage.get(stage, 0))
                           - by_stage.get(stage, 0) for stage in STAGES}
    return {
        "generated": len(records),
        "failed": int(failed),
        "keep_rate": None,
        "by_level": by_level,
        "by_stage": by_stage,
        "budget_hit": bool(budget_hit),
        "shortfall": shortfall,
        "shortfall_stage": shortfall_stage,
    }


def generate(jobs, char_line, master, neg, out_dir, seed, budget,
             char="", on_image=None, should_stop=None) -> tuple:
    """Llama a Forge txt2img job a job y escribe los PNG en `out_dir`.

    -> (records, metrics). `records` es la lista de dicts del manifest (solo
    imagenes guardadas; su `file` es SIEMPRE img/<nombre>, segun el ruling).
    Reintenta una vez por imagen; si el 2do intento falla, cuenta en
    metrics["failed"] y sigue (no aborta). `budget` limita los intentos totales
    (incluidos reintentos); si se agota antes de acabar, budget_hit=True.

    `on_image` y `should_stop` son opcionales (los usa la UI, no el CLI): el
    primero recibe un dict por evento (`start`, `done`, `fail`) y el segundo se
    consulta antes de cada imagen para poder cancelar. Con los defaults el
    comportamiento es identico al de siempre.
    """
    import requests

    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    planned = {}
    planned_stage = {}
    for job in jobs:
        planned[job.nivel] = planned.get(job.nivel, 0) + 1
        if job.stage:
            planned_stage[job.stage] = planned_stage.get(job.stage, 0) + 1

    rec_char = _char_name(char, char_line)
    lora_weight = _lora_weight(char_line)

    records = []
    failed = 0
    attempts = 0
    budget_hit = False
    stopped = False
    fixed_seed = seed is not None
    per_job_seed = any(job.seed is not None for job in jobs)
    if per_job_seed:
        print("[-] semilla: una por job (distinta por stage)")
    else:
        print(f"[-] semilla: {'fija ' + str(seed) if fixed_seed else 'aleatoria por imagen'}")

    for i, job in enumerate(jobs, 1):
        if attempts >= budget:
            budget_hit = True
            break
        if should_stop is not None and should_stop():
            stopped = True
            print(f"[!] cancelado antes de la imagen {i}/{len(jobs)}")
            break
        if on_image is not None:
            on_image({"event": "start", "index": i, "total": len(jobs),
                      "nivel": job.nivel, "stage": job.stage,
                      "pose_id": job.pose_id})

        if job.seed is not None:
            job_seed = int(job.seed)
        else:
            job_seed = seed if fixed_seed else random.randint(0, 2**32 - 1)
        prompt = build_run_prompt(master, char_line, job)
        job_neg = theme_negative(job, neg)
        data = None
        error = None
        for attempt in (1, 2):
            attempts += 1
            if attempt == 2 and (job.seed is not None or not fixed_seed):
                job_seed = random.randint(0, 2**32 - 1)
            try:
                payload = {
                    "prompt": prompt, "negative_prompt": job_neg,
                    "width": 832, "height": 1216, "steps": 28, "cfg_scale": 5.0,
                    "sampler_name": "DPM++ 2M Karras", "batch_size": 1,
                    "n_iter": 1, "seed": job_seed, "clip_skip": 2,
                }
                resp = requests.post(f"{FORGE}/sdapi/v1/txt2img", json=payload,
                                     timeout=300)
                resp.raise_for_status()
                data = resp.json()
                break
            except Exception as e:
                error = e
                print(f"    [FAIL {attempt}/2] {e}")
            if attempts >= budget:
                budget_hit = True
                break

        if data is None:
            failed += 1
            print(f"[{i}/{len(jobs)}] {job.nivel} {job.origin} -> FALLO "
                  f"({error})")
            if on_image is not None:
                on_image({"event": "fail", "index": i, "total": len(jobs),
                          "nivel": job.nivel, "stage": job.stage,
                          "pose_id": job.pose_id, "error": str(error)})
            continue

        try:
            used_seed = json.loads(data.get("info", "{}")).get("seed", job_seed)
        except Exception:
            used_seed = job_seed
        name = _image_name(i, job, used_seed)
        (out_dir / name).write_bytes(base64.b64decode(data["images"][0]))
        records.append({
            "file": f"img/{name}",
            "pose_id": job.pose_id,
            "nivel": job.nivel,
            "stage": job.stage,
            "tema": list(job.tema),
            "char": rec_char,
            "seed": used_seed,
            "lora_weight": lora_weight,
            "prompt": prompt,
            "pose_line": job.pose_line,
            "origin": job.origin,
            "camera": job.camera,
            "theme": job.tema[0] if job.tema else None,
            "outfit_base": job.outfit_base,
            "outfit_key": job.outfit_key,
            "outfit_fallback": job.outfit_fallback,
            "pose_scene_warnings": list(job.pose_scene_warnings),
            "variants": dict(job.variant_keys),
            "dress": job.dress,
            "clean_poses": job.clean_poses,
            "verdict": None,
            "motivos": [],
        })
        print(f"[{i}/{len(jobs)}] {job.nivel} {job.origin} -> {name} "
              f"(seed={used_seed})")
        if on_image is not None:
            on_image({"event": "done", "index": i, "total": len(jobs),
                      "record": records[-1],
                      "path": str(out_dir / name)})

    metrics = compute_metrics(records, planned=planned, failed=failed,
                              budget_hit=budget_hit,
                              planned_stage=planned_stage or None)
    metrics["stopped"] = stopped
    return records, metrics


def main():
    p = argparse.ArgumentParser(description="Planificador de packs PlumLoom")
    p.add_argument("--char", required=True, help="clave del personaje (personajes.txt)")
    p.add_argument("--theme", default=None,
                   help="tema del pack (nombre en Archivos/danbooru/themes.json)")
    p.add_argument("--themes", default=None,
                   help="varios temas separados por comas (a,b)")
    p.add_argument("--variation", choices=["per-image", "per-pack", "fixed"],
                   default="per-image",
                   help="variacion de variantes por job/pack/fijas (default per-image)")
    p.add_argument("--variant", action="append", default=None, metavar="EJE=CLAVE",
                   help="con --variation fixed: variante por eje (repetible). "
                        "Ej: --variant scene=shore")
    p.add_argument("--dress", choices=["auto", "dressed", "stripping",
                                       "lingerie", "clothed", "nude"],
                   default="auto",
                   help="auto (stage->dress) | dressed | stripping | lingerie | "
                        "clothed | nude")
    p.add_argument("--free", action="store_true",
                   help="sin outfit: el tema solo sesga escena/luz (force_nude auto)")
    p.add_argument("--target", type=int, default=45, help="imagenes objetivo (default 45)")
    p.add_argument("--levels", default=None,
                   help="quotas legacy sugerente,spicy,explicito (default 15,16,14)")
    p.add_argument("--stages", default=None,
                   help="quotas por stage sugerente,spicy1,spicy2,explicit1,"
                        "explicit2 (default 9,9,9,9,9)")
    p.add_argument("--allow-legacy-outfit", action="store_true",
                   help="permite temas sin outfit_story (modo legacy por nivel)")
    p.add_argument("--strict-scene", action="store_true",
                   help="descarta jobs con choque pose/escena (default: avisa)")
    p.add_argument("--clean-poses", action="store_true",
                   help="quita de la pose los tags que contradicen el stage/tema "
                        "(estado de ropa, encuadre, fondo)")
    p.add_argument("--explore", type=float, default=0.2, help="ratio exploracion (default 0.2)")
    p.add_argument("--max-variants", type=int, default=2,
                   help="reusos maximos de una pose del banco (default 2)")
    p.add_argument("--seed", type=int, default=None,
                   help="semilla fija (default: aleatoria por imagen; fijala solo para corregir una imagen)")
    p.add_argument("--seed-per-stage", action="store_true",
                   help="cada stage usa su propia semilla (base + stage*1000003 + i); "
                        "evita que el mismo seed repita poses entre stages")
    p.add_argument("--bank-ids", default=None, metavar="ID[,ID]",
                   help="fija poses del banco (p.ej. pb0001,pb0008,pb0011); "
                        "util para variar la pose entre temas")
    p.add_argument("--dry-run", action="store_true",
                   help="imprime el plan sin tocar Forge")
    p.add_argument("--pilot", type=int, default=None, metavar="N",
                   help="genera un piloto de N imagenes en Output/pilots/<stamp>/")
    p.add_argument("--pack", action="store_true",
                   help="genera un pack en Output/packs/<clave>_<tema>/")
    p.add_argument("--watermark", default=None, metavar="TEXTO",
                   help="texto de la marca de agua para img/ en --pack "
                        "(sin el = copia plana)")
    p.add_argument("--budget", type=int, default=None,
                   help="maximo de intentos de generacion (default 3x target)")
    args = p.parse_args()

    if args.pilot is not None and args.pack:
        raise SystemExit("[!] elige --pilot o --pack, no ambos")
    if not args.dry_run and args.pilot is None and not args.pack:
        raise SystemExit("[!] elige --pilot N o --pack (o usa --dry-run)")
    if args.levels is not None and args.stages is not None:
        raise SystemExit("[!] no se pueden usar --levels y --stages juntos")

    stage_mode = args.levels is None
    if stage_mode:
        try:
            stage_quotas = parse_stage_quotas(args.stages)
        except ValueError as exc:
            raise SystemExit("[!] --stages: %s" % exc)
        quotas = dict(DEFAULT_QUOTAS)
        cameras = load_cameras()
        stage_cameras = load_stage_cameras(fallback=cameras)
        affinity = load_affinity()
    else:
        stage_quotas = None
        stage_cameras = None
        affinity = None
        quotas = parse_levels(args.levels)
        cameras = load_cameras()
    pools = {lv: load_pool_lines(lv) for lv in LEVELS}
    bank = pose_bank.load_bank()
    avoid = pose_bank.load_avoid()

    B = PromptBuilder(str(BASE_DIR))
    char = B.get_character(args.char)
    if not char:
        raise SystemExit(f"[!] personaje '{args.char}' no esta en personajes.txt")
    char_line = char["base"]

    target = args.pilot if args.pilot is not None else args.target

    theme_names = []
    if args.theme:
        theme_names.append(args.theme)
    if args.themes:
        theme_names.extend(t.strip() for t in args.themes.split(",") if t.strip())

    bank_ids = ([x.strip() for x in args.bank_ids.split(",") if x.strip()]
                if args.bank_ids else None)
    rng = random.Random(args.seed)
    dress_arg = "free" if args.free else args.dress
    if theme_names:
        variants = parse_variants(args.variant)
        if stage_mode:
            jobs = compose_theme_jobs(
                theme_names, target, quotas, args.variation, variants,
                dress_arg, args.explore, bank, avoid, pools, cameras, rng,
                max_variants=args.max_variants, bank_ids=bank_ids,
                stage_quotas=stage_quotas,
                allow_legacy_outfit=args.allow_legacy_outfit,
                strict_scene=args.strict_scene, affinity=affinity,
                stage_cameras=stage_cameras)
        else:
            jobs = compose_theme_jobs(
                theme_names, target, quotas, args.variation, variants,
                dress_arg, args.explore, bank, avoid, pools, cameras, rng,
                max_variants=args.max_variants, bank_ids=bank_ids)
    elif stage_mode:
        jobs = compose_stage_jobs(target, stage_quotas, args.explore, bank,
                                  avoid, pools, args.max_variants, rng,
                                  bank_ids=bank_ids)
        for job in jobs:
            job.dress = ("auto" if args.free else
                         dress_for_stage(job.stage,
                                         None if args.dress == "auto"
                                         else args.dress))
        assign_cameras(jobs, cameras, stage_cameras or {})
    else:
        jobs = compose_jobs(target, quotas, args.explore, bank, avoid, pools,
                            cameras, args.max_variants, rng, bank_ids=bank_ids)

    # un piloto de N es "hasta N": compose_jobs redondea por nivel y puede
    # pasarse en targets pequenos, asi que se recorta a N.
    if args.pilot is not None:
        jobs = jobs[:args.pilot]

    for job in jobs:
        job.clean_poses = args.clean_poses

    if args.seed_per_stage:
        base = args.seed if args.seed is not None else random.randint(1, 2**31 - 1)
        counters = {}
        for i, job in enumerate(jobs):
            if job.stage:
                # modo stages: la etapa separa los bloques de semillas
                idx = counters.get(job.stage, 0)
                counters[job.stage] = idx + 1
                job.seed = base + STAGES.index(job.stage) * 1000003 + idx
            else:
                # modo legacy (--pilot/--levels): indice global, si no los tres
                # niveles comparten seed (idx=0 en cada uno)
                job.seed = base + i
        print(f"[-] semillas por stage desde {base}: "
              + ", ".join(f"{st} {base + i * 1000003}"
                          for i, st in enumerate(STAGES)))

    if args.dry_run:
        print(f"[-] plan: {len(jobs)} jobs | personaje {args.char} | tema {args.theme}")
        print(f"[-] banco: {len(bank)} poses | camaras: {len(cameras)}")

        per_level = {lv: 0 for lv in LEVELS}
        per_stage = {stage: 0 for stage in STAGES}
        for i, job in enumerate(jobs, 1):
            per_level[job.nivel] = per_level.get(job.nivel, 0) + 1
            if job.stage:
                per_stage[job.stage] = per_stage.get(job.stage, 0) + 1
            pos = build_run_prompt(B.master_prompt, char_line, job)
            print(f"[{i}/{len(jobs)}] {job.nivel} {job.origin} {job.pose_line} "
                  f"| stage:{job.stage or '-'} | cam:{job.camera}"
                  + (f" | seed:{job.seed}" if job.seed is not None else ""))
            print(f"    prompt: {pos[:150]}")
            if job.outfit_line or job.scene_line or job.props_line or job.light_line:
                keys = " ".join(f"{k}={v}" for k, v in job.variant_keys.items()
                                if v)
                print(f"    tema  : {job.tema} | dress:{job.dress} "
                      f"| outfit_base:{job.outfit_base} "
                      f"| outfit:{job.outfit_line} | scene:{job.scene_line} "
                      f"| props:{job.props_line} | light:{job.light_line}"
                      + (f" | {keys}" if keys else ""))
            if job.pose_scene_warnings:
                print("    aviso : " + " | ".join(job.pose_scene_warnings))

        print(f"\n[OK] quota: sugerente {per_level['sugerente']} / "
              f"spicy {per_level['spicy']} / explícito {per_level['explicito']}")
        if stage_mode:
            print("[OK] stages: " + " / ".join(
                f"{stage} {per_stage[stage]}" for stage in STAGES))
        return

    # --- generacion (--pilot / --pack) ---
    # Guard: sin Forge NO se escribe ningun manifest (ni se crea la carpeta).
    if not forge_available():
        msg = f"Forge NO responde en {FORGE}. Arranca webui-user.bat --api"
        print(f"[!] {msg}")
        notify(f"❌ {msg}")
        raise SystemExit(1)

    if args.pilot is not None:
        run_dir = PILOTS_DIR / datetime.now().strftime("%Y%m%d_%H%M%S")
        img_dir = run_dir / "img"
    else:
        run_dir = PACKS_DIR / f"{args.char}_{args.theme or 'sin_tema'}"
        img_dir = run_dir / "raw"
    run_dir.mkdir(parents=True, exist_ok=True)

    budget = args.budget if args.budget is not None else target * 3

    records, metrics = generate(jobs, char_line, B.master_prompt,
                                B.negative_prompt, img_dir, args.seed, budget,
                                char=args.char)

    # El manifest solo si Forge estaba disponible (ya garantizado) y hubo
    # alguna imagen. metrics.json se escribe siempre para ver el faltante.
    if metrics["generated"] > 0:
        (run_dir / "pack_manifest.json").write_text(
            json.dumps(records, ensure_ascii=False, indent=1), encoding="utf-8")
    (run_dir / "metrics.json").write_text(
        json.dumps(metrics, ensure_ascii=False, indent=1), encoding="utf-8")

    # Packaging (solo --pack): puebla img/ desde raw/ (img_dir = raw/ en --pack).
    # Con --watermark marca; sin el, copia plana. El manifest ya apunta a
    # img/<nombre>, asi que aqui solo se generan los ficheros.
    if args.pack and metrics["generated"] > 0:
        made = watermark_dir(img_dir, run_dir / "img", args.watermark or "")
        print(f"[OK] img/: {len(made)} ficheros"
              + (" (marca de agua)" if args.watermark else " (copia plana)"))

    print(f"\n[OK] {metrics['generated']} imagenes generadas "
          f"({metrics['failed']} fallidas) -> {run_dir}")
    if metrics["budget_hit"]:
        print(f"[!] budget agotado ({budget}); faltante por nivel: "
              f"{metrics['shortfall']}")
    if metrics["generated"] > 0:
        print("[OK] pack_manifest.json + metrics.json escritos")
    else:
        print("[!] nada generado; NO se escribe pack_manifest.json")
    notify(f"✅ Generadas {metrics['generated']} imágenes -> {run_dir}")


if __name__ == "__main__":
    main()
