"""
planner: convierte lo que pide la pantalla en Jobs, SIN tocar la GPU.

La preview y el lanzamiento usan el MISMO objeto: `/api/plan` compone los jobs
y los guarda en memoria; `/api/start` solo ejecuta esos jobs. Asi lo que se ve
es exactamente lo que se genera (D2.7).

Reglas que respeta (las mismas del CLI, no hay una segunda verdad):

- Poses sin repetir mientras queden en el banco; si se agotan, se repite y se
  avisa (`limits`/`blocked`). El limite se cuenta POR NIVEL, que es lo que de
  verdad manda (sugerente/spicy/explicito).
- Una pose por (personaje, stage): se reparten sin reemplazo con un unico
  `Dealer` para todo el lote. Nada de dejar que `compose_stage_jobs` elija solo,
  porque su contador de reuso es por personaje.
- Camara por orden de imagen dentro del personaje (el piloto "Diferent poses":
  from above / from front / from side), no por stage.
- Semilla aleatoria por imagen (`seed=None`); fija solo si se pide a proposito.

    from planner import compose
    plan = compose({"mode": "group", "keys": [...], "stages": [...]})
"""
import random
import re
import sys
from datetime import datetime
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent.parent
for _extra in (BASE_DIR / "Engine", BASE_DIR / "Archivos"):
    if str(_extra) not in sys.path:
        sys.path.insert(0, str(_extra))

import pose_bank  # noqa: E402
import pack_builder as pb  # noqa: E402
import batch_poses_chars as bpc  # noqa: E402
from prompt_builder import PromptBuilder  # noqa: E402
from pose_scene import load_affinity  # noqa: E402
from stages import (STAGES, STAGE_LEVEL, STAGE_DRESS,  # noqa: E402
                    stage_allows_pose)

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

STAGE_TITLE = {
    "sugerente": "Sugerente",
    "spicy1": "Spicy 1",
    "spicy2": "Spicy 2",
    "explicit1": "Explícito 1",
    "explicit2": "Explícito 2",
}

# Un tema ligado a un personaje concreto no vale para un lote multitudinario
# (misma regla que batch_poses_chars).
THEMES_BLOCKED = bpc.THEMES_BLOCKED

# Presets de lote largo de un personaje: reparto par entre los stages marcados.
PACK_PRESETS = (35, 45, 50)

_WEIGHT_RE = re.compile(r"\(\s*([^():]*?)\s*:\s*[\d.]+\s*\)")

# ---------------------------------------------------------------- nombres

# El nombre visible sale SIEMPRE de la linea de personajes.txt, nunca de la
# carpeta de LoRAs (alli hay 422 ficheros y varias lineas comparten LoRA).
# Orden: tag de personaje del CSV (type=4) > tag que casa con la clave >
# clave limpia. La clave sigue siendo el ID (es lo que comen los scripts).
_GENERIC_TAGS = {"1girl", "solo", "girl", "1boy", "boy", "1other", "female",
                 "male", "hetero", "2girls", "multiple girls", "androgynous"}
_LORA_RE = re.compile(r"<lora:[^>]*>")
_NAME_NOISE = re.compile(
    r"\b(illustrious|illust|illu|ixl|ilxl|il|xl|sdxl|noobai\w*|nvwls|lora|"
    r"nochekaiser|nyx|goofy|richy|v\d+(\.\d+)?|r\d+|s\d+)\b", re.I)

_CSV_TYPES = None


def _tag_types() -> dict:
    """{tag: type} del CSV de Danbooru, cargado una vez por proceso.

    Solo se usa `type`: 4 = personaje, 3 = copyright. Sirve para reconocer el
    nombre real dentro de una linea sin fiarse del triggerword.
    """
    global _CSV_TYPES
    if _CSV_TYPES is None:
        types = {}
        path = BASE_DIR / "Archivos" / "Danbooru tags.csv"
        try:
            import csv
            with path.open(encoding="utf-8-sig", newline="") as fh:
                for row in csv.DictReader(fh):
                    tag = (row.get("tag") or "").strip().lower()
                    if tag:
                        types[tag] = (row.get("type") or "").strip()
        except (OSError, UnicodeDecodeError):
            types = {}
        _CSV_TYPES = types
    return _CSV_TYPES


