"""
VOD delivery assets: a lightweight playback proxy + a scrubber storyboard.

Why this exists
---------------
`/api/vods` used to hand the player the raw OBS master (1080p, ~25 Mbps, and
19 GB for a long night), and vod.js opened it TWICE — once for the <video>
element and once more for a hidden element that seeked around to paint the
scrub-preview canvas. Two full-bitrate streams for one viewer.

So each VOD gets two derived files, both under proxy_dir():

  <base>_proxy.mp4   720p30 ~3.5 Mbps h264 — what the player actually streams
  <base>_board.jpg   a grid of 160x90 thumbs, one every N seconds
  <base>_board.json  the grid geometry, so the frontend can index into it

The storyboard replaces the hidden second stream outright: hovering the
timeline becomes a CSS background-position offset into one already-cached
JPEG instead of a range request + decoder seek against a 19 GB file.

proxy_dir() is a SUBDIRECTORY of config.VOD_DIR on purpose: StaticFiles mounts
config.VOD_DIR recursively so these are reachable at /vod_media/vod_proxies/..., but
sync_vod_library's glob is non-recursive, so proxies can never be mistaken
for VODs and registered as their own library entries.
"""
from clipshelf import config
import json
import os
import threading
import subprocess
import time


def proxy_dir() -> str:
    """Resolved at call time — the recordings folder is user-settable."""
    return os.getenv("proxy_dir()") or os.path.join(config.VOD_DIR, "vod_proxies")

# Storyboard geometry. Tiles are sized to the existing #scrub-canvas (160x90).
BOARD_TW, BOARD_TH = 160, 90
BOARD_COLS = 20
BOARD_MAX_TILES = 600          # caps the sprite at 3200x2700 — well under any texture limit

# NVENC first, libx264 as the fallback when the GPU is busy or absent.
# -g 60 = a keyframe every 2s. Well below the ~8s default, because seek
# granularity IS the feature here: a scrub can only land on a keyframe, and
# these files exist to make jumping around a 3-hour night feel instant.
# Costs a few percent bitrate, which is not the constrained resource.
_GOP = ["-g", "60", "-keyint_min", "60", "-sc_threshold", "0"]
_V_NVENC = ["-c:v", "h264_nvenc", "-preset", "p4", "-b:v", "3500k",
            "-maxrate", "5M", "-bufsize", "8M", *_GOP]
_V_X264 = ["-c:v", "libx264", "-preset", "veryfast", "-crf", "24",
           "-maxrate", "5M", "-bufsize", "8M", *_GOP]


def _log(msg):
    print(f"[{time.strftime('%X')}] [vod-assets] {msg}", flush=True)


def proxy_path(filename: str) -> str:
    return os.path.join(proxy_dir(), os.path.splitext(filename)[0] + "_proxy.mp4")


def board_path(filename: str) -> str:
    return os.path.join(proxy_dir(), os.path.splitext(filename)[0] + "_board.jpg")


def board_meta_path(filename: str) -> str:
    return os.path.join(proxy_dir(), os.path.splitext(filename)[0] + "_board.json")


def probe_duration(path: str) -> float:
    try:
        res = subprocess.run(
            ["ffprobe", "-v", "error", "-show_entries", "format=duration",
             "-of", "default=noprint_wrappers=1:nokey=1", path],
            capture_output=True, text=True, check=True, timeout=60, encoding="utf-8", errors="replace")
        return float(res.stdout.strip())
    except Exception:
        return 0.0


