#!/usr/bin/env python3
"""render_release_videos — the release's videos come from Slopmotion, not a still.

I got this wrong first: `master yt render` wrapped each track in a still image,
and I picked that still from `output/*.jpg` — nine illustrations of unknown
provenance that `6c3272c refactor: Remove covers from git` had deliberately
taken OUT of the repo. PLN: *"i dont like these covers, wait where are tehse
genai images from? these are not great sources! we wanna use slopmotion for
videos remember?"* Both objections stand. Untracked images with no recorded
origin are exactly what the sample-rights ledger exists to refuse, and applying
that discipline to audio while grabbing pictures off the floor is not a policy.

So: Slopmotion (`../../visuals/slop/render_slop_clip.mjs`, Kevin's
`hydra-live-hexa`) renders the video, and this bridges it to the release plan —
same plan the SoundCloud and YouTube adapters read, so nothing forks. It names
its output exactly what `tidal_ears.yt.video_path` expects, so `master yt
upload --videos <dir>` finds it with no translation step.

## The cost is wall time, and it is not small

Slopmotion captures in REALTIME, on purpose: Hydra's animation clock is wall
time, so frame-stepping plays motion ~3x fast and drifts off the audio it is
reacting to. A full-length render therefore costs the track's own duration, per
shape. OPAL-26 is 4361 s of continuous mix plus 4361 s of tracks — about 2h25 of
headless Chromium for the landscape shape alone.

That is far too long for a session-bound background job, so `--unit` emits a
`systemd --user` service instead of pretending a shell job will survive. Renders
are resumable: an existing output is skipped, so an interrupted batch costs only
the clip it was in the middle of.

    # preflight only — checks node 22, the dev server, and the plan
    python3 render_release_videos.py PLAN --videos DIR --check

    # render, resumable
    python3 render_release_videos.py PLAN --videos DIR --playset obsidian-tide

    # or hand it to systemd, because 2h25 outlives a terminal
    python3 render_release_videos.py PLAN --videos DIR --unit
"""
from __future__ import annotations

import argparse
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path

TIDAL = Path(__file__).resolve().parents[2]
SLOP = TIDAL / "visuals/slop/render_slop_clip.mjs"
SLOP_OUT = TIDAL / "visuals/slop/out"
IDEAS = TIDAL / "visuals/slop/clip_ideas.json"
HEXA = Path(os.environ.get("HEXA_DIR", "/home/pln/Work/Sound/hydra-live-hexa"))
PACKS = HEXA / "src/data/playsetPacks.json"
BASE_URL = os.environ.get("SLOP_BASE_URL", "http://localhost:5173")
NVM_NODE = Path.home() / ".nvm/versions/node"

SHAPES = {"landscape": "YouTube 1920x1080",
          "square": "feed 1080x1080",
          "vertical": "reels 1080x1920"}


# ---------------------------------------------------------------- naming
# Deliberately imported from the adapter rather than reimplemented: two copies
# of a filename rule is how a renderer and an uploader come to disagree about
# which file is track 7.
def _yt():
    sys.path.insert(0, "/home/pln/Work/Sound/tidal-ears/src")
    from tidal_ears import yt
    return yt


def node22() -> str | None:
    """The newest local node >= 22. The renderer needs it; system node is 16."""
    if not NVM_NODE.exists():
        return None
    cands = []
    for d in NVM_NODE.iterdir():
        try:
            major = int(d.name.lstrip("v").split(".")[0])
        except ValueError:
            continue
        if major >= 22 and (d / "bin/node").exists():
            cands.append((major, d / "bin/node"))
    return str(max(cands)[1]) if cands else None


def dev_server_up() -> bool:
    import urllib.error
    import urllib.request
    try:
        with urllib.request.urlopen(BASE_URL, timeout=3) as r:
            return r.status < 500
    except Exception:
        return False


def playsets() -> set[str]:
    if not PACKS.exists():
        return set()
    d = json.loads(PACKS.read_text())
    items = d if isinstance(d, list) else d.get("packs", [])
    return {p.get("slug") or p.get("name") for p in items if isinstance(p, dict)}