def _norm(text) -> str:
    return re.sub(r"[^a-z0-9]", "", (text or "").lower())


def _plain(text) -> str:
    return re.sub(r"\s+", " ", (text or "").replace("_", " ")).strip().lower()


def _clean_tag(tag) -> str:
    return tag.replace("\\(", "(").replace("\\)", ")").strip()


def _csv_form(tag) -> str:
    return _clean_tag(tag).replace(" ", "_").lower()


def _pretty_key(key) -> str:
    """Ultimo recurso: `Hoseki_..._IllustriousXL_v2` -> `Hoseki ... V2`."""
    text = _NAME_NOISE.sub(" ", key or "")
    text = re.sub(r"[_\-.]+", " ", text)
    text = re.sub(r"\s+", " ", text).strip()
    words = [w if (w.isupper() or any(c.isupper() for c in w[1:]))
             else w.capitalize() for w in text.split()]
    return " ".join(words) or (key or "")


def _titlecase_name(tag) -> str:
    """`reze (chainsaw man)` -> `Reze (Chainsaw Man)`.

    Solo toca tags que vienen en minusculas (asi los escribe Danbooru): si el
    nombre ya trae mayusculas o caracteres raros, se deja tal cual.
    """
    if any(c.isupper() for c in tag) or not re.fullmatch(r"[a-z0-9 ().'\-]+",
                                                        tag):
        return tag
    return re.sub(r"(^|[\s(])([a-z])",
                  lambda m: m.group(1) + m.group(2).upper(), tag)


def display_name(line, key) -> str:
    """Nombre visible del personaje, sacado de su linea."""
    types = _tag_types()
    tags = [t.strip() for t in _LORA_RE.sub("", line or "").split(",")
            if t.strip()]
    for tag in tags:
        if tag.lower() in _GENERIC_TAGS:
            continue
        if types.get(_csv_form(tag)) == "4":
            return _titlecase_name(_clean_tag(tag))
    kn = _norm(key)
    for tag in tags:
        if tag.lower() in _GENERIC_TAGS:
            continue
        tn = _norm(tag)
        if len(tn) >= 3 and (tn in kn or kn in tn):
            return _titlecase_name(_clean_tag(tag))
    return _pretty_key(key)


def _name_suffix(name, key) -> str:
    """Lo que sobra de la clave quitando el nombre: `Makima` + `Makima_v2` -> v2.

    Es lo que se pone entre parentesis cuando dos personajes comparten nombre
    visible (dos LoRAs de Yelan, Makima, Raiden...), para que la lista no
    parezca tener duplicados.
    """
    tail = _plain(key).replace(_plain(name), " ")
    tail = re.sub(r"[\s\-_·]+", " ", tail).strip()
    return tail or key


def fs_name(key) -> str:
    """Nombre de CARPETA para una clave de personaje.

    `reze \\(chainsaw man\\)` lleva una barra invertida que Windows lee como
    separador de ruta: `run_dir / key` acaba en `...\\reze \\(chainsaw man` y
    `mkdir` revienta con WinError 3. La clave sigue siendo la identidad (es la
    que comen los scripts); esto es solo el nombre del directorio.
    """
    text = (key or "").replace("\\(", "(").replace("\\)", ")")
    text = re.sub(r'[\\/:*?"<>|]', "_", text)
    return text.strip(" .") or "char"


# ---------------------------------------------------------------- contexto

def load_context() -> dict:
    """Carga personajes, banco, pools y temas. Sin GPU y sin cache.

    Se rehace en cada preview a proposito: si editas el pool desde la UI, el
    siguiente plan ya cuenta con las poses nuevas (D2.3).
    """
    B = PromptBuilder(str(BASE_DIR))
    bank = pose_bank.load_bank()
    avoid = pose_bank.load_avoid()
    return {
        "B": B,
        "bank": bank,
        "avoid": avoid,
        "pools": {lv: pb.load_pool_lines(lv) for lv in LEVELS},
        "cameras": pb.load_cameras(),
        "themes": pb.load_themes(),
        "affinity": load_affinity(),
    }


def usable_poses(ctx, skip_ids=None) -> dict:
    """{nivel: [entradas]} sin blacklisteadas ni vetadas en esta corrida."""
    return bpc.usable_poses(ctx["bank"], ctx["avoid"], skip_ids)


