#!/usr/bin/env python3
"""frozentime — one command over the five-stage Frozen Time pipeline.

init -> mesh -> angles -> guide -> generate -> file, plus `status`.
Runbook (the authority on every step): FROZEN_TIME.md
Filing convention + card format: FROZEN_TIME.md §13

STATE ON DISK — one JSON per job, under jobs/<reel>/<job_id>/, so a crash or a new
session never loses place. `status` reads only that file — never a guess.

TWO GATES, NEVER BYPASSED — `mesh` and `generate` shell out to the real gated CLIs
(~/.local/bin/meshy, ~/.local/bin/higgsfield) with MESHY_GO / HF_GO left exactly as
the calling shell set them. This script NEVER sets, guesses, or hardcodes either
variable. If the gate blocks, we reprint its card verbatim and stop — the exact
command it wants is already in that card.

Delegates to bin/frozen_camera.py (owned by another agent) for `angles` and `guide`.
If that file isn't on disk yet, those stages fail loudly with a clear message
instead of silently faking a render.
"""
import argparse, base64, datetime, json, mimetypes, os, re, subprocess, sys, urllib.request
from pathlib import Path

# ── paths ──────────────────────────────────────────────────────────────────
HOME = Path.home()
ROOT = Path(os.environ.get("FROZEN_TIME_ROOT", HOME / ".claude/research/frozen_time"))
BIN_DIR = ROOT / "bin"
REELS_DIR = HOME / "Projects"      # the reel's own build folder
JOBS_DIR  = ROOT / "jobs"                            # legacy scratch, read-only fallback
FROZEN_CAMERA = BIN_DIR / "frozen_camera.py"
BLENDER_BIN = "/Applications/Blender.app/Contents/MacOS/Blender"

# ⛔ MESHY_BIN / HF_BIN must point at GATE WRAPPERS, not the raw vendor CLIs.
# The gate is what refuses to spend until a live quote exists and MESHY_GO / HF_GO match it.
# This script does NOT implement the gate itself — see FROZEN_TIME.md §10. Point these at the
# plain vendor binaries and every billable verb below runs UNGUARDED, with no quote shown.
MESHY_BIN = str(HOME / ".local/bin/meshy")
HF_BIN = str(HOME / ".local/bin/higgsfield")
MESHY_LEDGER = HOME / ".claude/meshy/ledger.jsonl"
HF_LEDGER = HOME / ".claude/higgsfield/ledger.jsonl"
MESHY_RATES = HOME / ".claude/meshy/rates.json"
HF_RATES = HOME / ".claude/higgsfield/rates.json"

PROJECTS_DIR = Path(os.environ.get("FROZEN_TIME_PROJECTS", HOME / "Projects"))
CARDS_DIR = Path(os.environ.get("FROZEN_TIME_CARDS", HOME / "Projects/cards"))

STAGES = ["init", "mesh", "angles", "guide", "generate", "file"]
AFTER_INIT = ["mesh", "angles", "guide", "generate", "file"]

# the proven beat sheet — FROZEN_TIME.md §6.6, job_example.json — frozen_camera.py's
# exact schema. beats[0]'s azimuth is a placeholder; `angles` auto-solves and overwrites
# it per-job (the match-frame azimuth is never eyeballed — FROZEN_TIME.md §6.2).
DEFAULT_BEATS = [
    {"name": "match", "fit": "match", "azimuth": 123.0, "elevation": 0.0, "radius_mult": 1.0,
     "aim_height_frac": 0.10, "hold_frames": 10, "whip_in_frames": 0},
    {"name": "profile_whip", "fit": "none", "azimuth": 0.0, "elevation": 26.0, "radius_mult": 0.579,
     "aim_height_frac": 0.22, "hold_frames": 22, "whip_in_frames": 26},
    {"name": "birdseye", "fit": "frustum", "azimuth": -165.0, "elevation": 82.0, "radius_mult": 0.901,
     "aim_height_frac": 0.0, "fit_target_height_frac": 0.72, "hold_frames": 60, "whip_in_frames": 26},
]

PROMPT_TEMPLATE = """Use the grey clay reference video <video1> as the only reference for the camera: its movement, path, speed, shot-size changes, framing and blocking. Follow it exactly, frame for frame. Do not change the shot structure. Do not add cuts. The clay video governs camera only — it does not govern colour, material or appearance.

Use the reference image <image1> for everything that is seen: the athlete, his clothing, and the field around him.

The scene is frozen in time — a bullet-time freeze-frame. 100% frozen physics, zero temporal movement anywhere in the environment. Infinite shutter. The man is arrested mid-action: {teaching}. This is the subject of the shot and stays exactly as it is.

The camera alone travels, at normal speed, through the stopped world — a rapid orbital fly-around of a stationary point in space, exactly as the reference video describes. Ground, fence, trees and sky parallax correctly with the camera.

His face does not need to match the reference image and may stay soft and loosely defined; body shape, pose, clothing and the field are what must hold.

Photoreal, matching the reference image's colour and grain.

Avoid: the subject moving, re-posing or continuing the action; grey clay or stone material on skin or fabric; the background sliding or dissolving; camera cuts; speed ramps; subtitles; logos; watermarks."""


# ── small utils ──────────────────────────────────────────────────────────────
def now_iso():
    return datetime.datetime.now(datetime.timezone.utc).isoformat()


def err(msg):
    sys.stderr.write(f"frozentime: {msg}\n")
    sys.exit(1)


def warn(msg):
    sys.stderr.write(f"frozentime: {msg}\n")