# `clip_ideas.json` predates the release plan and agrees with it on neither
# axis. Its titles are shorter or in French ("Sunshine", "La Revolution Sera
# Samplee"), and its `setlist_pos` follows an EARLIER running order — its 4 is
# Take five Drops where the plan's performance 4 is Am i Doing it Right. So
# neither title equality nor position is a safe join, and fuzzy title matching
# is worse than no join at all: it puts a playset on the wrong track silently.
#
# Hence an explicit, auditable alias table. Short, checked by eye once, and it
# fails loudly (the clip reports no playset) rather than guessing.
IDEA_ALIASES = {
    "Sunshine": "You My Sunshine",
    "La Revolution Sera Samplee": "REVOLUTION",
}


def idea_playsets() -> dict[str, list[str]]:
    """release title -> the playsets that clip idea already chose, if any.

    Only 7 of the 15 performed tracks have a clip idea, and one of those is
    Desire, which is cut from the release — so this covers a minority even after
    aliasing. The rest fall back to `--playset`, and that fallback is a look
    decision rather than a default the tool should quietly make for PLN.
    """
    if not IDEAS.exists():
        return {}
    d = json.loads(IDEAS.read_text())
    out = {}
    for i in d.get("ideas", []):
        t = i.get("opal_title")
        ps = (i.get("visual") or {}).get("playsets") or []
        if t and ps:
            out[IDEA_ALIASES.get(t, t)] = ps
    return out


def jobs(plan: dict, videos: Path, which: str) -> list[dict]:
    yt = _yt()
    todo = yt.entries(plan, which)
    ideas = idea_playsets()
    out, pos = [], 0
    for e in todo:
        if not e.get("_is_continuous_mix"):
            pos += 1
        dest = yt.video_path(videos, e, plan, pos)
        out.append({
            "title": e["title"],
            "audio": Path(e["audio"]),
            "dest": dest,
            "stem": dest.stem,
            "dur": float(e.get("_duration_s") or 0.0),
            "idea_playsets": ideas.get(e["title"]),
            "is_mix": bool(e.get("_is_continuous_mix")),
        })
    return out


def hms(s: float) -> str:
    s = int(round(s))
    return f"{s // 3600}h{(s % 3600) // 60:02d}m" if s >= 3600 else f"{s // 60}m{s % 60:02d}s"


def preflight(js: list[dict], shape: str, playset: str | None) -> list[str]:
    """Everything that would make the batch fail, found before it starts.

    A realtime renderer that dies on argument three has cost real minutes, so
    every check that can happen up front happens up front.
    """
    bad = []
    if not SLOP.exists():
        bad.append(f"renderer missing: {SLOP}")
    if node22() is None:
        bad.append("no node >= 22 in ~/.nvm (system node is 16 and fails)")
    if not dev_server_up():
        bad.append(f"Slopmotion dev server not answering at {BASE_URL}\n"
                   f"      start it: cd {HEXA} && nvm use 22 && npm run dev")
    if shape not in SHAPES:
        bad.append(f"unknown shape {shape!r}; have {', '.join(SHAPES)}")
    have = playsets()
    if playset and have and playset not in have:
        bad.append(f"unknown playset {playset!r}; {len(have)} available")
    for j in js:
        if not j["audio"].exists():
            bad.append(f"audio missing: {j['audio'].name}")
    if not playset:
        missing = [j["title"] for j in js if not j["idea_playsets"]]
        if missing:
            bad.append(f"{len(missing)} clip(s) have no playset from clip_ideas.json "
                       f"and no --playset was given — that is a look decision, so "
                       f"it is not being guessed:\n      "
                       + ", ".join(m[:26] for m in missing[:6])
                       + (" …" if len(missing) > 6 else ""))
    return bad


def render_one(j: dict, shape: str, playset: str, node: str,
               fps: int, verbose: bool = True) -> bool:
    rel = j["audio"].relative_to(TIDAL) if j["audio"].is_relative_to(TIDAL) \
        else j["audio"]
    cmd = [node, str(SLOP),
           "--audio", str(rel),
           "--dur", "full",
           "--shape", shape,
           "--fps", str(fps),
           "--name", j["stem"],
           "--playset", playset]
    if verbose:
        print(f"    {' '.join(cmd[1:])}")
    r = subprocess.run(cmd, cwd=str(TIDAL))
    if r.returncode != 0:
        print(f"    ! renderer exited {r.returncode}")
        return False
    produced = SLOP_OUT / f"{j['stem']}_{shape}.mp4"
    if not produced.exists():
        print(f"    ! renderer reported success but {produced.name} is not there")
        return False
    j["dest"].parent.mkdir(parents=True, exist_ok=True)
    # Hardlink when we can: these are hundreds of megabytes each, and the slop
    # out/ copy is worth keeping as the renderer's own record.
    try:
        if j["dest"].exists():
            j["dest"].unlink()
        os.link(produced, j["dest"])
    except OSError:
        shutil.copy2(produced, j["dest"])
    return True