def characters(ctx, used=None) -> list:
    """Todos los personajes con las marcas que necesita la pantalla.

    `key` es el ID (lo que comen los scripts), `name` es lo que se pinta y
    `lora_state` dice si el LoRA declarado esta en disco:

        ok      -> el .safetensors existe
        missing -> la linea pide un LoRA que no esta en disco
        none    -> la linea no lleva <lora:> (se resuelve con tags del ckpt)
    """
    from character_keys import lora_stems
    available = lora_stems()
    used = used or set()
    rows = []
    for key in ctx["B"].get_character_names():
        char = ctx["B"].get_character(key) or {}
        line = char.get("base") or ""
        match = re.search(r"<lora:([^:>]+):", line)
        if not match:
            state = "none"
        elif not available or match.group(1).lower() in available:
            state = "ok"
        else:
            state = "missing"
        rows.append({
            "key": key,
            "name": display_name(line, key),
            "line": line,
            "lora": char.get("lora"),
            "lora_state": state,
            "has_lora": state != "missing",
            "used": key in used,
        })

    # Nombres repetidos (dos LoRAs de Yelan, por ejemplo) se desambiguan con la
    # clave, que es unica: si no, la lista parece tener duplicados.
    seen = {}
    for row in rows:
        seen.setdefault(row["name"].lower(), []).append(row)
    for group in seen.values():
        if len(group) < 2:
            continue
        for row in group:
            if _plain(row["key"]) != _plain(row["name"]):
                row["name"] = "%s (%s)" % (row["name"],
                                           _titlecase_name(_name_suffix(
                                               row["name"], row["key"])))
    return rows


def used_characters() -> set:
    return bpc.used_characters()


def pose_counts(ctx, skip_ids=None) -> dict:
    """Cuenta de poses usables por nivel y por stage (para el aviso de D2.3)."""
    usable = usable_poses(ctx, skip_ids)
    by_level = {lv: len(usable[lv]) for lv in LEVELS}
    by_stage = {}
    for stage in STAGES:
        level = STAGE_LEVEL[stage]
        by_stage[stage] = len([e for e in usable[level]
                               if stage_allows_pose(stage,
                                                    e.get("pose_line", ""))])
    return {"by_level": by_level, "by_stage": by_stage,
            "total": sum(by_level.values())}


def themes_meta(ctx) -> list:
    """Lista de temas con lo justo para pintar el selector."""
    out = []
    for name, theme in sorted((ctx["themes"] or {}).items()):
        story = theme.get("outfit_story")
        scenes = list((theme.get("scene") or {}).keys())
        out.append({
            "name": name,
            "has_story": isinstance(story, dict) and bool(story),
            "scenes": scenes,
            "blocked": name in THEMES_BLOCKED,
        })
    return out


# ---------------------------------------------------------------- reparto

class Dealer:
    """Reparte poses por stage sin reemplazo; repite solo si se agota.

    Un unico Dealer para TODO el lote: es lo que garantiza que no se repita la
    misma pose entre personajes (el contador de pack_builder es por personaje).
    """

    def __init__(self, pools, allow_repeat=True):
        self.pools = pools
        self.allow_repeat = allow_repeat
        self.ptr = {lv: 0 for lv in LEVELS}
        self.cycle = {lv: 0 for lv in LEVELS}
        self.repeated = []

    def take(self, stage):
        level = STAGE_LEVEL[stage]
        pool = self.pools.get(level) or []
        while self.ptr[level] < len(pool):
            entry = pool[self.ptr[level]]
            self.ptr[level] += 1
            if stage_allows_pose(stage, entry.get("pose_line", "")):
                return entry
        allowed = [e for e in pool
                   if stage_allows_pose(stage, e.get("pose_line", ""))]
        if not allowed:
            raise ValueError("no queda ninguna pose de '%s' valida para %s"
                             % (level, stage))
        if not self.allow_repeat:
            raise ValueError("se acabo el banco de '%s' sin repetir pose" % level)
        entry = allowed[self.cycle[level] % len(allowed)]
        self.cycle[level] += 1
        self.repeated.append({"stage": stage, "pose_id": entry.get("id")})
        return entry