def run(binpath, argv, timeout=180):
    """Invoke a real CLI, inheriting the parent environment untouched — this is
    the ONLY way MESHY_GO / HF_GO ever reach the gate: the calling shell set them,
    we never do. Captured so we can parse + re-print + ledger it."""
    try:
        p = subprocess.run([binpath] + argv, capture_output=True, text=True, timeout=timeout)
        return p.returncode, p.stdout, p.stderr
    except FileNotFoundError:
        return 127, "", f"{binpath} not found"
    except subprocess.TimeoutExpired:
        return 124, "", f"{binpath} {' '.join(argv)} timed out after {timeout}s"


def run_meshy(argv, timeout=180):
    return run(MESHY_BIN, argv, timeout)


def run_higgsfield(argv, timeout=300):
    return run(HF_BIN, argv, timeout)


def download(url, dest):
    req = urllib.request.Request(url, headers={"User-Agent": "frozentime/1.0"})
    with urllib.request.urlopen(req, timeout=180) as r, open(dest, "wb") as f:
        import shutil as _sh
        _sh.copyfileobj(r, f)


def to_data_uri(path):
    """Meshy's image-to-3d has no upload endpoint — image_url must be a public
    URL or a data URI. We embed a data URI. IMPORTANT: the meshy guard logs the
    full request `data` dict into the SHARED ~/.claude/meshy/ledger.jsonl on every
    blocked/approved/ran attempt, uncompressed — verified during testing that a
    768x1376 PNG (2MB, ~2.76MB base64) bloats that shared ledger by ~2.76MB per
    attempt. Re-encode to JPEG q90 first (~620KB base64 for the same frame,  a
    4.4x cut) to keep that shared file's growth bounded. Falls back to the raw
    file if `sips` isn't available or the convert fails for any reason."""
    src = Path(path)
    tmp_jpg = src.with_suffix(".dataUri.jpg")
    try:
        r = subprocess.run(["sips", "-s", "format", "jpeg", "-s", "formatOptions", "90",
                             str(src), "--out", str(tmp_jpg)],
                            capture_output=True, text=True, timeout=30)
        if r.returncode == 0 and tmp_jpg.exists():
            data = base64.b64encode(tmp_jpg.read_bytes()).decode()
            return f"data:image/jpeg;base64,{data}"
    except Exception:
        pass
    finally:
        tmp_jpg.unlink(missing_ok=True)
    mime = mimetypes.guess_type(str(src))[0] or "image/png"
    data = base64.b64encode(src.read_bytes()).decode()
    return f"data:{mime};base64,{data}"


def image_dims(path):
    """macOS `sips` — no extra dependency."""
    try:
        out = subprocess.run(["sips", "-g", "pixelWidth", "-g", "pixelHeight", str(path)],
                              capture_output=True, text=True, timeout=20).stdout
        w = h = None
        for line in out.splitlines():
            line = line.strip()
            if line.startswith("pixelWidth:"):
                w = int(line.split(":")[1].strip())
            if line.startswith("pixelHeight:"):
                h = int(line.split(":")[1].strip())
        return w, h
    except Exception:
        return None, None


def parse_bbox(s):
    try:
        x0, y0, x1, y1 = [int(v.strip()) for v in s.split(",")]
        return [x0, y0, x1, y1]  # frozen_camera.py's exact schema: reference.bbox = [x0,y0,x1,y1]
    except Exception:
        err(f"--bbox must be 'x0,y0,x1,y1', got {s!r}")


def rates(path, default):
    d = dict(default)
    try:
        d.update(json.load(open(path)))
    except Exception:
        pass
    return d


def meshy_inr(credits):
    r = rates(MESHY_RATES, {"inr_per_usd": 88.0, "plan_usd_per_month": 20.0, "credits_per_month": 1000.0})
    per = (r["plan_usd_per_month"] * r["inr_per_usd"]) / r["credits_per_month"]
    return credits * per


def hf_inr(credits):
    r = rates(HF_RATES, {"inr_per_usd": 88.0, "plan_usd_per_month": 79.0, "credits_per_month": 3600.0})
    per = (r["plan_usd_per_month"] * r["inr_per_usd"]) / r["credits_per_month"]
    return credits * per


def read_ledger_cost(ledger_path, verb):
    """Read the paid tool's OWN ledger for the last 'ran' record matching this
    verb — we don't re-derive cost, the gate already measured it precisely."""
    if not ledger_path.exists():
        return None, None
    q = a = None
    for line in reversed(ledger_path.read_text().splitlines()):
        line = line.strip()
        if not line:
            continue
        try:
            r = json.loads(line)
        except Exception:
            continue
        if r.get("event") == "ran" and r.get("verb") == list(verb):
            return r.get("quoted_credits"), r.get("actual_credits")
    return q, a


# ── job state ──────────────────────────────────────────────────────────────
def blank_state(reel, job_id, frame_path, frame_dims, teaching, bbox):
    stages = {k: {"status": "pending", "at": None, "detail": {}} for k in STAGES}
    stages["init"] = {"status": "done", "at": now_iso(), "detail": {"frame": str(frame_path)}}
    return {
        "reel": reel, "job_id": job_id,
        "created_at": now_iso(), "updated_at": now_iso(),
        "frame_path": str(frame_path), "frame_dims": {"w": frame_dims[0], "h": frame_dims[1]},
        "teaching": teaching,
        "stages": stages,
        "meshy": {"task_id": None, "glb_path": None, "credits_quoted": None, "credits_actual": None},
        "camera": {"bbox": bbox, "lens_mm": 50.0},
        "beats": None,  # filled from DEFAULT_BEATS once mesh is done; beats[0].azimuth is auto-solved
        "higgsfield": {"image_uuid": None, "video_uuid": None, "job_id": None,
                        "credits_quoted": None, "credits_actual": None, "output_path": None,
                        "raw_create": None, "raw_wait": None},
        "costs": {"meshy_credits": 0, "meshy_inr": 0.0, "higgsfield_credits": 0, "higgsfield_inr": 0.0,
                   "total_inr": 0.0},
    }