def write_unit(js: list[dict], a) -> Path:
    """A systemd --user unit, because 2h25 of rendering outlives a terminal.

    Not Type=oneshot with a shell loop: the point is that closing the session,
    or this agent's own process going away, must not take the render with it.
    """
    unit = Path.home() / ".config/systemd/user/parvagues-slop-release.service"
    unit.parent.mkdir(parents=True, exist_ok=True)
    argv = [sys.executable, str(Path(__file__).resolve()), str(a.plan),
            "--videos", str(a.videos), "--shape", a.shape,
            "--which", a.which, "--fps", str(a.fps)]
    if a.playset:
        argv += ["--playset", a.playset]
    total = sum(j["dur"] for j in js)
    unit.write_text(f"""[Unit]
Description=ParVagues — Slopmotion release render ({len(js)} clips, ~{hms(total)})
After=network.target

[Service]
Type=oneshot
WorkingDirectory={TIDAL}
Environment=HEXA_DIR={HEXA}
Environment=SLOP_BASE_URL={BASE_URL}
ExecStart={' '.join(argv)}
# Realtime capture: the batch takes as long as the music does. No timeout.
TimeoutStartSec=0
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=default.target
""")
    return unit


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("plan")
    ap.add_argument("--videos", required=True, help="where yt upload will look")
    ap.add_argument("--shape", default="landscape", choices=list(SHAPES))
    ap.add_argument("--which", default="all", choices=["set", "album", "all"])
    ap.add_argument("--playset", default=None,
                    help="one playset for every clip; without it, each clip must "
                         "have one from clip_ideas.json")
    ap.add_argument("--fps", type=int, default=30)
    ap.add_argument("--check", action="store_true", help="preflight and stop")
    ap.add_argument("--unit", action="store_true",
                    help="write a systemd --user unit instead of rendering")
    ap.add_argument("--overwrite", action="store_true")
    a = ap.parse_args()

    plan = json.loads(Path(a.plan).read_text())
    videos = Path(a.videos)
    js = jobs(plan, videos, a.which)

    done = [j for j in js if j["dest"].exists() and not a.overwrite]
    left = [j for j in js if j not in done]
    total = sum(j["dur"] for j in left)

    print(f"{plan['album']} · {SHAPES[a.shape]} · {len(js)} clip(s)")
    if done:
        print(f"  {len(done)} already rendered, skipping")
    print(f"  {len(left)} to render — ~{hms(total)} of REALTIME capture")
    for j in left:
        src = a.playset or (j["idea_playsets"][0] if j["idea_playsets"] else "—")
        print(f"    {hms(j['dur']):>7}  {j['stem'][:44]:<44} {src}")

    bad = preflight(left, a.shape, a.playset)
    if bad:
        print(f"\n{len(bad)} thing(s) to settle first:")
        for b in bad:
            print(f"  - {b}")
        return 1
    print("\npreflight: clean")
    if a.check:
        return 0
    if a.unit:
        u = write_unit(left, a)
        print(f"\nwrote {u}\n"
              f"  systemctl --user daemon-reload\n"
              f"  systemctl --user start parvagues-slop-release\n"
              f"  journalctl --user -fu parvagues-slop-release")
        return 0

    node = node22()
    ok = 0
    for n, j in enumerate(left, 1):
        ps = a.playset or j["idea_playsets"][0]
        print(f"\n[{n}/{len(left)}] {j['title']}  ({hms(j['dur'])}, {ps})")
        if render_one(j, a.shape, ps, node, a.fps):
            ok += 1
            print(f"    ✓ {j['dest'].name}")
    print(f"\n{ok}/{len(left)} rendered into {videos}")
    return 0 if ok == len(left) else 1


if __name__ == "__main__":
    sys.exit(main())