def _even_quotas(stages, total) -> dict:
    """Reparte `total` entre los stages marcados; el resto, a los primeros."""
    quotas = {stage: 0 for stage in STAGES}
    stages = [s for s in STAGES if s in set(stages)]
    if not stages:
        return quotas
    base, rest = divmod(int(total), len(stages))
    for i, stage in enumerate(stages):
        quotas[stage] = base + (1 if i < rest else 0)
    return quotas


# ---------------------------------------------------------------- composicion

def _compose_char(ctx, char_line, theme, stage_poses, rng, strict_scene):
    """Jobs de un personaje: una llamada a compose por stage.

    Se hace por stage y no de golpe porque `bank_ids` fija la pose por NIVEL:
    con spicy1 y spicy2 en la misma llamada, los dos cogerian la misma pose.
    """
    jobs = []
    for stage, poses in stage_poses:
        if not poses:
            continue
        quotas = {stage: len(poses)}
        got = pb.compose_theme_jobs(
            [theme], len(poses), {lv: 0 for lv in LEVELS}, "per-image", {},
            "auto", 0.0, ctx["bank"], ctx["avoid"], ctx["pools"],
            ctx["cameras"], rng, max_variants=1,
            bank_ids=[p.get("id") for p in poses],
            stage_quotas=quotas, allow_legacy_outfit=True,
            strict_scene=strict_scene, affinity=ctx["affinity"])
        jobs.extend(got)
    # camara por orden de imagen (como el piloto): from above / from front / ...
    pb.assign_cameras(jobs, ctx["cameras"])
    return jobs


def _spec(raw) -> dict:
    raw = raw or {}
    mode = raw.get("mode") or "pack"
    if mode not in ("pack", "group"):
        raise ValueError("mode desconocido: %r" % mode)
    stages = [s for s in (raw.get("stages") or []) if s in STAGES]
    if not stages:
        raise ValueError("marca al menos un stage")
    stages = [s for s in STAGES if s in set(stages)]
    total = raw.get("total")
    try:
        total = int(total) if total not in (None, "") else 45
    except (TypeError, ValueError):
        raise ValueError("total de imagenes no entero: %r" % raw.get("total"))
    if mode == "pack" and total < 1:
        raise ValueError("el total de imagenes debe ser >= 1")
    seed = raw.get("seed")
    if seed in ("", None):
        seed = None
    else:
        try:
            seed = int(seed)
        except (TypeError, ValueError):
            raise ValueError("semilla no entera: %r" % raw.get("seed"))
    pick_seed = raw.get("pick_seed")
    try:
        pick_seed = int(pick_seed) if pick_seed not in (None, "") else None
    except (TypeError, ValueError):
        pick_seed = None
    return {
        "mode": mode,
        "keys": [str(k) for k in (raw.get("keys") or []) if str(k).strip()],
        "count": int(raw.get("count") or 0),
        "exclude_used": bool(raw.get("exclude_used")),
        "stages": stages,
        "total": total,
        "theme": (raw.get("theme") or "").strip() or None,
        "seed": seed,
        "pick_seed": pick_seed,
        "allow_repeat": bool(raw.get("allow_repeat")),
        "strict_scene": bool(raw.get("strict_scene")),
        "exclude_poses": [str(x).strip() for x in
                          (raw.get("exclude_poses") or []) if str(x).strip()],
    }