def job_paths(reel, job_id):
    """Everything for a shot lives inside that reel's build folder:
       ~/Projects/<reel>_build/frozentime/<shot>/ .
    An old job under the scratch jobs/ tree still resolves, so nothing breaks."""
    d = REELS_DIR / f"{reel}_build" / "frozentime" / job_id
    legacy = JOBS_DIR / reel / job_id
    if not (d / "state.json").exists() and (legacy / "state.json").exists():
        d = legacy
    return d, d / "state.json", d / "ledger.jsonl", d / "work"


def save_state(job_dir, state):
    state["updated_at"] = now_iso()
    (job_dir / "state.json").write_text(json.dumps(state, indent=2))


def append_ledger(job_dir, rec):
    rec = dict(rec)
    rec["at"] = now_iso()
    with open(job_dir / "ledger.jsonl", "a") as f:
        f.write(json.dumps(rec) + "\n")
    (job_dir / "work").mkdir(parents=True, exist_ok=True)


def current_pointer_path():
    return JOBS_DIR / ".current.json"


def get_current():
    p = current_pointer_path()
    if p.exists():
        try:
            return json.loads(p.read_text())
        except Exception:
            return None
    return None


def set_current(reel, job_id):
    JOBS_DIR.mkdir(parents=True, exist_ok=True)
    current_pointer_path().write_text(json.dumps({"reel": reel, "job_id": job_id}))


def resolve_job(args, require_init=True):
    reel = getattr(args, "reel", None)
    job_id = getattr(args, "job_id", None)
    if not reel:
        cur = get_current()
        if not cur:
            err("no --reel given and no current job on disk — run `frozentime init --reel <id> ...` first, "
                "or pass --reel explicitly.")
        reel = cur["reel"]
        job_id = job_id or cur["job_id"]
    job_id = job_id or "default"
    job_dir, state_path, ledger_path, work_dir = job_paths(reel, job_id)
    if not state_path.exists():
        if require_init:
            err(f"no job at {job_dir} — run `frozentime init --reel {reel}"
                + (f" --job-id {job_id}" if job_id != "default" else "") + " --frame <path> --teaching \"...\"` first.")
        return reel, job_id, job_dir, None
    state = json.loads(state_path.read_text())
    set_current(reel, job_id)
    return reel, job_id, job_dir, state


def list_all_jobs():
    out = []
    if not JOBS_DIR.exists():
        return out
    for reel_dir in sorted(JOBS_DIR.iterdir()):
        if not reel_dir.is_dir():
            continue
        for job_dir in sorted(reel_dir.iterdir()):
            sp = job_dir / "state.json"
            if sp.exists():
                try:
                    out.append((reel_dir.name, job_dir.name, json.loads(sp.read_text())))
                except Exception:
                    pass
    return out


def next_hint(state):
    for name in AFTER_INIT:
        if state["stages"][name]["status"] != "done":
            return name
    return None


def recompute_costs(state):
    mq, ma = state["meshy"].get("credits_quoted"), state["meshy"].get("credits_actual")
    hq, ha = state["higgsfield"].get("credits_quoted"), state["higgsfield"].get("credits_actual")
    mc = ma if ma is not None else 0
    hc = ha if ha is not None else 0
    state["costs"] = {
        "meshy_credits": mc, "meshy_inr": round(meshy_inr(mc), 2),
        "higgsfield_credits": hc, "higgsfield_inr": round(hf_inr(hc), 2),
        "total_inr": round(meshy_inr(mc) + hf_inr(hc), 2),
    }


# ── printing ─────────────────────────────────────────────────────────────────
def one_line_summary(reel, job_id, state):
    nh = next_hint(state)
    recompute_costs(state)
    cost = state["costs"]["meshy_credits"] + state["costs"]["higgsfield_credits"]
    return f"{reel}/{job_id:<8} next={nh or 'DONE':<9} spent={cost:<6.0f}cr teaching={state['teaching'][:50]!r}"


def print_status(reel, job_id, state):
    recompute_costs(state)
    print(f"=== frozentime job {reel}/{job_id} ===")
    print(f"teaching : {state['teaching']}")
    print(f"frame    : {state['frame_path']} ({state['frame_dims']['w']}x{state['frame_dims']['h']})")
    bbox = state["camera"].get("bbox")
    print(f"bbox     : {bbox if bbox else 'NOT SET — angles/guide need it (re-run init --bbox x0,y0,x1,y1)'}")
    beats = state.get("beats")
    if beats:
        names = ", ".join(f"{b['name']}(az={b['azimuth']:.1f},el={b.get('elevation',0):.0f})" for b in beats)
        print(f"beats    : {names}")
    else:
        print("beats    : not set yet — frozentime angles")
    print("stages   :")
    for name in STAGES:
        s = state["stages"][name]
        tag = {"done": "DONE", "pending": "pending", "processing": "PROCESSING", "blocked": "BLOCKED"}.get(
            s["status"], s["status"])
        at = f" @ {s['at']}" if s.get("at") else ""
        print(f"  {name:<10} {tag}{at}")
    c = state["costs"]
    print(f"cost     : Meshy {c['meshy_credits']}cr · "
          f"Higgsfield {c['higgsfield_credits']}cr · total {c['meshy_credits'] + c['higgsfield_credits']}cr")
    nh = next_hint(state)
    print(f"NEXT     : {'nothing — filed' if nh is None else 'frozentime ' + nh}")


