"""Montador del kit · marbuilds.dev/resources/video-gta-ia

Todo sale de UN fichero, montaje.json: la lista de planos, el HUD, los subtítulos y el cierre.
Uso (desde esta carpeta):

    python3 montaje.py revisar montaje.json      # 6 fotogramas del HUD para mirar antes de renderizar
    python3 montaje.py todo montaje.json         # música, vídeo base, audio, HUD y vídeo final

Los tiempos del HUD se escriben RELATIVOS a cada plano: ["G", 1.5] = segundo 1,5 del plano G
(sin contar su congelación inicial), ["G", "fin"] = donde acaba G, ["inicio"] = el segundo 0.
Así, si cambias la duración de un plano, todo lo de después se recoloca solo.

Requisitos: python3 con numpy, ffmpeg, node 20+, `npm i playwright && npx playwright install chromium`,
y la fuente Pricedown ("Pricedown Bl.otf", tal cual viene de DaFont) dentro de fuentes/.
"""
import json
import subprocess
import sys
from pathlib import Path

AQUI = Path(__file__).parent


def sh(args):
    subprocess.run([str(a) for a in args], check=True)


class Montaje:
    def __init__(self, cfg_path):
        self.cfg_path = Path(cfg_path).resolve()
        self.cfg = json.loads(self.cfg_path.read_text())
        self.base_dir = self.cfg_path.parent
        c = self.cfg
        self.formato = c.get("formato", "wide")
        self.fps = c.get("fps", 30)
        self.W, self.H = (1920, 1080) if self.formato == "wide" else (1080, 1920)
        self.VY = 600  # en vertical, la franja 16:9 empieza aquí
        self.clips = (self.base_dir / c.get("clips", "clips")).resolve()
        self.build = (self.base_dir / "build" / self.formato).resolve()
        self.segs = c["planos"]
        # duración de cada plano en fotogramas enteros: sin esto, el audio y el HUD derivan del vídeo
        self.start, self.len, t = {}, {}, 0.0
        for s in self.segs:
            d = (s["hasta"] - s["desde"]) / s.get("velocidad", 1) + s.get("congelar_inicio", 0) + s.get("congelar_final", 0)
            d = round(d * self.fps) / self.fps
            self.start[s["id"]], self.len[s["id"]] = round(t, 3), d
            t += d
        self.total = round(t, 3)

    # --- tiempos ---------------------------------------------------------------------------
    def t(self, ref):
        if isinstance(ref, (int, float)):
            return float(ref)
        if ref[0] == "inicio":
            return 0.0
        if ref[0] == "final":
            return self.total
        sid, x = ref[0], ref[1]
        s = next(p for p in self.segs if p["id"] == sid)
        if x == "fin":
            return round(self.start[sid] + self.len[sid], 3)
        return round(self.start[sid] + s.get("congelar_inicio", 0) + x, 3)

    def tramo(self, par):
        return [self.t(par[0]), self.t(par[1])]

    # --- timeline del HUD --------------------------------------------------------------------
    def timeline(self):
        h = self.cfg["hud"]
        vert = self.formato == "vertical"
        T = {
            "duration": self.total, "layout": self.formato, "W": self.W, "H": self.H,
            "serie": h.get("serie", ""), "nivel": h.get("nivel", ""), "moneda": h.get("moneda", "$"),
            "mision": {"etiqueta": h["mision"]["etiqueta"], "titulo": h["mision"]["titulo"]},
            "cierre": {**h.get("cierre", {})},
            "end": next((self.start[s["id"]] for s in self.segs if s["tipo"] == "cierre"), self.total + 1),
            "phone": [[self.start[s["id"]], self.start[s["id"]] + self.len[s["id"]]] for s in self.segs if s["tipo"] == "movil"],
            "missionBox": self.tramo(h["mision"]["en"]),
            "gameplay": [self.tramo(x) for x in h.get("juego", [])],
            "objectives": [{"label": o["texto"],
                            **({"miss": self.t(o["falta"])} if "falta" in o else {}),
                            **({"done": self.t(o["hecho"])} if "hecho" in o else {})} for o in h.get("objetivos", [])],
            "clock": {"label": h.get("reloj", {}).get("etiqueta", ""),
                      "start": self.t(h["reloj"]["claves"][0][0]) if "reloj" in h else 1e9,
                      "keys": [[self.t(k), v] for k, v in h.get("reloj", {}).get("claves", [])]},
            "stars": [[self.t(k), v] for k, v in h.get("estrellas", [[["inicio"], 0]])],
            "cash": [[self.t(k), v] for k, v in h.get("dinero", [[["inicio"], 0]])],
            "xp": [[self.t(k), v] for k, v in h.get("xp", [[["inicio"], 0]])],
            "map": [{"at": self.tramo(m["en"]), "label": m["etiqueta"], "scroll": m.get("desplazamiento", 120)} for m in h.get("minimapa", [])],
            "prompts": [{"at": self.tramo(b["en"]), "k": b["tecla"], "text": b["texto"]} for b in h.get("botones", [])],
            "npc": [{"at": self.tramo(n["en"]), "name": n["nombre"], "lvl": n["nivel"], "x": n["x"], "y": n["y"]} for n in h.get("npc", [])],
            "pops": [{"at": self.tramo(p["en"]), "n": p.get("grande"), "t": p["texto"], "y": p.get("y", 250),
                      "phone": p.get("sobre_movil", False), "nSize": p.get("tam_grande"), "tSize": p.get("tam_texto")}
                     for p in h.get("rotulos", [])],
            "stings": [{"at": self.tramo(s["en"]), "html": s["html"],
                        "y": (self.VY - s.get("subir", 60)) if vert else None} for s in h.get("stingers", [])],
            "subs": [{"at": self.tramo(s["en"]),
                      "html": f'<b style="color:{s.get("color", "#ffd23f")}">{s["quien"]}:</b> {s["texto"]}' if s.get("quien") else s["texto"]}
                     for s in h.get("subtitulos", [])],
        }
        if T["cierre"].get("logo"):
            logo = (self.base_dir / T["cierre"]["logo"]).resolve()
            if logo.exists():
                T["cierre"]["logo"] = logo.as_uri()
            else:
                print(f"Aviso: no encuentro el logo {logo}; la tarjeta final sale sin él")
                T["cierre"].pop("logo")
        self.build.mkdir(parents=True, exist_ok=True)
        (self.build / "timeline.json").write_text(json.dumps(T, ensure_ascii=False, indent=1))
        return T

    # --- música y efectos ----------------------------------------------------------------------
    def musica(self, T):
        m = self.cfg.get("audio", {}).get("musica", "sintetizar")
        if m != "sintetizar":
            sh(["python3", AQUI / "musica.py", self.build, "efectos"])  # los efectos se generan igual
            return (self.base_dir / m).resolve()
        stings = [s["at"][0] for s in T["stings"]]
        mision = self.t(self.cfg["audio"]["arranca_beat"]) if "arranca_beat" in self.cfg.get("audio", {}) else 0
        fin_gancho = self.t(self.cfg["audio"]["fin_gancho"]) if "fin_gancho" in self.cfg.get("audio", {}) else 0
        cumplida = self.t(self.cfg["audio"]["hueco_final"]) if "hueco_final" in self.cfg.get("audio", {}) else self.total
        outro = self.t(self.cfg["audio"]["outro"]) if "outro" in self.cfg.get("audio", {}) else self.total
        sh(["python3", AQUI / "musica.py", self.build, self.total + 0.5, mision, cumplida, outro, fin_gancho])
        return self.build / "musica.wav"

    # --- vídeo base ------------------------------------------------------------------------------
    def filtro(self, s, pre, pad):
        """Grafo de ffmpeg que coloca un plano en el lienzo del formato elegido."""
        W, H = self.W, self.H
        if s["tipo"] == "movil":
            c = s.get("recorte_movil")  # [ancho, alto, x, y] en píxeles del vídeo original, opcional
            crop = f",crop={c[0]}:{c[1]}:{c[2]}:{c[3]}" if c else ""
            if self.formato == "vertical":
                return f"[0:v]{pre}{crop}{pad},scale={W}:{H}:force_original_aspect_ratio=increase,crop={W}:{H},setsar=1[v]"
            return (f"[0:v]{pre}{crop}{pad},split[x][y];[x]scale={W}:{H}:force_original_aspect_ratio=increase,crop={W}:{H},"
                    f"boxblur=30:2,eq=brightness=-0.25:saturation=0.7[bg];[y]scale=-2:{H},setsar=1[fg];[bg][fg]overlay=(W-w)/2:0[v]")
        if self.formato == "wide":
            return f"[0:v]{pre}{pad},scale={W}:{H}:force_original_aspect_ratio=increase,crop={W}:{H},setsar=1[v]"
        return (f"[0:v]{pre}{pad},split[x][y];[x]scale=-2:{H},crop={W}:{H},boxblur=28:2,eq=brightness=-0.18:saturation=0.8[bg];"
                f"[y]scale={W}:608,setsar=1[fg];[bg][fg]overlay=0:{self.VY}[v]")

    def base(self):
        W, H, fps = self.W, self.H, self.fps
        partes = []
        for s in self.segs:
            k = s["id"]; out = self.build / f"seg_{k}.mp4"; n = round(self.len[k] * fps)
            if s["tipo"] == "cierre":
                sh(["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", f"color=c=0x050507:s={W}x{H}:r={fps}",
                    "-frames:v", n, "-c:v", "libx264", "-crf", 16, "-pix_fmt", "yuv420p", out])
                partes.append(out); continue
            vel = s.get("velocidad", 1)
            pre = f"trim={s['desde']}:{s['hasta']},setpts=(PTS-STARTPTS)/{vel},fps={fps}"
            pad = ""
            if s.get("congelar_inicio"): pad += f",tpad=start_mode=clone:start_duration={s['congelar_inicio']}"
            if s.get("congelar_final"): pad += f",tpad=stop_mode=clone:stop_duration={s['congelar_final']}"
            pad += f",tpad=stop_mode=clone:stop_duration=0.2,setpts=N/{fps}/TB"  # rellena y renumera
            fc = self.filtro(s, pre, pad)
            sh(["ffmpeg", "-v", "error", "-y", "-i", self.clips / s["fuente"], "-filter_complex", fc, "-map", "[v]",
                "-frames:v", n, "-an", "-c:v", "libx264", "-crf", 16, "-preset", "fast", "-pix_fmt", "yuv420p", "-r", fps, out])
            partes.append(out)
        lst = self.build / "lista.txt"
        lst.write_text("".join(f"file '{p.name}'\n" for p in partes))
        sh(["ffmpeg", "-v", "error", "-y", "-f", "concat", "-safe", 0, "-i", lst, "-c", "copy", self.build / "base.mp4"])

    # --- audio -------------------------------------------------------------------------------------
    def audio(self, T, musica):
        a = self.cfg.get("audio", {})
        inputs, chains, dia = [], [], []
        def entrada(path):
            inputs.extend(["-i", str(path)]); return len(inputs) // 2 - 1
        for s in self.segs:
            if s["tipo"] != "plano" or s.get("silenciar"):
                continue
            i = entrada(self.clips / s["fuente"]); k = s["id"]
            vel = s.get("velocidad", 1)
            d = int((self.start[k] + s.get("congelar_inicio", 0)) * 1000); dur = (s["hasta"] - s["desde"]) / vel
            tempo = f"atempo={vel}," if vel != 1 else ""
            chains.append(f"[{i}:a]atrim={s['desde']}:{s['hasta']},asetpts=PTS-STARTPTS,{tempo}aresample=48000,aformat=channel_layouts=stereo,"
                          f"afade=t=in:d=0.04,afade=t=out:st={dur - 0.06}:d=0.06,adelay={d}|{d}[d{k}]")
            dia.append(f"[d{k}]")
        mi = entrada(musica)
        # volumen de la música por tramos: [[desde, ganancia], ...]; por defecto baja (0.3) para que la voz mande
        tramos = [[self.t(r), g] for r, g in a.get("volumen_musica", [[["inicio"], 0.3]])]
        expr = str(tramos[-1][1])
        for i in range(len(tramos) - 2, -1, -1):
            expr = f"if(lt(t,{tramos[i + 1][0]}),{tramos[i][1]},{expr})"
        chains.append(f"[{mi}:a]volume='{expr}':eval=frame,atrim=0:{self.total}[mus]")
        fx = []
        def sfx(nombre, at, gain):
            j = entrada(self.build / f"sfx-{nombre}.wav"); ms = int(max(0, at) * 1000)
            chains.append(f"[{j}:a]volume={gain},adelay={ms}|{ms}[x{j}]"); fx.append(f"[x{j}]")
        if a.get("efectos", True) and (self.build / "sfx-ding.wav").exists():
            for s in self.segs[1:]:
                sfx("whoosh", self.start[s["id"]] - 0.12, 0.35)
            hueco = self.t(a["hueco_final"]) if "hueco_final" in a else None
            for st in T["stings"]:
                if hueco is not None and abs(st["at"][0] - hueco) < 0.05:
                    continue  # ahí suena la fanfarria de misión cumplida
                sfx("riser", st["at"][0] - 1.2, 0.5); sfx("hit", st["at"][0], 0.7)
            for o in T["objectives"]:
                if "miss" in o: sfx("fail", o["miss"], 0.35)
                if o.get("done"): sfx("ding", o["done"], 0.5)
            for p in T["pops"]:
                sfx("ding", p["at"][0], 0.45)
            prev = 0
            for ts, k in T["stars"]:
                if k > prev: sfx("star", ts, 0.5)
                prev = k
            if T["clock"]["keys"]: sfx("tick", T["clock"]["start"], 0.6)
            if hueco is not None:
                sfx("riser", hueco - 1.2, 0.5); sfx("passed", hueco, 0.8)
        partes = ["[mus]"]
        if dia:
            chains.append(f"{''.join(dia)}amix=inputs={len(dia)}:normalize=0,volume=1.8,apad=whole_dur={self.total},asplit[dia][key]")
            chains.append("[mus][key]sidechaincompress=threshold=0.012:ratio=10:attack=10:release=300[musd]")
            partes = ["[dia]", "[musd]"]
        if fx:
            chains.append(f"{''.join(fx)}amix=inputs={len(fx)}:normalize=0[fx]"); partes.append("[fx]")
        chains.append(f"{''.join(partes)}amix=inputs={len(partes)}:normalize=0,atrim=0:{self.total},loudnorm=I=-14:TP=-1.5:LRA=11[out]")
        sh(["ffmpeg", "-v", "error", "-y", *inputs, "-filter_complex", ";".join(chains), "-map", "[out]", "-ar", 48000, self.build / "mezcla.wav"])

    # --- final ----------------------------------------------------------------------------------------
    def final(self):
        salida = self.base_dir / self.cfg.get("salida", f"video_{self.formato}.mp4")
        sh(["ffmpeg", "-v", "error", "-y", "-i", self.build / "base.mp4", "-framerate", self.fps, "-i", self.build / "hud" / "f_%05d.png",
            "-i", self.build / "mezcla.wav", "-filter_complex", "[0:v][1:v]overlay=0:0:format=auto[v]", "-map", "[v]", "-map", "2:a",
            "-c:v", "libx264", "-crf", 17, "-preset", "medium", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k",
            "-movflags", "+faststart", "-t", self.total, salida])
        return salida

    def revision(self, instantes):
        """Compone el HUD sobre el fotograma real de cada instante, para ver si tapa caras o textos."""
        for t in instantes:
            s = next(p for p in reversed(self.segs) if self.start[p["id"]] <= t)
            hud = self.build / "revision" / f"revision_{t:.2f}.png"
            out = self.build / "revision" / f"revision_{t:.2f}.jpg"
            if s["tipo"] == "cierre":
                fondo = ["-f", "lavfi", "-i", f"color=c=0x050507:s={self.W}x{self.H}"]
                fc = "[0:v]null[v]"
            else:
                rel = t - self.start[s["id"]] - s.get("congelar_inicio", 0)
                src = min(max(s["desde"] + rel * s.get("velocidad", 1), s["desde"]), s["hasta"] - 0.05)
                fondo = ["-ss", f"{src:.3f}", "-i", self.clips / s["fuente"]]
                fc = self.filtro(s, "null", "")
            sh(["ffmpeg", "-v", "error", "-y", *fondo, "-i", hud, "-filter_complex", f"{fc};[v][1:v]overlay=0:0[o]",
                "-map", "[o]", "-frames:v", 1, "-q:v", 3, out])

    def hud(self, solo=None):
        args = ["node", AQUI / "render_hud.mjs", self.build / "timeline.json", self.build / ("revision" if solo else "hud"), self.fps]
        if solo: args.append(solo)
        sh(args)