def _pick_items(ctx, spec, rng, used):
    """-> ([{char, char_line, theme}], avisos) segun modo/pestaña."""
    warnings = []
    info = {c["key"]: c for c in characters(ctx, used)}

    if spec["mode"] == "pack":
        key = (spec["keys"] or [None])[0]
        if not key:
            raise ValueError("elige un personaje")
        if key not in info:
            raise ValueError("'%s' no esta en personajes.txt" % key)
        items = [key]
        state = info[key]["lora_state"]
        if state == "missing":
            warnings.append("%s pide un LoRA que no esta en disco: saldra con "
                            "el aspecto por defecto del checkpoint"
                            % info[key]["name"])
        elif state == "none":
            warnings.append("%s no lleva <lora:>: el personaje depende de los "
                            "tags del checkpoint" % info[key]["name"])
    else:
        if spec["keys"]:
            missing = [k for k in spec["keys"] if k not in info]
            if missing:
                raise ValueError("no estan en personajes.txt: %s"
                                 % ", ".join(missing))
            items = spec["keys"]
            missing_lora = [info[k]["name"] for k in items
                            if info[k]["lora_state"] == "missing"]
            if missing_lora:
                warnings.append("LoRA ausente en disco: %s"
                                % ", ".join(missing_lora))
        else:
            count = int(spec["count"] or 0)
            if count < 1:
                raise ValueError("indica cuantos personajes sortear")
            pool = [k for k, c in info.items()
                    if c["has_lora"] and (not (spec["exclude_used"]
                                               and c["used"]))]
            rng.shuffle(pool)
            if count > len(pool):
                warnings.append("pedias %d personajes y solo hay %d disponibles"
                                % (count, len(pool)))
            items = pool[:count]
            if not items:
                raise ValueError("no hay personajes disponibles "
                                 "(quita el filtro de usados)")

    if spec["mode"] == "pack":
        theme = spec["theme"] or rng.choice([t for t in ctx["themes"]
                                             if t not in THEMES_BLOCKED])
        if theme not in ctx["themes"]:
            raise ValueError("el tema '%s' no existe" % theme)
        themes = [theme]
    else:
        if spec["theme"]:
            if spec["theme"] not in ctx["themes"]:
                raise ValueError("el tema '%s' no existe" % spec["theme"])
            themes = [spec["theme"]] * len(items)
        else:
            pool = [t for t in ctx["themes"] if t not in THEMES_BLOCKED]
            rng.shuffle(pool)
            if len(items) > len(pool):
                warnings.append("pedias %d temas distintos y solo hay %d: "
                                "se repiten" % (len(items), len(pool)))
            themes = [pool[i % len(pool)] for i in range(len(items))]

    out = []
    for key, theme in zip(items, themes):
        out.append({"char": key, "char_name": info[key]["name"],
                    "dir": fs_name(key),
                    "char_line": info[key]["line"], "theme": theme})
    return out, warnings


def _checks(prompt, job, theme_has_story):
    """Validaciones de la preview (D2.7). -> [{'level','text'}]"""
    out = []
    low = prompt.lower()
    for token, label in ((r"\b1girl\b", "1girl"), (r"\b1boy\b", "1boy")):
        n = len(re.findall(token, low))
        if label == "1girl":
            out.append({"level": "ok" if n == 1 else "err",
                        "text": "1girl x%d" % n})
        elif n > 1:
            out.append({"level": "err", "text": "1boy x%d (sale otro hombre)" % n})
    if "1boy" not in low and "solo" not in low:
        out.append({"level": "warn", "text": "1girl sin `solo`"})
    segments = [s.strip() for s in prompt.split("BREAK")]
    out.append({"level": "ok" if len(segments) >= 3 else "warn",
                "text": "%d tramos BREAK" % len(segments)})
    if "<lora:" not in low:
        out.append({"level": "err", "text": "sin <lora:> (personaje generico)"})
    if not job.pose_line.strip():
        out.append({"level": "err", "text": "pose vacia"})
    if _WEIGHT_RE.search(job.pose_line or ""):
        out.append({"level": "warn",
                    "text": "pesos en la pose: el ensamblador los borra"})
    if re.search(r"(^|, )\s*no\s+[a-z]", low):
        out.append({"level": "err",
                    "text": "`no X` en el positivo: refuerza X"})
    tags = [t.strip().lower() for seg in segments for t in seg.split(",")
            if t.strip()]
    harmless = {"solo", "1girl", "1boy", "nude", "completely_nude", "naked"}
    dupes = sorted({t for t in tags
                    if tags.count(t) > 1 and t not in harmless})
    if dupes:
        out.append({"level": "warn",
                    "text": "tags duplicados: %s" % ", ".join(dupes[:5])})
    if len(tags) > 65:
        out.append({"level": "warn", "text": "%d tags (diluye)" % len(tags)})
    if job.dress in ("dressed", "stripping", "lingerie", "clothed"):
        if not job.outfit_line:
            out.append({"level": "warn", "text": "vestida pero sin outfit"})
        elif not theme_has_story:
            out.append({"level": "warn",
                        "text": "tema sin outfit_story (ropa legacy)"})
    if not job.camera:
        out.append({"level": "warn", "text": "sin camara"})
    return out