# ── stage: init ──────────────────────────────────────────────────────────────
def cmd_init(args):
    reel = args.reel
    if not reel:
        err("--reel is required")
    job_id = args.job_id or "default"
    job_dir, state_path, ledger_path, work_dir = job_paths(reel, job_id)

    if state_path.exists():
        state = json.loads(state_path.read_text())
        patched = False
        if args.bbox and not state["camera"].get("bbox"):
            state["camera"]["bbox"] = parse_bbox(args.bbox)
            patched = True
        if patched:
            save_state(job_dir, state)
            append_ledger(job_dir, {"stage": "init", "action": "patched_bbox", "bbox": state["camera"]["bbox"]})
            print(f"[init] job {reel}/{job_id} already existed — patched in the bbox you gave.")
        else:
            print(f"[init] job {reel}/{job_id} already exists — not overwriting. "
                  f"Use --job-id for a second shot on this reel.")
        set_current(reel, job_id)
        print_status(reel, job_id, state)
        return 0

    if not args.frame:
        err("--frame <path> is required for a new job")
    frame_src = Path(args.frame).expanduser()
    if not frame_src.exists():
        err(f"--frame does not exist: {frame_src}")
    if not args.teaching:
        err("--teaching \"<one line>\" is required — it decides where the camera lands")

    job_dir.mkdir(parents=True)
    work_dir.mkdir(parents=True)
    stored_frame = job_dir / f"frame{frame_src.suffix or '.png'}"
    stored_frame.write_bytes(frame_src.read_bytes())
    w, h = image_dims(stored_frame)
    bbox = parse_bbox(args.bbox) if args.bbox else None

    state = blank_state(reel, job_id, stored_frame, (w, h), args.teaching, bbox)
    save_state(job_dir, state)
    append_ledger(job_dir, {"stage": "init", "action": "created", "frame": str(stored_frame),
                             "teaching": args.teaching, "cost_credits": 0})
    set_current(reel, job_id)

    print(f"[init] job {reel}/{job_id} created. Frame stored at {stored_frame} ({w}x{h}).")
    if not bbox:
        warn("no --bbox given — measure the athlete's box (x0,y0,x1,y1) before `guide` runs; "
             "re-run init with --bbox to fill it in, or edit state.json's camera.bbox directly.")
    print_status(reel, job_id, state)
    return 0


# ── stage: mesh (GATE 1) ─────────────────────────────────────────────────────
def cmd_mesh(args):
    reel, job_id, job_dir, state = resolve_job(args)
    st = state["stages"]["mesh"]

    if st["status"] == "done" and not args.force:
        print(f"[mesh] already done — glb at {state['meshy']['glb_path']} "
              f"({state['meshy']['credits_actual']} credits). Pass --force to redo (spends again).")
        return 0

    task_id = state["meshy"].get("task_id")
    if task_id and not args.force:
        rc, out, sout_err = run_meshy(["status", "image-to-3d", task_id])
        if rc != 0:
            warn(f"status poll failed: {sout_err or out}")
            return rc
        try:
            obj = json.loads(out)
        except Exception:
            warn(f"could not parse status response: {out[:300]}")
            return 1
        status = obj.get("status")
        if status == "SUCCEEDED":
            glb_url = (obj.get("model_urls") or {}).get("glb")
            if not glb_url:
                err(f"SUCCEEDED but no glb in model_urls — raw: {out[:400]}")
            dest = job_dir / "work" / "mesh.glb"
            download(glb_url, dest)
            state["meshy"]["glb_path"] = str(dest)
            q, a = read_ledger_cost(MESHY_LEDGER, ("image-to-3d",))
            state["meshy"]["credits_actual"] = a
            if not state.get("beats"):
                state["beats"] = json.loads(json.dumps(DEFAULT_BEATS))
            recompute_costs(state)
            state["stages"]["mesh"] = {"status": "done", "at": now_iso(),
                                        "detail": {"task_id": task_id, "glb": str(dest)}}
            save_state(job_dir, state)
            append_ledger(job_dir, {"stage": "mesh", "action": "downloaded", "task_id": task_id,
                                     "glb": str(dest), "cost_credits": a})
            print(f"[mesh] done — glb at {dest} ({a} credits)")
            return 0
        else:
            state["stages"]["mesh"]["status"] = "processing"
            save_state(job_dir, state)
            print(f"[mesh] still {status} ({obj.get('progress', '?')}%) — re-run `frozentime mesh` to check again.")
            return 0

    # not yet created (or --force) — build the request and go through the real gate.
    # NOTE: a data-URI-embedded frame blows past macOS's argv limit (ARG_MAX) as a
    # `--data` string — hit this for real during verification (OSError: Argument list
    # too long). meshy's own CLI supports `--data-file <path>` for exactly this; use it.
    image_url = args.image_url or to_data_uri(state["frame_path"])
    data = {
        "image_url": image_url,
        "ai_model": args.ai_model,
        "ultra_mode": not args.no_ultra,
        "should_texture": not args.no_texture,
        "texture_resolution": args.texture_resolution,
        "pose_mode": args.pose_mode,  # empty by default — never force a/t-pose
    }
    data_file = job_dir / "work" / "mesh_request.json"
    data_file.write_text(json.dumps(data))
    rc, out, mesh_err = run_meshy(["image-to-3d", "--data-file", str(data_file)])
    if out:
        print(out)
    if mesh_err:
        sys.stderr.write(mesh_err + "\n")
    if rc != 0:
        append_ledger(job_dir, {"stage": "mesh", "action": "blocked", "data_summary":
                                 {k: v for k, v in data.items() if k != "image_url"}})
        warn("mesh BLOCKED — no MESHY_GO, or it didn't match the live quote shown above. "
             "Export the exact MESHY_GO the card printed, then re-run `frozentime mesh`. "
             "This script will never set that variable for you.")
        return rc

    try:
        obj = json.loads(out)
        tid = obj.get("result") or obj.get("task_id") or obj.get("id")
    except Exception:
        tid = None
    if not tid:
        err(f"mesh call succeeded but no task id could be parsed from: {out[:300]}")

    state["meshy"]["task_id"] = tid
    q, _ = read_ledger_cost(MESHY_LEDGER, ("image-to-3d",))
    state["meshy"]["credits_quoted"] = q
    state["stages"]["mesh"] = {"status": "processing", "at": now_iso(), "detail": {"task_id": tid}}
    save_state(job_dir, state)
    append_ledger(job_dir, {"stage": "mesh", "action": "created", "task_id": tid, "quoted_credits": q})
    print(f"[mesh] task created: {tid} (quoted {q} credits). Re-run `frozentime mesh` shortly to poll + fetch the GLB.")
    return 0