def board_interval(dur_s: float) -> int:
    """One thumb every N seconds, rounded to 5s, so the sprite stays under BOARD_MAX_TILES."""
    if dur_s <= 0:
        return 10
    raw = dur_s / BOARD_MAX_TILES
    return max(5, int(-(-raw // 5)) * 5)


def _run_ffmpeg_with_progress(cmd: list, timeout: int, on_fraction=None, total_s: float = 0.0):
    """subprocess.run(check=True, timeout=) semantics, plus `-progress pipe:1`
    parsing: on_fraction(0..1) fires as out_time advances.

    The lines are pulled by a reader thread rather than select(). On Windows
    select() accepts ONLY sockets — handed a pipe it raises WinError 10038,
    "an operation was attempted on something that is not a socket". Which
    meant proxy building, and therefore the storyboard and smooth scrubbing
    of an hour-long recording, had never once worked on Windows: the error
    was caught, logged, and reported as a failed encode.
    """
    argv = [cmd[0], "-progress", "pipe:1", "-nostats", *cmd[1:]]
    proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=None, text=True,
                            encoding="utf-8", errors="replace")
    deadline = time.time() + timeout
    state = {"last": -1.0}

    def pump():
        try:
            for line in proc.stdout:            # blocks in the thread, not here
                if on_fraction and total_s > 0 and line.startswith("out_time_ms="):
                    val = line.split("=", 1)[1].strip()
                    if val.isdigit():
                        # ffmpeg quirk: out_time_ms is microseconds
                        frac = min(1.0, (int(val) / 1e6) / total_s)
                        if frac - state["last"] >= 0.01:
                            state["last"] = frac
                            try:
                                on_fraction(frac)
                            except Exception:
                                pass
        except Exception:
            pass                                # the encode's own result decides

    reader = threading.Thread(target=pump, daemon=True)
    reader.start()
    try:
        ret = proc.wait(timeout=max(1, deadline - time.time()))
        if ret != 0:
            raise subprocess.CalledProcessError(ret, cmd)
    except subprocess.TimeoutExpired:
        proc.kill()
        raise
    finally:
        if proc.poll() is None:
            proc.kill()
        reader.join(timeout=5)
        try:
            proc.stdout.close()
        except Exception:
            pass

def build_proxy(master_path: str, out_path: str, progress_cb=None) -> bool:
    """
    720p30 CFR proxy. `-map 0:a:0` pins the combined audio track — OBS masters
    carry several and ffmpeg's default pick is not reliably the one we want.
    `-r 30` forces CFR even from a 60fps or VFR master: the proxy is a delivery
    format, and the frame-timing story downstream is built on a 30 grid.

    progress_cb(0..1) reports encode position against the master's duration.
    """
    os.makedirs(os.path.dirname(out_path), exist_ok=True)
    total_s = probe_duration(master_path) if progress_cb else 0.0
    tmp = out_path + ".tmp.mp4"
    for vcodec in (_V_NVENC, _V_X264):
        try:
            _run_ffmpeg_with_progress(
                ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y",
                 "-i", master_path,
                 "-map", "0:v:0", "-map", "0:a:0",
                 "-vf", "scale=-2:720,format=yuv420p", "-r", "30",
                 *vcodec,
                 "-c:a", "aac", "-b:a", "128k", "-ac", "2",
                 "-movflags", "+faststart", "-f", "mp4", tmp],
                timeout=7200, on_fraction=progress_cb, total_s=total_s)
            os.replace(tmp, out_path)
            return True
        except Exception as e:
            _log(f"proxy ({vcodec[1]}) failed for {os.path.basename(master_path)}: {e}")
            if os.path.exists(tmp):
                os.remove(tmp)
    return False


def build_board(src_path: str, out_path: str, meta_path: str, dur_s: float) -> bool:
    """
    Sprite sheet of the whole VOD. Built from the PROXY, not the master — the
    tile filter has to decode every sampled frame, and decoding 3.5 Mbps of
    720p costs a fraction of what 25 Mbps of 1080p does over the SMB mount.
    """
    if dur_s <= 0:
        return False
    os.makedirs(os.path.dirname(out_path), exist_ok=True)
    interval = board_interval(dur_s)
    count = max(1, int(dur_s // interval) + 1)
    rows = max(1, -(-count // BOARD_COLS))
    tmp = out_path + ".tmp.jpg"
    try:
        subprocess.run(
            ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y",
             "-i", src_path, "-an",
             "-vf", (f"fps=1/{interval},scale={BOARD_TW}:{BOARD_TH}:force_original_aspect_ratio=decrease,"
                     f"pad={BOARD_TW}:{BOARD_TH}:(ow-iw)/2:(oh-ih)/2,"
                     f"tile={BOARD_COLS}x{rows}"),
             "-frames:v", "1", "-q:v", "4", tmp],
            check=True, timeout=3600)
        os.replace(tmp, out_path)
        with open(meta_path, "w") as fh:
            json.dump({"interval": interval, "cols": BOARD_COLS, "rows": rows,
                       "tw": BOARD_TW, "th": BOARD_TH, "count": count,
                       "dur": round(dur_s, 3)}, fh)
        return True
    except Exception as e:
        _log(f"board failed for {os.path.basename(src_path)}: {e}")
        if os.path.exists(tmp):
            os.remove(tmp)
        return False


_builds: dict = {}
_builds_lock = threading.Lock()


def build_all(filename: str, force: bool = False, progress_cb=None) -> dict:
    """
    Build whatever is missing for one VOD. Returns which pieces exist afterwards.
    Safe to call repeatedly — it is the resume mechanism for the backfill.

    Calls for the same file are serialised. The background worker and a
    person pressing "build scrub assets" used to race and encode the same
    recording twice at once: both wrote through temp files so the result was
    correct, but it burned double the CPU for minutes at a time, on the one
    machine that is also running a game.

    progress_cb(fraction, detail) spans the whole build: the proxy encode
    covers 0..0.92, the storyboard the rest.
    """
    with _builds_lock:
        lock = _builds.setdefault(filename, threading.Lock())
    with lock:
        return _build_all_locked(filename, force, progress_cb)


def _build_all_locked(filename: str, force: bool, progress_cb) -> dict:
    master = os.path.join(config.VOD_DIR, filename)
    if not os.path.exists(master):
        return {"proxy": False, "board": False, "error": "master missing"}

    pp, bp, mp = proxy_path(filename), board_path(filename), board_meta_path(filename)
    out = {"proxy": os.path.exists(pp), "board": os.path.exists(bp) and os.path.exists(mp)}

    if force or not out["proxy"]:
        t0 = time.time()
        _log(f"proxy start: {filename}")
        encode_cb = (lambda f: progress_cb(0.92 * f, "encoding proxy")) if progress_cb else None
        out["proxy"] = build_proxy(master, pp, progress_cb=encode_cb)
        if out["proxy"]:
            mb = os.path.getsize(pp) / 1048576
            _log(f"proxy done: {filename} -> {mb:.0f} MB in {time.time() - t0:.0f}s")

    if out["proxy"] and (force or not out["board"]):
        if progress_cb:
            progress_cb(0.94, "storyboard")
        # duration from the proxy: it is CFR and cheap to probe
        dur = probe_duration(pp) or probe_duration(master)
        out["board"] = build_board(pp, bp, mp, dur)
        if out["board"]:
            _log(f"board done: {filename} (every {board_interval(dur)}s)")

    return out


def board_info(filename: str) -> dict | None:
    """Grid geometry for the frontend, or None if this VOD has no storyboard yet."""
    mp = board_meta_path(filename)
    if not (os.path.exists(mp) and os.path.exists(board_path(filename))):
        return None
    try:
        with open(mp) as fh:
            return json.load(fh)
    except Exception:
        return None


def rel_url(path: str) -> str:
    """Absolute path under config.VOD_DIR -> the /vod_media URL that serves it."""
    from urllib.parse import quote
    return "/vod_media/" + quote(os.path.relpath(path, config.VOD_DIR).replace(os.sep, "/"))