def compose(raw_spec, used=None) -> dict:
    """-> plan completo (items con Jobs reales) + preview serializable.

    No genera nada. Lanza `ValueError` con un mensaje legible si la peticion
    no se puede cumplir tal cual.
    """
    spec = _spec(raw_spec)
    ctx = load_context()
    if used is None and (spec["exclude_used"] or spec["mode"] == "group"):
        used = used_characters()
    pick_seed = spec["pick_seed"]
    if pick_seed is None:
        pick_seed = random.randint(1, 2**31 - 1)
    rng = random.Random(pick_seed)

    base, warnings = _pick_items(ctx, spec, rng, used or set())
    usable = usable_poses(ctx, spec["exclude_poses"])
    for lv in LEVELS:
        rng.shuffle(usable[lv])

    if not any(usable[lv] for lv in LEVELS):
        raise ValueError("el banco no tiene poses usables")

    if spec["mode"] == "pack":
        quotas = _even_quotas(spec["stages"], spec["total"])
    else:
        quotas = {stage: (1 if stage in spec["stages"] else 0)
                  for stage in STAGES}

    dealer = Dealer(usable, allow_repeat=True)
    items = []
    missing_jobs = 0
    for entry in base:
        stage_poses = []
        for stage in spec["stages"]:
            n = quotas.get(stage, 0)
            if n <= 0:
                continue
            stage_poses.append((stage, [dealer.take(stage) for _ in range(n)]))
        expected = sum(len(p) for _, p in stage_poses)
        jobs = _compose_char(ctx, entry["char_line"], entry["theme"],
                             stage_poses, rng, spec["strict_scene"])
        if len(jobs) != expected:
            missing_jobs += expected - len(jobs)
        items.append({**entry, "stages": [s for s, _ in stage_poses],
                      "jobs": jobs})

    plan = {
        "spec": spec,
        "items": items,
        "pick_seed": pick_seed,
        "quotas": quotas,
    }
    preview = preview_of(ctx, plan)
    preview["warnings"].extend(warnings)
    if missing_jobs:
        preview["warnings"].append(
            "%d imagen(es) descartadas por pose↔escena conflictiva "
            "(--strict-scene)" % missing_jobs)
    if dealer.repeated and not spec["allow_repeat"]:
        preview["blocked"] = True
        preview["blocked_reason"] = (
            "el banco no da para %d imagenes sin repetir pose; marca "
            "'permitir repetir poses' o baja el total"
            % preview["total"])
    return {"plan": plan, "preview": preview}