# ── stage: angles (free) ────────────────────────────────────────────────────
def run_frozen_camera(argv, timeout=1800):
    """Shell out to bin/frozen_camera.py under Blender's own Python — it owns
    bpy, our stdlib-only script can't import it. Real interface, confirmed by
    reading the file directly: subcommands render / contact-sheet /
    solve-azimuth, all taking --job <path-to-json>."""
    if not FROZEN_CAMERA.exists():
        return 127, "", (f"bin/frozen_camera.py not found at {FROZEN_CAMERA} — that build is owned by "
                          f"another agent and isn't on disk yet. This stage cannot run until it lands.")
    if not os.path.exists(BLENDER_BIN):
        return 127, "", f"Blender not found at {BLENDER_BIN}"
    cmd = [BLENDER_BIN, "-b", "-noaudio", "--python", str(FROZEN_CAMERA), "--"] + argv
    try:
        p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return p.returncode, p.stdout, p.stderr
    except subprocess.TimeoutExpired:
        return 124, "", f"Blender {' '.join(argv)} timed out after {timeout}s"


def build_camera_job(state, output=None, beats=None):
    """The exact job-file schema frozen_camera.py's load_job() expects."""
    job = {
        "mesh": state["meshy"]["glb_path"],
        "reference": {
            "image": state["frame_path"],
            "width": state["frame_dims"]["w"],
            "height": state["frame_dims"]["h"],
            "bbox": state["camera"]["bbox"],
        },
        "fps": 24,
        "resolution": [1080, 1920],
        "lens_mm": state["camera"]["lens_mm"],
        "decimate_ratio": 0.08,
    }
    if output is not None:
        job["output"] = output
    if beats is not None:
        job["beats"] = beats
    return job


def cmd_angles(args):
    reel, job_id, job_dir, state = resolve_job(args)

    # a bare pick just patches one beat's azimuth/elevation — cheap, no render
    if args.pick is not None:
        if not state.get("beats"):
            err("no beats yet — run `frozentime mesh` then `frozentime angles` first.")
        beat_name = args.beat or state["beats"][-1]["name"]
        for b in state["beats"]:
            if b["name"] == beat_name:
                b["azimuth"] = args.pick
                if args.elevation is not None:
                    b["elevation"] = args.elevation
                save_state(job_dir, state)
                append_ledger(job_dir, {"stage": "angles", "action": "picked", "beat": beat_name,
                                         "azimuth_deg": args.pick, "elevation_deg": args.elevation,
                                         "cost_credits": 0})
                print(f"[angles] beat {beat_name!r} → azimuth {args.pick}°"
                      + (f", elevation {args.elevation}°" if args.elevation is not None else ""))
                return 0
        err(f"no beat named {beat_name!r} — beats are: {[b['name'] for b in state['beats']]}")

    if state["stages"]["mesh"]["status"] != "done":
        err("mesh must be DONE before angles — run `frozentime mesh` first (it needs your MESHY_GO).")
    if not state["camera"].get("bbox"):
        err("no bbox on this job — re-run `frozentime init --reel " + reel + " --bbox x0,y0,x1,y1`.")

    if state["stages"]["angles"]["status"] == "done" and not args.force:
        detail = state["stages"]["angles"]["detail"]
        print(f"[angles] already done — match azimuth {state['beats'][0]['azimuth']:.1f}°, "
              f"contact sheet at {detail.get('contact_sheet')}")
        print("Adjust an end beat: frozentime angles --beat <name> --pick <deg> --elevation <deg>")
        return 0

    if not state.get("beats"):
        state["beats"] = json.loads(json.dumps(DEFAULT_BEATS))

    work = job_dir / "work" / "angles"
    work.mkdir(parents=True, exist_ok=True)
    job = build_camera_job(state)
    job_path = work / "job.json"
    job_path.write_text(json.dumps(job, indent=2))

    # 1) auto-solve the match-frame azimuth — never eyeballed (FROZEN_TIME.md §6.2)
    solve_args = ["solve-azimuth", "--job", str(job_path)]
    hint = args.hint if args.hint is not None else state["stages"]["angles"]["detail"].get("hint")
    if hint is not None:
        solve_args += ["--hint", str(hint)]
    rc, out, cam_err = run_frozen_camera(solve_args)
    if out:
        print(out)
    if rc != 0:
        warn(cam_err)
        append_ledger(job_dir, {"stage": "angles", "action": "solve_failed", "error": cam_err[:500], "cost_credits": 0})
        return rc
    m = re.search(r"AZIMUTH SOLVER RESULT: az = ([\-0-9.]+) deg", out)
    if not m:
        err("solve-azimuth ran but printed no result line — see its output above.")
    solved_az = float(m.group(1))
    if "no --hint given" in out and hint is None:
        warn("solve-azimuth found multiple candidate crossings and no --hint was given — it fell back to "
             "the sharpest bracket. Pass `frozentime angles --hint <rough eyeball guess>` to disambiguate.")

    # 2) contact sheet — the human pick for where the move should END
    sheet_out = work / "contact_sheet.png"
    cs_args = ["contact-sheet", "--job", str(job_path), "--azimuth-steps", str(args.count),
               "--elevations", args.elevations, "--out", str(sheet_out)]
    rc2, out2, cam_err2 = run_frozen_camera(cs_args)
    if out2:
        print(out2)
    if rc2 != 0:
        warn(cam_err2)
        append_ledger(job_dir, {"stage": "angles", "action": "sheet_failed", "error": cam_err2[:500], "cost_credits": 0})
        return rc2

    state["beats"][0]["azimuth"] = solved_az
    state["stages"]["angles"] = {"status": "done", "at": now_iso(),
                                  "detail": {"contact_sheet": str(sheet_out), "match_azimuth_deg": solved_az,
                                             "hint": hint}}
    save_state(job_dir, state)
    append_ledger(job_dir, {"stage": "angles", "action": "solved_and_rendered", "match_azimuth_deg": solved_az,
                             "contact_sheet": str(sheet_out), "cost_credits": 0})
    print(f"[angles] match azimuth auto-solved: {solved_az}°")
    print(f"[angles] contact sheet: {sheet_out}")
    others = [b["name"] for b in state["beats"][1:]]
    print(f"Pick where the move should end: frozentime angles --beat <name> --pick <deg> --elevation <deg>  "
          f"(beats: {others})")
    return 0