def main():
    sys.stdout.reconfigure(line_buffering=True)  # mensajes en orden con los de ffmpeg y node
    if len(sys.argv) < 3 or sys.argv[1] not in ("revisar", "todo"):
        print(__doc__); sys.exit(1)
    m = Montaje(sys.argv[2])
    T = m.timeline()
    print(f"Duración total: {m.total} s · {round(m.total * m.fps)} fotogramas · formato {m.formato}")
    for s in m.segs:
        print(f"  {s['id']:>3}  {m.start[s['id']]:7.2f} s  {m.len[s['id']]:5.2f} s  {s.get('fuente') or 'cierre'}")
    if sys.argv[1] == "revisar":
        instantes = [round(m.total * f, 2) for f in (0.03, 0.2, 0.4, 0.6, 0.8, 0.97)]
        m.hud(",".join(map(str, instantes)))
        m.revision(instantes)
        print(f"Revisa las imágenes en {m.build / 'revision'} (revision_*.jpg: el HUD sobre la imagen real)")
        return
    musica = m.musica(T)
    print("Vídeo base..."); m.base()
    print("Audio..."); m.audio(T, musica)
    print("HUD (el paso largo)..."); m.hud()
    print("Final..."); print("Listo:", m.final())


if __name__ == "__main__":
    try:
        main()
    except subprocess.CalledProcessError:
        print("\nSe ha parado en el paso de arriba. El mensaje justo encima de esta línea dice qué falta.")
        sys.exit(1)
