#!/usr/bin/env python3
"""Build a lightweight local feedback proxy bound to an exact-30fps master.

The accepted 1080x1920 master is never changed.  A verified 360x640 CFR proxy
is published atomically inside ``<master>.feedback/`` for fast browser review;
the manifest binds its frames and checksum back to the accepted master.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import subprocess
import tempfile
from fractions import Fraction
from pathlib import Path


PROXY_NAME = "review-proxy.mp4"
PROXY_WIDTH = 360
PROXY_HEIGHT = 640


HTML = r'''<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Frame feedback</title>
<style>
:root { color-scheme: dark; --bg:#151515; --panel:#222; --ink:#f5f1eb; --muted:#bdb7ad; --accent:#ceb890; }
* { box-sizing:border-box } body { margin:0; background:var(--bg); color:var(--ink); font:14px/1.4 system-ui,sans-serif }
main { display:grid; grid-template-columns:minmax(360px,400px) minmax(310px,430px); gap:16px; max-width:862px; margin:auto; padding:16px }
.viewer { min-width:0 }.frame-wrap { width:360px; max-width:100%; margin:auto; overflow:hidden; background:#000; border:1px solid #444; display:flex; align-items:center; justify-content:center }
canvas { width:min(360px,100%); height:auto; display:block; margin:auto; image-rendering:auto; background:#000 }
.bar,.controls,.note-row { display:flex; flex-wrap:wrap; align-items:center; gap:8px; margin:10px 0 }
.time { font:700 22px ui-monospace,monospace } .native { color:var(--accent); font-weight:700 }
button,input,textarea { color:inherit; font:inherit } button { background:#302f2b; border:1px solid #615b50; border-radius:4px; padding:7px 10px; cursor:pointer }
button:hover { border-color:var(--accent) } button.primary { background:var(--accent); color:#171611; border-color:var(--accent); font-weight:700 }
aside { background:var(--panel); border:1px solid #3a3a3a; padding:14px; border-radius:6px; align-self:start; position:sticky; top:16px }
h1,h2,p { margin:0 0 10px } h1 { font-size:18px } h2 { font-size:13px; color:var(--muted); text-transform:uppercase; letter-spacing:.08em }
textarea { width:100%; min-height:95px; padding:8px; background:#181818; border:1px solid #555; border-radius:4px }
pre { white-space:pre-wrap; overflow-wrap:anywhere; background:#181818; border:1px solid #444; padding:10px; min-height:100px; margin:0 }
.hint,.muted { color:var(--muted) } .warn { color:#f0cb73 } #status { margin-left:auto }
@media (max-width:760px) { main { grid-template-columns:1fr; max-width:430px; padding:10px } aside { position:static } }
</style>
<main>
  <section class="viewer">
    <div class="bar"><strong>Frame feedback</strong><span class="native">360×640 review proxy · exact 30fps frame clock</span></div>
    <div class="frame-wrap"><canvas id="canvas" width="360" height="640" aria-label="Review proxy frame"></canvas></div>
    <div class="bar"><span class="time" id="time">0:00.000 · frame 0</span><span id="status" class="muted">Loading review proxy…</span></div>
    <div class="controls">
      <button id="play">Play</button><button data-step="-10">−10f</button><button data-step="-1">−1f</button>
      <button data-step="1">+1f</button><button data-step="10">+10f</button><button id="download">Download PNG</button>
      <label>Frame <input id="frame-input" type="number" min="0" step="1" value="0" inputmode="numeric"></label><button id="go">Go</button>
    </div>
    <p class="hint">Keys: Space play/pause · ←/→ ±1 frame · Shift+←/→ ±10 frames. This smaller proxy maps every comment to the same exact frame in the accepted master.</p>
    <video id="video" src="__VIDEO__" playsinline preload="auto" hidden></video>
  </section>
  <aside>
    <h1>Frame feedback</h1><p class="hint">Stored only in this browser’s local storage. No uploads or network requests.</p>
    <h2>Comment at current frame</h2><textarea id="comment" placeholder="Describe the change needed at this exact frame."></textarea>
    <div class="note-row"><button class="primary" id="add">Add comment</button><button id="copy">Copy exact list</button><button id="clear">Clear</button></div>
    <h2>Export</h2><pre id="export">No comments yet.</pre>
  </aside>
</main>
<script>
(() => {
  'use strict';
  const FPS = 30;
  const video = document.getElementById('video');
  const canvas = document.getElementById('canvas');
  const ctx = canvas.getContext('2d', { alpha: false });
  const time = document.getElementById('time');
  const status = document.getElementById('status');
  const input = document.getElementById('frame-input');
  const storageKey = __STORAGE_KEY__;
  let requestedFrame = 0;
  let displayedFrame = null;
  let totalFrames = 0;
  let notes = JSON.parse(localStorage.getItem(storageKey) || '[]');
  let frameCallbackPending = false;
  let primingInitialFrame = false;

  const clamp = value => Math.max(0, Math.min(totalFrames, Math.round(value)));
  const formatTime = value => {
    const seconds = value / FPS, minutes = Math.floor(seconds / 60), remainder = seconds - minutes * 60;
    return `${minutes}:${remainder.toFixed(3).padStart(6, '0')}`;
  };
  const saveNotes = () => localStorage.setItem(storageKey, JSON.stringify(notes));
  const render = () => {
    if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) ctx.drawImage(video, 0, 0, 360, 640);
    time.textContent = displayedFrame === null
      ? `${formatTime(requestedFrame)} · target frame ${requestedFrame}`
      : `${formatTime(displayedFrame)} · displayed frame ${displayedFrame}`;
    input.value = requestedFrame;
    const rows = notes.map(note => `[${formatTime(note.frame)} · frame ${note.frame}] "${note.comment}"`);
    document.getElementById('export').textContent = rows.length ? rows.join('\n') : 'No comments yet.';
  };
  const seek = value => {
    requestedFrame = clamp(value); displayedFrame = null; video.pause();
    queueDecodedFramePaint();
    // A frame's centre, never its shared boundary, makes a CFR seek unambiguous.
    video.currentTime = (requestedFrame + 0.5) / FPS;
    status.textContent = `Seeking target frame ${requestedFrame}…`; render();
  };
  const frameFromMediaTime = mediaTime => clamp(Math.floor(mediaTime * FPS + 0.000001));
  const currentFrame = () => displayedFrame === null ? frameFromMediaTime(video.currentTime) : displayedFrame;
  const seekBy = amount => seek(currentFrame() + amount);
  const syncPlaybackFrame = () => {
    requestedFrame = frameFromMediaTime(video.currentTime); displayedFrame = null;
    status.textContent = `Target frame ${requestedFrame}; decoded-frame truth unavailable in this browser.`; render();
  };
  // `timeupdate` is commonly throttled to roughly 4Hz.  The decoded-frame
  // callback instead runs once for each frame the video element presents, so
  // the review canvas visibly follows playback rather than appearing to hold.
  const paintDecodedFrame = (_now, metadata) => {
    frameCallbackPending = false;
    displayedFrame = frameFromMediaTime(metadata.mediaTime);
    requestedFrame = displayedFrame;
    status.textContent = `Displayed frame ${displayedFrame} (${formatTime(displayedFrame)})`;
    render();
    if (primingInitialFrame) { video.pause(); video.muted = false; primingInitialFrame = false; return; }
    if (!video.paused && !video.ended) queueDecodedFramePaint();
  };
  const queueDecodedFramePaint = () => {
    if (typeof video.requestVideoFrameCallback === 'function' && !frameCallbackPending) {
      frameCallbackPending = true;
      video.requestVideoFrameCallback(paintDecodedFrame);
    }
  };
  const primeInitialFrame = async () => {
    try {
      video.muted = true; primingInitialFrame = true; await video.play();
    } catch (_error) {
      primingInitialFrame = false; video.muted = false;
      if (displayedFrame === null) status.textContent = 'Frame preview starts when playback begins.';
    }
  };

  video.addEventListener('loadedmetadata', () => {
    if (video.videoWidth !== 360 || video.videoHeight !== 640) {
      status.textContent = `Refusing ${video.videoWidth}×${video.videoHeight}: expected verified 360×640 proxy.`; status.className = 'warn'; return;
    }
    totalFrames = Math.max(0, Math.round(video.duration * FPS) - 1);
    input.max = totalFrames; status.textContent = `Bound to ${totalFrames + 1} verified CFR 30fps frames.`; seek(0); primeInitialFrame();
  });
  video.addEventListener('seeked', () => {
    if (typeof video.requestVideoFrameCallback !== 'function') {
      status.textContent = `Target frame ${requestedFrame}; decoded-frame truth unavailable in this browser.`; render();
    }
  });
  video.addEventListener('play', queueDecodedFramePaint);
  // Safe fallback for engines without requestVideoFrameCallback.
  video.addEventListener('timeupdate', () => {
    if (typeof video.requestVideoFrameCallback !== 'function') syncPlaybackFrame();
  });
  video.addEventListener('pause', render);
  document.querySelectorAll('[data-step]').forEach(button => button.addEventListener('click', () => seekBy(Number(button.dataset.step))));
  document.getElementById('go').addEventListener('click', () => seek(Number(input.value)));
  input.addEventListener('change', () => seek(Number(input.value)));
  document.getElementById('play').addEventListener('click', () => video.paused ? video.play() : video.pause());
  document.getElementById('download').addEventListener('click', () => canvas.toBlob(blob => {
    const frame = displayedFrame === null ? requestedFrame : displayedFrame;
    const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `frame-${String(frame).padStart(5, '0')}.png`; a.click(); URL.revokeObjectURL(a.href);
  }, 'image/png'));
  document.getElementById('add').addEventListener('click', () => {
    const field = document.getElementById('comment'), comment = field.value.trim(); if (!comment) return field.focus();
    const frame = displayedFrame === null ? requestedFrame : displayedFrame;
    notes.push({ frame, comment }); notes.sort((a, b) => a.frame - b.frame); field.value = ''; saveNotes(); render();
  });
  document.getElementById('clear').addEventListener('click', () => { if (notes.length && confirm('Clear all local comments?')) { notes = []; saveNotes(); render(); } });
  document.getElementById('copy').addEventListener('click', async () => {
    const text = document.getElementById('export').textContent; if (!notes.length) return;
    await navigator.clipboard.writeText(text); status.textContent = 'Exact feedback list copied.';
  });
  document.addEventListener('keydown', event => {
    if (event.target.matches('textarea,input')) return;
    if (event.key === ' ') { event.preventDefault(); video.paused ? video.play() : video.pause(); }
    if (event.key === 'ArrowLeft') { event.preventDefault(); seekBy(event.shiftKey ? -10 : -1); }
    if (event.key === 'ArrowRight') { event.preventDefault(); seekBy(event.shiftKey ? 10 : 1); }
  });
  canvas.width = 360; canvas.height = 640;
  render();
})();
</script>
'''


SERVER = '''#!/usr/bin/env python3
"""Serve this feedback artifact only to this Mac, on a loopback port."""
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import quote
import argparse
import webbrowser

class LoopbackOnlyHandler(SimpleHTTPRequestHandler):
    def parse_request(self):
        if not super().parse_request():
            return False
        host = self.headers.get("Host", "").rsplit(":", 1)[0].lower()
        if host not in ("127.0.0.1", "localhost"):
            self.send_error(421, "Misdirected Request")
            return False
        return True

artifact = Path(__file__).resolve().parent
root = artifact.parent
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--no-open", action="store_true")
parser.add_argument("--port", type=int, default=0, help="stable loopback port; 0 chooses a free port (default)")
args = parser.parse_args()
if not 0 <= args.port <= 65535:
    parser.error("--port must be between 0 and 65535")
handler = partial(LoopbackOnlyHandler, directory=str(root))
try:
    server = ThreadingHTTPServer(("127.0.0.1", args.port), handler)
except OSError as exc:
    parser.error(f"could not bind loopback port {args.port}: {exc}")
with server:
    url = f"http://127.0.0.1:{server.server_port}/{quote(artifact.name)}/index.html"
    print(f"Local-only proxy review: {url}", flush=True)
    if not args.no_open:
        webbrowser.open(url)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
'''

def _json_from(argv: list[str]) -> dict:
    result = subprocess.run(argv, capture_output=True, text=True, check=False)
    if result.returncode:
        raise ValueError(f"ffprobe failed: {result.stderr.strip() or result.returncode}")
    try:
        return json.loads(result.stdout)
    except json.JSONDecodeError as exc:
        raise ValueError("ffprobe returned invalid JSON") from exc


def probe_video(video: Path, ffprobe: str, *, width: int, height: int, label: str) -> dict:
    stream_data = _json_from([
        ffprobe, "-v", "error", "-show_streams", "-of", "json", str(video),
    ])
    streams = [stream for stream in stream_data.get("streams", []) if stream.get("codec_type") == "video"]
    if len(streams) != 1:
        raise ValueError(f"{label} must contain exactly one video stream")
    stream = streams[0]
    if (stream.get("width"), stream.get("height")) != (width, height):
        raise ValueError(f"{label} must be exact {width}x{height}")
    try:
        rates = {key: Fraction(str(stream[key])) for key in ("avg_frame_rate", "r_frame_rate")}
    except (KeyError, ValueError, ZeroDivisionError) as exc:
        raise ValueError("ffprobe did not provide valid frame-rate fields") from exc
    if rates != {"avg_frame_rate": Fraction(30), "r_frame_rate": Fraction(30)}:
        raise ValueError(f"{label} must be exact CFR 30fps (avg_frame_rate and r_frame_rate must both equal 30/1)")

    frame_data = _json_from([
        ffprobe, "-v", "error", "-select_streams", "v:0", "-show_frames",
        "-show_entries", "frame=best_effort_timestamp_time", "-of", "json", str(video),
    ])
    times = [float(frame["best_effort_timestamp_time"]) for frame in frame_data.get("frames", [])
             if "best_effort_timestamp_time" in frame]
    if len(times) < 2 or any(abs((right - left) - (1 / 30)) > 1e-6 for left, right in zip(times, times[1:])):
        raise ValueError(f"{label} must be exact CFR 30fps (decoded frame timestamps are not 1/30 second apart)")
    return {
        "cfr_30fps": True,
        "width": width,
        "height": height,
        "avg_frame_rate": stream["avg_frame_rate"],
        "r_frame_rate": stream["r_frame_rate"],
        "verified_frame_timestamp_count": len(times),
    }


def probe_master(master: Path, ffprobe: str) -> dict:
    return probe_video(master, ffprobe, width=1080, height=1920, label="master")


def encode_proxy(master: Path, output: Path, ffmpeg: str, ffprobe: str, master_probe: dict) -> tuple[Path, dict]:
    proxy = output / PROXY_NAME
    descriptor, temporary_name = tempfile.mkstemp(
        prefix=".review-proxy-", suffix=".mp4", dir=output,
    )
    os.close(descriptor)
    temporary = Path(temporary_name)
    try:
        result = subprocess.run([
            ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-i", str(master),
            "-map", "0:v:0", "-map", "0:a:0?",
            "-vf", f"scale={PROXY_WIDTH}:{PROXY_HEIGHT}:flags=lanczos",
            "-fps_mode", "cfr", "-r", "30",
            "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-pix_fmt", "yuv420p",
            "-c:a", "aac", "-b:a", "96k", "-movflags", "+faststart", str(temporary),
        ], capture_output=True, text=True, check=False)
        if result.returncode:
            raise ValueError(f"ffmpeg proxy encode failed: {result.stderr.strip() or result.returncode}")
        proxy_probe = probe_video(
            temporary, ffprobe, width=PROXY_WIDTH, height=PROXY_HEIGHT, label="review proxy",
        )
        if proxy_probe["verified_frame_timestamp_count"] != master_probe["verified_frame_timestamp_count"]:
            raise ValueError(
                "review proxy frame count does not match master "
                f"({proxy_probe['verified_frame_timestamp_count']} != "
                f"{master_probe['verified_frame_timestamp_count']})"
            )
        temporary.replace(proxy)
        return proxy, proxy_probe
    finally:
        temporary.unlink(missing_ok=True)


def build(master: Path, output: Path, *, ffprobe: str = "ffprobe", ffmpeg: str = "ffmpeg") -> Path:
    master = master.resolve()
    output = output.resolve()
    if not master.is_file():
        raise ValueError(f"master does not exist: {master}")
    if output.parent != master.parent:
        raise ValueError("artifact must be a sibling directory of its master so it binds ../<master>")
    if output.exists() and not output.is_dir():
        raise ValueError(f"artifact target is not a directory: {output}")
    probe = probe_master(master, ffprobe)
    output.mkdir(parents=True, exist_ok=True)
    proxy, proxy_probe = encode_proxy(master, output, ffmpeg, ffprobe, probe)
    master_sha256 = hashlib.sha256(master.read_bytes()).hexdigest()
    page = HTML.replace("__VIDEO__", PROXY_NAME).replace(
        "__STORAGE_KEY__", json.dumps(f"frame-feedback:{master_sha256}"),
    )
    (output / "index.html").write_text(page, encoding="utf-8")
    manifest = {
        "master": master.name,
        "master_sha256": master_sha256,
        "video_src": PROXY_NAME,
        "proxy_sha256": hashlib.sha256(proxy.read_bytes()).hexdigest(),
        "fps": 30,
        "resolution": [PROXY_WIDTH, PROXY_HEIGHT],
        "offline": True,
        "probe": probe,
        "proxy_probe": proxy_probe,
    }
    (output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
    (output / "serve.py").write_text(SERVER, encoding="utf-8")
    return output


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("master", type=Path)
    parser.add_argument("--out", type=Path, help="default: <master>.feedback beside master")
    parser.add_argument("--ffprobe", default="ffprobe", help="ffprobe executable (default: ffprobe)")
    parser.add_argument("--ffmpeg", default="ffmpeg", help="ffmpeg executable (default: ffmpeg)")
    args = parser.parse_args()
    output = args.out or args.master.with_name(args.master.name + ".feedback")
    try:
        artifact = build(args.master, output, ffprobe=args.ffprobe, ffmpeg=args.ffmpeg)
    except ValueError as exc:
        parser.error(str(exc))
    print(f"bound lightweight review proxy: {args.master.resolve()} -> {artifact / 'index.html'}")


if __name__ == "__main__":
    main()