# ── stage: guide (free) ──────────────────────────────────────────────────────
def cmd_guide(args):
    reel, job_id, job_dir, state = resolve_job(args)

    if state["stages"]["guide"]["status"] == "done" and not args.force:
        print(f"[guide] already rendered: {state['stages']['guide']['detail'].get('guide_video')}")
        return 0
    if state["stages"]["angles"]["status"] != "done":
        err("angles must be DONE before guide — run `frozentime angles` first (it solves the match azimuth).")

    beats = state.get("beats")
    if args.beats:
        raw = args.beats
        if raw.startswith("@"):
            raw = Path(raw[1:]).expanduser().read_text()
        try:
            beats = json.loads(raw)
        except Exception as e:
            err(f"--beats could not be parsed as JSON (or @file): {e}")
    if not beats:
        err("no beats available — run `frozentime angles` first, or pass --beats explicitly.")

    out_path = job_dir / "work" / "guide.mp4"
    job = build_camera_job(state, output=str(out_path), beats=beats)
    if args.lens:
        job["lens_mm"] = args.lens
    if args.decimate:
        job["decimate_ratio"] = args.decimate
    job_path = job_dir / "work" / "camera_job_guide.json"
    job_path.write_text(json.dumps(job, indent=2))

    rc, out, cam_err = run_frozen_camera(["render", "--job", str(job_path)])
    if out:
        print(out)
    if rc != 0:
        warn(cam_err)
        append_ledger(job_dir, {"stage": "guide", "action": "failed", "error": cam_err[:800], "cost_credits": 0})
        return rc

    state["beats"] = beats
    state["stages"]["guide"] = {"status": "done", "at": now_iso(), "detail": {"guide_video": str(out_path)}}
    save_state(job_dir, state)
    append_ledger(job_dir, {"stage": "guide", "action": "rendered", "guide_video": str(out_path), "cost_credits": 0})
    print(f"[guide] grey camera move: {out_path}")
    return 0


# ── stage: generate (GATE 2) ─────────────────────────────────────────────────
def hf_upload(path):
    rc, out, u_err = run_higgsfield(["upload", "create", str(path), "--json"])
    if rc != 0:
        return None, (u_err or out)
    try:
        obj = json.loads(out)
        return (obj.get("id") or obj.get("result") or obj.get("uuid")), None
    except Exception:
        return None, f"could not parse upload response: {out[:300]}"


