"""Genera vistas novel-view (back/left/right) desde 1 imagen para alimentar el 3D multi-vista.

El camino imagen->3D de una sola vista deja indeterminada la cara trasera del objeto.
El nodo `Hunyuan3Dv2ConditioningMultiView` reconstruye mucho mejor con varias vistas
ortogonales, pero hace falta producirlas. ComfyUI 0.26.0 trae DOS sintetizadores de
vistas NATIVOS (sin custom node), confirmados en /object_info:

  - StableZero123 (`StableZero123_Conditioning_Batched`): control directo de azimuth
    por vista; un batch saca varias vistas en una pasada. Requiere el checkpoint
    `3D_stable_zero123.ckpt` (~8.58 GB; cabe en 8 GB solo para SINTESIS de vistas).
  - SV3D (`SV3D_Conditioning`): orbita de 21 frames en una pasada, mejor consistencia;
    requiere `sv3d_u.safetensors`/`3D_sv3d_p.safetensors` (~2.3 GB; modelo de video, mas
    exigente en VRAM).

Esta funcion es HONESTA sobre la viabilidad: consulta el servidor, comprueba que el
nodo Y su checkpoint estan disponibles, y SOLO encola si hay un camino viable. Si no
hay ningun (nodo + modelo) instalado, devuelve {ok: False, reason: ...} con la accion
concreta para habilitarlo, SIN tocar la GPU (no encola nada). Asi no finge un resultado
ni compite por la GPU cuando el modelo no esta.

Descartados por aislamiento: MV-Adapter y Zero123++ son custom nodes (no nativos); la
regla prohibe instalarlos aqui.

Compone comfyui_object_info + comfyui_submit_workflow + comfyui_wait_result +
comfyui_fetch_output_image. Impura: HTTP GET/POST + escritura en disco. Solo stdlib.
"""
import os
import sys

_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
    sys.path.insert(0, _THIS_DIR)

from comfyui_object_info import comfyui_object_info  # noqa: E402
from comfyui_validate_workflow import comfyui_validate_workflow  # noqa: E402
from comfyui_submit_workflow import comfyui_submit_workflow  # noqa: E402
from comfyui_wait_result import comfyui_wait_result  # noqa: E402
from comfyui_fetch_output_image import comfyui_fetch_output_image  # noqa: E402

# Checkpoint(s) requerido(s) por cada metodo (los carga ImageOnlyCheckpointLoader).
# Cada metodo declara una tupla de candidatos en orden de preferencia: se usa el
# primero que el servidor ofrezca. SV3D acepta 3D_sv3d_p (orbita con elevacion
# variable, preferido) o sv3d_u (orbita uniforme en el ecuador).
_METHOD_CKPT = {
    "zero123": ("3D_stable_zero123.ckpt",),
    "sv3d": ("3D_sv3d_p.safetensors", "sv3d_u.safetensors"),
}
# azimuth (grados) -> nombre de vista. 0=front (la que aporta el caller).
_AZIMUTH_NAME = {0: "front", 90: "right", 180: "back", 270: "left"}