def preview_of(ctx, plan) -> dict:
    """Vista serializable del plan (lo que pinta la pantalla)."""
    spec = plan["spec"]
    by_level = {lv: 0 for lv in LEVELS}
    total = 0
    items_out = []
    for item in plan["items"]:
        theme = ctx["themes"].get(item["theme"]) or {}
        has_story = isinstance(theme.get("outfit_story"), dict) and bool(
            theme.get("outfit_story"))
        images = []
        for job in item["jobs"]:
            prompt = pb.build_run_prompt(ctx["B"].master_prompt,
                                         item["char_line"], job)
            by_level[job.nivel] = by_level.get(job.nivel, 0) + 1
            total += 1
            images.append({
                "stage": job.stage,
                "nivel": job.nivel,
                "pose_id": job.pose_id,
                "pose_line": job.pose_line,
                "camera": job.camera,
                "dress": job.dress,
                "outfit": job.variant_keys.get("outfit"),
                "outfit_line": job.outfit_line,
                "scene": job.variant_keys.get("scene"),
                "lighting": job.variant_keys.get("lighting"),
                "origin": job.origin,
                "prompt": prompt,
                "checks": _checks(prompt, job, has_story),
                "warnings": list(job.pose_scene_warnings),
                "seed": job.seed,
            })
        items_out.append({
            "char": item["char"],
            "name": item.get("char_name") or item["char"],
            "theme": item["theme"],
            "has_story": has_story,
            "stages": item["stages"],
            "images": images,
        })

    counts = pose_counts(ctx, spec["exclude_poses"])
    avail = counts["by_level"]
    limits = []
    bottleneck = None
    max_total = None
    for lv in LEVELS:
        need = by_level.get(lv, 0)
        ok = need <= avail.get(lv, 0)
        limits.append({"level": lv, "need": need, "avail": avail.get(lv, 0),
                       "ok": ok})
        if need > 0 and not ok and bottleneck is None:
            bottleneck = lv
        if need > 0 and total:
            room = avail.get(lv, 0) * total / need
            max_total = room if max_total is None else min(max_total, room)

    unit = "images"
    if spec["mode"] == "group":
        # en grupo el numero util es "cuantos personajes caben sin repetir"
        per_char = {lv: 0 for lv in LEVELS}
        for stage in spec["stages"]:
            per_char[STAGE_LEVEL[stage]] += 1
        max_total = None
        for lv in LEVELS:
            if not per_char[lv]:
                continue
            room = avail.get(lv, 0) // per_char[lv]
            max_total = room if max_total is None else min(max_total, room)
        unit = "chars"

    warnings = []
    if bottleneck is not None:
        warnings.append(
            "%d imagenes: el banco solo tiene %d poses de %s usables, asi que "
            "habra repeticion" % (total, avail.get(bottleneck, 0), bottleneck))
    blocked_reason = None
    if total == 0:
        blocked_reason = "el plan sale vacio"
    return {
        "mode": spec["mode"],
        "stages": spec["stages"],
        "theme": spec["theme"],
        "total": total,
        "chars": len(items_out),
        "items": items_out,
        "warnings": warnings,
        "limits": {
            "levels": limits,
            "by_stage": counts["by_stage"],
            "no_repeat_max": int(max_total) if max_total is not None else None,
            "no_repeat_unit": unit,
        },
        "blocked": blocked_reason is not None,
        "blocked_reason": blocked_reason,
        "pick_seed": plan["pick_seed"],
        "seed": spec["seed"],
        "quotas": plan["quotas"],
        "allow_repeat": spec["allow_repeat"],
    }


def _clip(text, size) -> str:
    text = (text or "")[:max(0, int(size))].rstrip(" ._-+")
    return text


def run_label(preview, when=None, limit=78) -> str:
    """Nombre legible de carpeta: `<personaje>_<ambiente>_<fecha>`.

    - 1 personaje  -> `Reze_(Chainsaw_Man)_locker_room_20261030_215533`
    - 2 personajes -> `A-B_<ambiente>_<fecha>`
    - mas          -> `grupoN_<2 ambientes>+N_<fecha>` (N personajes no caben)

    Sustituye al viejo `<stamp>_<plan_id>` (D2.15). El `plan_id` sigue siendo
    el ID interno del plan; esto es solo el nombre del directorio. Se sanea con
    `fs_name` (`Reze` lleva `\\(`) y los espacios pasan a `_` para poder
    escribirlo en la consola sin comillas. La **fecha nunca se recorta**: si no
    cabe, se acortan el personaje y el ambiente.
    """
    items = preview.get("items") or []
    names, themes = [], []
    for item in items:
        if item.get("name") and item["name"] not in names:
            names.append(item["name"])
        theme = item.get("theme")
        if theme and theme not in themes:
            themes.append(theme)
    if len(names) == 1:
        who = names[0]
    elif len(names) == 2:
        who = "%s-%s" % (names[0], names[1])
    else:
        who = "grupo%d" % max(1, len(names))

    amb = "-".join(themes[:2])
    if len(themes) > 2:
        amb += "+%d" % (len(themes) - 2)

    stamp = (when or datetime.now()).strftime("%Y%m%d_%H%M%S")
    budget = limit - len(stamp) - 1
    amb = _clip(amb, min(30, max(0, budget - 8)))
    who = _clip(who, max(1, budget - (len(amb) + 1 if amb else 0)))

    parts = [p for p in (who, amb, stamp) if p]
    return fs_name("_".join(parts).replace(" ", "_"))[:limit].rstrip(" ._")


def unique_run_dir(root, preview, when=None) -> Path:
    """`run_label()` garantizando que la carpeta no existe (sufijo `_2`, `_3`)."""
    label = run_label(preview, when)
    path = Path(root) / label
    n = 2
    while path.exists():
        path = Path(root) / ("%s_%d" % (label, n))
        n += 1
    return path