def cmd_generate(args):
    reel, job_id, job_dir, state = resolve_job(args)

    if state["stages"]["generate"]["status"] == "done" and not args.force:
        print(f"[generate] already done — output at {state['higgsfield']['output_path']} "
              f"({state['higgsfield']['credits_actual']} credits). Pass --force to redo (spends again).")
        return 0
    if state["stages"]["guide"]["status"] != "done":
        err("guide must be DONE before generate — it's the camera reference video.")

    hf = state["higgsfield"]

    if not hf.get("image_uuid"):
        uid, u_err = hf_upload(state["frame_path"])
        if not uid:
            err(f"reference-image upload failed: {u_err}")
        hf["image_uuid"] = uid
        save_state(job_dir, state)
        append_ledger(job_dir, {"stage": "generate", "action": "uploaded_image", "uuid": uid, "cost_credits": 0})

    if not hf.get("video_uuid"):
        guide_path = state["stages"]["guide"]["detail"]["guide_video"]
        vid, u_err = hf_upload(guide_path)
        if not vid:
            err(f"guide-video upload failed: {u_err}")
        hf["video_uuid"] = vid
        save_state(job_dir, state)
        append_ledger(job_dir, {"stage": "generate", "action": "uploaded_video", "uuid": vid, "cost_credits": 0})

    if not hf.get("job_id"):
        if args.prompt_file:
            prompt = Path(args.prompt_file).expanduser().read_text()
        else:
            prompt = PROMPT_TEMPLATE.format(teaching=state["teaching"])
        gen_args = ["generate", "create", args.model, "--prompt", prompt, "--mode", args.mode,
                    "--duration", str(args.duration), "--resolution", args.resolution,
                    "--aspect-ratio", args.aspect_ratio, "--image", hf["image_uuid"], "--video", hf["video_uuid"],
                    "--json"]
        rc, out, gen_err = run_higgsfield(gen_args)
        if out:
            print(out)
        if gen_err:
            sys.stderr.write(gen_err + "\n")
        if rc != 0:
            append_ledger(job_dir, {"stage": "generate", "action": "blocked", "model": args.model,
                                     "mode": args.mode, "duration": args.duration, "resolution": args.resolution})
            warn("generate BLOCKED — no HF_GO, or it didn't match the live quote shown above. "
                 "Export the exact HF_GO the card printed, then re-run `frozentime generate`. "
                 "This script will never set that variable for you.")
            return rc
        try:
            obj = json.loads(out)
            jid = obj.get("job_id") or obj.get("result") or obj.get("id")
        except Exception:
            obj, jid = None, None
        if not jid:
            hf["raw_create"] = out[:2000]
            save_state(job_dir, state)
            err(f"generate create ran but no job id could be parsed — raw output saved to state.json's "
                f"higgsfield.raw_create. Inspect and set higgsfield.job_id by hand, then re-run.")
        hf["job_id"] = jid
        q, _ = read_ledger_cost(HF_LEDGER, ("generate", "create"))
        hf["credits_quoted"] = q
        save_state(job_dir, state)
        append_ledger(job_dir, {"stage": "generate", "action": "created", "job_id": jid, "quoted_credits": q})
        print(f"[generate] job created: {jid} (quoted {q} credits).")

    rc, out, wait_err = run_higgsfield(["generate", "wait", hf["job_id"], "--json"])
    if out:
        print(out)
    if rc != 0:
        warn(wait_err or "still processing — re-run `frozentime generate` to check again.")
        return 0  # not fatal — resumable, no re-spend risk since job_id is already stored

    try:
        obj = json.loads(out)
        url = obj.get("video_url") or obj.get("output_url") or obj.get("url")
    except Exception:
        obj, url = None, None
    if not url:
        hf["raw_wait"] = out[:2000]
        save_state(job_dir, state)
        warn("generate wait returned no parsable output URL — raw saved to state.json's higgsfield.raw_wait. "
             "Inspect it and set higgsfield.output_path by hand if the asset did land.")
        return 0

    ext = Path(url.split("?")[0]).suffix or ".mp4"
    out_path = job_dir / "work" / f"generate_output{ext}"
    download(url, out_path)
    hf["output_path"] = str(out_path)
    q, a = read_ledger_cost(HF_LEDGER, ("generate", "create"))
    hf["credits_actual"] = a
    recompute_costs(state)
    state["stages"]["generate"] = {"status": "done", "at": now_iso(),
                                    "detail": {"job_id": hf["job_id"], "output": str(out_path)}}
    save_state(job_dir, state)
    append_ledger(job_dir, {"stage": "generate", "action": "done", "output": str(out_path), "cost_credits": a})
    print(f"[generate] done — {out_path} ({a} credits)")
    return 0


# ── stage: file (free) ───────────────────────────────────────────────────────
def next_version(dest_dir, stem_prefix):
    n = 1
    while True:
        candidates = list(dest_dir.glob(f"{stem_prefix}_v{n}.*"))
        if not candidates:
            return n
        n += 1


def build_card_block(reel, state):
    beats = state.get("beats") or []
    az = beats[0]["azimuth"] if beats else None
    detail = state["stages"]["file"]["detail"]
    return f"""## 🎥 Bullet-time beat — {state['teaching']}
*Built {datetime.date.today():%d %b %Y}. Build: `~/Projects/{reel}_build/bullettime/`*

| | |
|---|---|
| Source frame | `{detail['source_frame']}` |
| Mesh | Meshy 7, `pose_mode` empty, GLB → `{detail['mesh_glb']}` |
| Camera guide | `{detail['guide_video']}` — Blender, azimuth {az}° |
| Output | `{detail['output_video']}` — Seedance 2.5 `omni_reference`, 1080p 9:16 |
| Cost | {state['costs']['meshy_credits']}cr mesh + {state['costs']['higgsfield_credits']}cr video |

**Held / open:** — fill in after watching the shot; this line is not auto-judged.
"""


def cmd_file(args):
    reel, job_id, job_dir, state = resolve_job(args)

    if state["stages"]["file"]["status"] == "done" and not args.force:
        print(f"[file] already filed — {state['stages']['file']['detail']}")
        print(build_card_block(reel, state))
        return 0
    if state["stages"]["generate"]["status"] != "done":
        err("generate must be DONE before file.")

    dest_dir = PROJECTS_DIR / f"{reel}_build" / "bullettime"
    dest_dir.mkdir(parents=True, exist_ok=True)

    v = next_version(dest_dir, f"{reel}_bullettime")
    frame_ext = Path(state["frame_path"]).suffix or ".png"
    guide_src = Path(state["stages"]["guide"]["detail"]["guide_video"])
    out_src = Path(state["higgsfield"]["output_path"])
    mesh_src = Path(state["meshy"]["glb_path"])

    names = {
        "source_frame": f"{reel}_source_frame{frame_ext}",
        "mesh_glb": f"{reel}_mesh.glb",
        "guide_video": f"{reel}_clayguide_v{v}{guide_src.suffix}",
        "output_video": f"{reel}_bullettime_v{v}{out_src.suffix}",
    }
    for src, name_key in [(Path(state["frame_path"]), "source_frame"), (mesh_src, "mesh_glb"),
                           (guide_src, "guide_video"), (out_src, "output_video")]:
        dest = dest_dir / names[name_key]
        dest.write_bytes(src.read_bytes())

    state["stages"]["file"] = {"status": "done", "at": now_iso(),
                                "detail": {**names, "dest_dir": str(dest_dir)}}
    save_state(job_dir, state)
    append_ledger(job_dir, {"stage": "file", "action": "filed", "dest_dir": str(dest_dir), "cost_credits": 0})

    card = build_card_block(reel, state)
    (job_dir / "card_block.md").write_text(card)
    print(f"[file] copied into {dest_dir}")
    print()
    print(card)

    card_path = CARDS_DIR / f"{reel}.md"
    if args.update_card and card_path.exists():
        text = card_path.read_text()
        if "Bullet-time beat" not in text:
            card_path.write_text(text.rstrip() + "\n\n" + card)
            print(f"[file] appended the card block to {card_path} — the cards folder is a git repo: "
                  f"pull first, commit only this reel's card + this build's files, push at close.")
        else:
            print(f"[file] {card_path} already has a Bullet-time beat section — not touching it.")
    elif not args.update_card:
        print(f"[file] card block saved to {job_dir / 'card_block.md'}. "
              f"Pass --update-card to append it into {card_path} (never auto-committed/pushed).")
    return 0