def comfyui_generate_views_from_image(
    image_name: str,
    *,
    method: str = "auto",
    server: str = "127.0.0.1:8188",
    azimuths: tuple = (90, 180, 270),
    elevation: float = 0.0,
    video_frames: int = 21,
    sv3d_width: int = 576,
    sv3d_height: int = 576,
    dest_dir: str | None = None,
    validate_only: bool = False,
    wait_timeout: float = 300.0,
    timeout: float = 30.0,
) -> dict:
    """Sintetiza vistas novel-view desde una imagen ya subida al input/ de ComfyUI.

    Args:
        image_name: nombre del archivo de imagen en el `input/` del servidor
            (la vista frontal). Debe existir ya (subelo con POST /upload/image).
        method: 'zero123' (StableZero123, control de azimuth), 'sv3d' (orbita
            SVD) o 'auto' (elige el primero cuyo nodo+checkpoint esten
            instalados, prefiriendo zero123). keyword-only.
        server: host:port de ComfyUI. keyword-only.
        azimuths: angulos (grados) de las vistas a generar; 90=right, 180=back,
            270=left (0=front la aporta el caller). Se asumen equiespaciados para
            el batch. keyword-only.
        elevation: elevacion de camara en grados para todas las vistas.
            keyword-only.
        video_frames: SOLO sv3d. Numero de frames del orbit (vistas equiespaciadas
            en 360 grados que el modelo sintetiza en una pasada). Default 21 (el
            nativo de SV3D). Bajalo (p. ej. 7-12) para reducir VRAM en 8 GB
            lowvram a costa de menos densidad angular. keyword-only.
        sv3d_width: SOLO sv3d. Ancho de cada frame del orbit. Default 576 (nativo).
            Bajalo (p. ej. 320) para reducir VRAM. keyword-only.
        sv3d_height: SOLO sv3d. Alto de cada frame del orbit. Default 576 (nativo).
            keyword-only.
        dest_dir: carpeta local donde descargar las vistas generadas. Si None, no
            se descargan (solo se devuelven los nombres del output del servidor).
            keyword-only.
        validate_only: si True, construye el workflow y lo VALIDA contra
            /object_info (comfyui_validate_workflow) SIN encolar ni tocar la GPU,
            devolviendo el veredicto estructural. Util para comprobar viabilidad
            antes de comprometer GPU (y para smoke sin generar). keyword-only.
        wait_timeout: timeout de espera de la generacion en segundos. keyword-only.
        timeout: timeout HTTP por request en segundos. keyword-only.

    Returns:
        dict. Si hay camino viable y se genera:
          {ok: True, method, views: {"back": <ruta/nombre>, "left": ..., "right": ...},
           prompt_id, available: {...}, reason: "", error: ""}.
        Con method='sv3d' ademas trae el orbit completo:
          {..., "views": {<cardinales mapeados al frame mas cercano>},
           "frames": [<ruta/nombre de cada uno de los N frames del orbit>],
           "frame_count": N}.
        Con validate_only=True (no encola):
          {ok: <valido>, method, validated: True, valid, missing_nodes,
           missing_models, views: {}, available: {...}, reason: "", error: ""}.
        Si NINGUN nodo+modelo viable (stub honesto, no encola):
          {ok: False, method, views: {}, reason: "<que falta y como instalarlo>",
           available: {nodes: {...}, ckpts: {...}}, error: ""}.
        Cualquier fallo de red/encolado tambien devuelve ok=False con error.
    """
    # 1. Inventario del servidor (impuro, solo lectura: NO encola, NO toca GPU).
    try:
        oi = comfyui_object_info(server=server, timeout=timeout)
    except Exception as exc:  # noqa: BLE001
        return _stub(method, f"no se pudo consultar /object_info de {server}: {exc}", error=str(exc))

    nodes_present = {
        "zero123": "StableZero123_Conditioning_Batched" in oi,
        "sv3d": "SV3D_Conditioning" in oi,
    }
    ckpts = _checkpoint_combo(oi)
    ckpts_present = {m: (_resolve_ckpt(m, ckpts) is not None) for m in _METHOD_CKPT}
    available = {"nodes": nodes_present, "ckpts": ckpts_present, "ckpt_combo": ckpts}

    # 2. Elegir metodo viable (nodo + checkpoint presentes).
    order = [method] if method in _METHOD_CKPT else ["zero123", "sv3d"]
    chosen = next(
        (m for m in order if nodes_present.get(m) and ckpts_present.get(m)),
        None,
    )
    if chosen is None:
        return _stub(
            method,
            _why_unavailable(order, nodes_present, ckpts_present),
            available=available,
        )

    # 3. Construir el workflow.
    ckpt_name = _resolve_ckpt(chosen, ckpts)
    try:
        wf = _build_views_workflow(image_name, chosen, ckpt_name, azimuths, elevation,
                                   video_frames=video_frames,
                                   sv3d_width=sv3d_width, sv3d_height=sv3d_height)
    except NotImplementedError as exc:
        return _stub(chosen, str(exc), available=available, error=str(exc))

    # 3a. Modo validate_only: valida contra /object_info SIN encolar (no toca GPU).
    if validate_only:
        val = comfyui_validate_workflow(wf, server=server, timeout=timeout)
        return {"ok": bool(val.get("valid")), "method": chosen, "validated": True,
                "valid": val.get("valid"), "missing_nodes": val.get("missing_nodes", []),
                "missing_models": val.get("missing_models", []), "views": {},
                "available": available, "reason": "", "error": val.get("error", "")}

    # 3b. Encolar y generar (solo si hay camino viable y NO es validate_only).
    try:
        sub = comfyui_submit_workflow(wf, server=server, timeout=timeout)
        prompt_id = sub.get("prompt_id")
        if not prompt_id:
            return _stub(chosen, f"el servidor no devolvio prompt_id: {sub}", available=available)
        comfyui_wait_result(prompt_id, server=server, timeout=wait_timeout)
        if chosen == "sv3d":
            views, frames = _collect_views_sv3d(prompt_id, server, azimuths, dest_dir, timeout)
            return {"ok": True, "method": chosen, "views": views, "frames": frames,
                    "frame_count": len(frames), "prompt_id": prompt_id,
                    "available": available, "reason": "", "error": ""}
        views = _collect_views(prompt_id, server, azimuths, dest_dir, timeout)
        return {"ok": True, "method": chosen, "views": views, "prompt_id": prompt_id,
                "available": available, "reason": "", "error": ""}
    except Exception as exc:  # noqa: BLE001
        return _stub(chosen, f"fallo al generar vistas: {exc}", available=available, error=str(exc))


# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _checkpoint_combo(oi: dict) -> list:
    """Lista de checkpoints que el servidor ofrece a ImageOnlyCheckpointLoader."""
    for node in ("ImageOnlyCheckpointLoader", "CheckpointLoaderSimple"):
        spec = (oi.get(node) or {}).get("input", {}).get("required", {})
        decl = spec.get("ckpt_name")
        if isinstance(decl, list) and decl and isinstance(decl[0], list):
            return list(decl[0])
    return []


def _resolve_ckpt(method: str, ckpts: list) -> str | None:
    """Primer checkpoint candidato del metodo que el servidor ofrece, o None."""
    for cand in _METHOD_CKPT.get(method, ()):
        if cand in ckpts:
            return cand
    return None


def _why_unavailable(order, nodes_present, ckpts_present) -> str:
    parts = []
    for m in order:
        if m not in _METHOD_CKPT:
            continue
        if not nodes_present.get(m):
            parts.append(f"{m}: nodo nativo ausente en el servidor")
        elif not ckpts_present.get(m):
            ck = " o ".join(_METHOD_CKPT[m])
            parts.append(
                f"{m}: nodo OK pero falta el checkpoint '{ck}'. "
                f"Instalalo con comfyui_download_model(<url_{m}>, dest_subdir='checkpoints')"
            )
    return ("Ningun sintetizador de vistas nativo viable en 8 GB esta listo. "
            + " | ".join(parts) + ". Alternativa: aporta fotos reales del objeto "
            "(front/left/back/right) y usa comfyui_build_image_to_3d_multiview_workflow directamente.")


def _build_views_workflow(image_name, method, ckpt_name, azimuths, elevation, *,
                          video_frames=21, sv3d_width=576, sv3d_height=576) -> dict:
    """Workflow batched de sintesis de vistas. zero123 (azimuth) o sv3d (orbit)."""
    if method == "sv3d":
        return _build_sv3d_workflow(image_name, ckpt_name, elevation,
                                    video_frames, sv3d_width, sv3d_height)
    azs = sorted(azimuths)
    start = azs[0]
    increment = (azs[1] - azs[0]) if len(azs) > 1 else 90
    batch = len(azs)
    return {
        "1": {"class_type": "LoadImage", "inputs": {"image": image_name}},
        "2": {"class_type": "ImageOnlyCheckpointLoader", "inputs": {"ckpt_name": ckpt_name}},
        "3": {
            "class_type": "StableZero123_Conditioning_Batched",
            "inputs": {
                "clip_vision": ["2", 1],
                "init_image": ["1", 0],
                "vae": ["2", 2],
                "width": 256,
                "height": 256,
                "batch_size": batch,
                "elevation": elevation,
                "azimuth": start,
                "elevation_batch_increment": 0.0,
                "azimuth_batch_increment": increment,
            },
        },
        "4": {
            "class_type": "KSampler",
            "inputs": {
                "seed": 0, "steps": 20, "cfg": 4.0, "sampler_name": "euler",
                "scheduler": "normal", "denoise": 1.0,
                "model": ["2", 0], "positive": ["3", 0], "negative": ["3", 1],
                "latent_image": ["3", 2],
            },
        },
        "5": {"class_type": "VAEDecode", "inputs": {"samples": ["4", 0], "vae": ["2", 2]}},
        "6": {"class_type": "SaveImage", "inputs": {"images": ["5", 0], "filename_prefix": "novel_view"}},
    }