# ── status ───────────────────────────────────────────────────────────────────
def cmd_status(args):
    if args.all:
        jobs = list_all_jobs()
        if not jobs:
            print("No frozentime jobs on disk yet.")
            return 0
        for reel, jid, state in jobs:
            print(one_line_summary(reel, jid, state))
        return 0
    reel, job_id, job_dir, state = resolve_job(args)
    print_status(reel, job_id, state)
    return 0


# ── argparse ─────────────────────────────────────────────────────────────────
def add_common(p):
    p.add_argument("--reel", help="reel id, e.g. R117 (default: the last job touched)")
    p.add_argument("--job-id", help="only needed for a second shot on the same reel (default: 'default')")


def main():
    ap = argparse.ArgumentParser(prog="frozentime", description=__doc__.splitlines()[0])
    sub = ap.add_subparsers(dest="cmd", required=True)

    p = sub.add_parser("init", help="start a job")
    add_common(p)
    p.add_argument("--frame", help="path to the source frame")
    p.add_argument("--teaching", help="the one-line coaching point — decides where the camera lands")
    p.add_argument("--bbox", help="athlete's pixel bbox 'x0,y0,x1,y1' (needed by guide)")
    p.set_defaults(func=cmd_init)

    p = sub.add_parser("mesh", help="GATE 1 — Meshy image-to-3D")
    add_common(p)
    p.add_argument("--image-url", help="public URL instead of embedding the frame as a data URI")
    p.add_argument("--ai-model", default="meshy-7")
    p.add_argument("--no-ultra", action="store_true")
    p.add_argument("--no-texture", action="store_true")
    p.add_argument("--texture-resolution", default="2k")
    p.add_argument("--pose-mode", default="", help="leave empty — forcing a/t-pose destroys the shot")
    p.add_argument("--force", action="store_true")
    p.set_defaults(func=cmd_mesh)

    p = sub.add_parser("angles", help="free — auto-solve the match azimuth + contact sheet, or record a pick")
    add_common(p)
    p.add_argument("--count", type=int, default=12, help="azimuth steps around the contact sheet (default 12)")
    p.add_argument("--elevations", default="0", help="comma-separated elevations for the sheet, e.g. 0,30,60")
    p.add_argument("--hint", type=float, default=None,
                   help="rough eyeball azimuth guess — disambiguates solve-azimuth when the silhouette's "
                        "aspect ratio matches at more than one angle")
    p.add_argument("--pick", type=float, help="record an END-beat azimuth he picked off the contact sheet")
    p.add_argument("--beat", help="which beat --pick applies to (default: the last beat, i.e. the reveal)")
    p.add_argument("--elevation", type=float, help="elevation to go with --pick")
    p.add_argument("--force", action="store_true")
    p.set_defaults(func=cmd_angles)

    p = sub.add_parser("guide", help="free — render the grey camera move locally")
    add_common(p)
    p.add_argument("--beats", help="override JSON array in frozen_camera.py's beat schema, or @path/to/beats.json "
                                    "(default: this job's beats — the proven R117 sheet + any angles picks)")
    p.add_argument("--lens", type=float, default=None)
    p.add_argument("--decimate", type=float, default=0.08)
    p.add_argument("--force", action="store_true")
    p.set_defaults(func=cmd_guide)

    p = sub.add_parser("generate", help="GATE 2 — Higgsfield Seedance")
    add_common(p)
    p.add_argument("--model", default="seedance_2_5")
    p.add_argument("--mode", default="omni_reference")
    p.add_argument("--duration", type=int, default=6)
    p.add_argument("--resolution", default="1080p")
    p.add_argument("--aspect-ratio", default="9:16")
    p.add_argument("--prompt-file", help="override the built-in prompt template with a file")
    p.add_argument("--force", action="store_true")
    p.set_defaults(func=cmd_generate)

    p = sub.add_parser("file", help="free — copy into the reel build folder, print the card block")
    add_common(p)
    p.add_argument("--update-card", action="store_true", help="also append the block into the shot card")
    p.add_argument("--force", action="store_true")
    p.set_defaults(func=cmd_file)

    p = sub.add_parser("status", help="where the job is, what is next, what it has cost")
    add_common(p)
    p.add_argument("--all", action="store_true", help="list every job on disk")
    p.set_defaults(func=cmd_status)

    args = ap.parse_args()
    sys.exit(args.func(args) or 0)


if __name__ == "__main__":
    main()