def _build_sv3d_workflow(image_name, ckpt_name, elevation, video_frames,
                         width, height) -> dict:
    """Workflow SV3D: 1 imagen -> orbit de `video_frames` vistas en una pasada.

    SV3D reutiliza la maquinaria img2vid de Stable Video Diffusion: el checkpoint
    (3D_sv3d_p / sv3d_u) se carga con ImageOnlyCheckpointLoader (MODEL+CLIP_VISION+VAE),
    SV3D_Conditioning produce el conditioning del orbit y la latente, y se muestrea
    como un video con guia CFG lineal. El SaveImage emite los N frames del orbit.
    """
    return {
        "1": {"class_type": "LoadImage", "inputs": {"image": image_name}},
        "2": {"class_type": "ImageOnlyCheckpointLoader", "inputs": {"ckpt_name": ckpt_name}},
        "3": {
            "class_type": "SV3D_Conditioning",
            "inputs": {
                "clip_vision": ["2", 1],
                "init_image": ["1", 0],
                "vae": ["2", 2],
                "width": width,
                "height": height,
                "video_frames": video_frames,
                "elevation": elevation,
            },
        },
        "4": {
            "class_type": "VideoLinearCFGGuidance",
            "inputs": {"model": ["2", 0], "min_cfg": 1.0},
        },
        "5": {
            "class_type": "KSampler",
            "inputs": {
                "seed": 0, "steps": 20, "cfg": 2.5, "sampler_name": "euler",
                "scheduler": "karras", "denoise": 1.0,
                "model": ["4", 0], "positive": ["3", 0], "negative": ["3", 1],
                "latent_image": ["3", 2],
            },
        },
        "6": {"class_type": "VAEDecode", "inputs": {"samples": ["5", 0], "vae": ["2", 2]}},
        "7": {"class_type": "SaveImage", "inputs": {"images": ["6", 0], "filename_prefix": "sv3d_view"}},
    }


def _history_images(prompt_id, server, timeout) -> list:
    """Lista ordenada de descriptores de imagen del history de un prompt."""
    import json
    import urllib.request

    url = f"http://{server}/history/{prompt_id}"
    with urllib.request.urlopen(url, timeout=timeout) as resp:
        hist = json.load(resp)
    outputs = (hist.get(prompt_id) or {}).get("outputs", {})
    images = []
    for node_out in outputs.values():
        images.extend(node_out.get("images", []))
    return images


def _fetch_or_name(img, dest_dir, server) -> str:
    """Descarga la imagen a dest_dir y devuelve su ruta, o el nombre si dest_dir es None."""
    if not dest_dir:
        return img["filename"]
    got = comfyui_fetch_output_image(
        img["filename"], subfolder=img.get("subfolder", ""),
        type_=img.get("type", "output"), server=server, dest_dir=dest_dir, timeout=60.0,
    )
    return got.get("path", img["filename"])


def _collect_views_sv3d(prompt_id, server, azimuths, dest_dir, timeout) -> tuple:
    """Mapea el orbit SV3D: devuelve (cardinales, lista completa de frames).

    SV3D produce N frames equiespaciados en 360 grados empezando por la vista
    frontal (frame 0 = azimuth 0). Para cada azimuth solicitado se localiza el
    frame del orbit mas cercano y se etiqueta con su nombre de vista cardinal,
    de modo que el resultado encaja con el consumidor multi-vista. `frames`
    conserva el orbit completo para reconstruccion 3D densa.
    """
    images = _history_images(prompt_id, server, timeout)
    frames = [_fetch_or_name(img, dest_dir, server) for img in images]
    n = len(frames)
    views = {}
    if n:
        for az in sorted(azimuths):
            idx = round((az % 360) / 360.0 * n) % n
            name = _AZIMUTH_NAME.get(az % 360, f"az{az % 360}")
            views[name] = frames[idx]
    return views, frames


def _collect_views(prompt_id, server, azimuths, dest_dir, timeout) -> dict:
    """Mapea las imagenes del SaveImage (en orden de azimuth) a nombres de vista."""
    images = _history_images(prompt_id, server, timeout)
    views = {}
    for img, az in zip(images, sorted(azimuths)):
        name = _AZIMUTH_NAME.get(az, f"az{az}")
        views[name] = _fetch_or_name(img, dest_dir, server)
    return views


def _stub(method, reason, *, available=None, error="") -> dict:
    return {"ok": False, "method": method, "views": {}, "reason": reason,
            "available": available or {}, "error": error}


if __name__ == "__main__":
    import json

    # Smoke ligero: valida el camino sin encolar (no toca GPU). Si el checkpoint
    # del sintetizador no esta instalado, devuelve el stub honesto ok=False.
    res = comfyui_generate_views_from_image("front.png", validate_only=True)
    print(json.dumps({k: v for k, v in res.items() if k != "available"}, indent=2))
    print("available:", json.dumps(res.get("available", {}).get("ckpts", {})))
