r"""
Build a boat-cam MP4 per boat per **real** race from the actual captured stills in production GCS.

Unlike the synthetic GSYS series, the two real 2026-05-24 races (`Race 1` 09:00Z, `Day2` 09:30Z) have
genuine boat-camera photos in the V2 photos bucket (`{project}-viewregatta-photos`, keyed
`{deviceId}/YYYYMMDDTHHMMSSZ_{front|back}.jpg`). This:

  1. discovers the real races (V2 Firestore, via discover_2324) — or reads a manifest you pass;
  2. resolves each boat's real device at the gun (deviceMounts, time-scoped — the boatEntry.deviceId
     is null in V2);
  3. lists + downloads that device's stills inside the race window (bounded by filename offset);
  4. normalises each JPEG (EXIF auto-rotate, letterbox to 1280x720, slim timestamp caption) and
     encodes the sequence at **1 fps** — one still per frame, chronological.

Output is FLAT: every file in one directory, named `<date>_<raceLabel>_sail<N>_<cam>.mp4`.
2 races x 4 boats = 8 videos (front camera by default; `--camera both` doubles to 16).

  gcloud auth application-default login          # ADC for Firestore + GCS
  ..\..\..\.venv-demo\Scripts\python.exe export\gen_boatcam_real.py
  ..\..\..\.venv-demo\Scripts\python.exe export\gen_boatcam_real.py --camera both --limit 1   # quick test
"""
from __future__ import annotations

import argparse
import subprocess
import sys
from datetime import UTC, datetime, timedelta
from io import BytesIO
from pathlib import Path

from PIL import Image, ImageDraw, ImageFont, ImageOps

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent.parent.parent / "migration"))   # v2_reader, discover_2324

DEFAULT_OUT = HERE.parent / "data" / "boatcam"
W, H = 1280, 720
FPS = 1
VENUE_TZ_OFFSET_H = 2            # CEST
PROJECT = "apps-viewregatta"
BUCKET = f"{PROJECT}-viewregatta-photos"

# Some boats mounted the phone rotated 90°, and the stills were saved landscape with NO EXIF
# orientation tag — so exif_transpose can't fix them and the footage bakes in "lying down". Rotate
# the source upright before letterboxing. Keyed (deviceId, camera). sail5 (e95e24a14fa12cc4) is the
# only such device in the 2026-05-24 races: its front cam has sky on the left (needs 90° CW), its
# back cam has sky on the right (needs 90° CCW). Add a row here if another device turns up rotated.
ROTATE = {
    ("e95e24a14fa12cc4", "front"): Image.Transpose.ROTATE_270,   # 90° clockwise
    ("e95e24a14fa12cc4", "back"): Image.Transpose.ROTATE_90,     # 90° counter-clockwise
}


def _font(size: int) -> ImageFont.FreeTypeFont:
    for base in (Path("C:/Windows/Fonts"), Path("/usr/share/fonts")):
        p = base / "arial.ttf"
        if p.exists():
            return ImageFont.truetype(str(p), size)
    return ImageFont.load_default(size=size)


def _ts_str(ms: int) -> str:
    return datetime.fromtimestamp(ms / 1000, tz=UTC).strftime("%Y%m%dT%H%M%SZ")


def _parse_ts(blob_name: str) -> int | None:
    """`{device}/YYYYMMDDTHHMMSSZ_{cam}.jpg` -> epoch ms (or None if unparseable)."""
    stem = blob_name.rsplit("/", 1)[-1].split("_")[0]
    try:
        t = datetime.strptime(stem, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
    except ValueError:
        return None
    return int(t.timestamp() * 1000)


def _mount_at(mounts: list[dict], t_ms: int) -> dict | None:
    """The deviceMount covering `t_ms` — latest-activated, narrowest window wins (apply_2324 rule)."""
    cov = [m for m in mounts if int(m.get("fromUtcMs") or 0) <= t_ms
           and (m.get("toUtcMs") is None or t_ms <= int(m["toUtcMs"]))]
    if not cov:
        return None
    return max(cov, key=lambda m: (int(m.get("fromUtcMs") or 0),
                                   -int(m["toUtcMs"]) if m.get("toUtcMs") else float("-inf")))


def _race_label(name: str) -> str:
    """`'Race 1'` -> `race1`, `'Day2'` -> `day2` — a filesystem-friendly slug."""
    return "".join(c for c in (name or "race").lower() if c.isalnum()) or "race"


def list_window_blobs(bucket, device_id: str, lo_ms: int, hi_ms: int, camera: str):
    """Device stills with a timestamp in `[lo_ms, hi_ms]`, filtered to `camera` ('front'|'back'),
    sorted by time. Listing is bounded by name offset (names sort by timestamp) so we don't scan the
    device's whole history."""
    start_off = f"{device_id}/{_ts_str(lo_ms)}"
    end_off = f"{device_id}/{_ts_str(hi_ms + 1000)}~"        # '~' > 'Z'/'_' so the upper bound is inclusive
    out = []
    for bl in bucket.list_blobs(prefix=f"{device_id}/", start_offset=start_off, end_offset=end_off):
        if not bl.name.endswith(".jpg") or f"_{camera}" not in bl.name:
            continue
        t = _parse_ts(bl.name)
        if t is None or t < lo_ms or t > hi_ms:
            continue
        out.append((t, bl))
    out.sort(key=lambda x: x[0])
    return out


def _frame_from_jpeg(data: bytes, *, caption: str, font, rotate=None) -> Image.Image:
    """Decode one still, EXIF-auto-rotate, apply a fixed `rotate` (for devices saved sideways with no
    EXIF tag), letterbox to WxH on black, draw a slim caption."""
    src = ImageOps.exif_transpose(Image.open(BytesIO(data))).convert("RGB")
    if rotate is not None:
        src = src.transpose(rotate)
    fitted = ImageOps.contain(src, (W, H))
    canvas = Image.new("RGB", (W, H), (0, 0, 0))
    canvas.paste(fitted, ((W - fitted.width) // 2, (H - fitted.height) // 2))
    d = ImageDraw.Draw(canvas, "RGBA")
    tw = d.textlength(caption, font=font)
    d.rectangle((0, H - 40, tw + 32, H), fill=(0, 0, 0, 140))
    d.text((16, H - 20), caption, font=font, fill=(235, 235, 240), anchor="lm")
    return canvas


def encode_video(out_path: Path, blobs, *, sail: str, camera: str, font, rotate=None) -> int:
    """Download each still and stream it (rawvideo) into ffmpeg -> 1 fps H.264. Returns frame count."""
    out_path.parent.mkdir(parents=True, exist_ok=True)
    cmd = [
        "ffmpeg", "-y", "-loglevel", "error",
        "-f", "rawvideo", "-pixel_format", "rgb24", "-video_size", f"{W}x{H}",
        "-framerate", str(FPS), "-i", "-",
        "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "20", "-preset", "veryfast",
        "-movflags", "+faststart", str(out_path),
    ]
    proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
    n = 0
    try:
        for t_ms, bl in blobs:
            local = datetime.fromtimestamp(t_ms / 1000, tz=UTC) + timedelta(hours=VENUE_TZ_OFFSET_H)
            cap = f"#{sail} {camera}  {local:%Y-%m-%d %H:%M:%S} CEST"
            frame = _frame_from_jpeg(bl.download_as_bytes(), caption=cap, font=font, rotate=rotate)
            proc.stdin.write(frame.tobytes())
            n += 1
        proc.stdin.close()
    except BrokenPipeError:
        pass
    err = proc.stderr.read().decode("utf-8", "replace")
    if proc.wait() != 0:
        raise RuntimeError(f"ffmpeg failed for {out_path.name}:\n{err}")
    return n


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--out", default=str(DEFAULT_OUT), help="flat output directory")
    ap.add_argument("--camera", choices=("front", "back", "both"), default="front")
    ap.add_argument("--pad-s", type=int, default=300, help="window padding before gun / after stop")
    ap.add_argument("--manifest", default=None, help="discovered real manifest (else discover live)")
    ap.add_argument("--sails", default=None, help="comma list of sail numbers to (re)render, e.g. 5")
    ap.add_argument("--limit", type=int, default=None, help="cap number of boats (quick test)")
    args = ap.parse_args()

    import json

    from google.cloud import storage
    from v2_reader import device_mounts_for_boat, open_firestore

    if args.manifest:
        manifest = json.loads(Path(args.manifest).read_text(encoding="utf-8"))
    else:
        from discover_2324 import discover
        print("discovering real May 23/24 races from V2 Firestore ...")
        manifest = discover()
    client = open_firestore(PROJECT)
    bucket = storage.Client(project=PROJECT).bucket(BUCKET)
    font = _font(24)
    out_dir = Path(args.out)
    out_dir.mkdir(parents=True, exist_ok=True)

    cams = ("front", "back") if args.camera == "both" else (args.camera,)
    sail_filter = {s.strip() for s in args.sails.split(",")} if args.sails else None
    jobs = []
    for r in manifest["races"]:
        if not r["startGroups"]:
            continue
        g = r["startGroups"][0]
        gun, stop = int(g["officialStartUtcMs"]), int(g["officialStopUtcMs"])
        lo, hi = gun - args.pad_s * 1000, stop + args.pad_s * 1000
        date = datetime.fromtimestamp(gun / 1000, tz=UTC).strftime("%Y-%m-%d")
        label = _race_label(r["name"])
        for t in g["teams"]:
            for b in t["boats"]:
                if sail_filter is not None and str(b["sailId"]) not in sail_filter:
                    continue
                m = _mount_at(device_mounts_for_boat(client, b["boatId"]), gun)
                dev = (m or {}).get("deviceId")
                if not dev:
                    print(f"  SKIP sail {b['sailId']} ({label}): no device mount at gun")
                    continue
                for cam in cams:
                    jobs.append((date, label, str(b["sailId"]), dev, lo, hi, cam))

    if args.limit is not None:
        jobs = jobs[: args.limit]

    total = 0
    for date, label, sail, dev, lo, hi, cam in jobs:
        blobs = list_window_blobs(bucket, dev, lo, hi, cam)
        name = f"{date}_{label}_sail{sail}_{cam}.mp4"
        if not blobs:
            print(f"  {name}: no {cam} stills in window — skipped")
            continue
        rotate = ROTATE.get((dev, cam))
        n = encode_video(out_dir / name, blobs, sail=sail, camera=cam, font=font, rotate=rotate)
        total += 1
        secs = n  # 1 fps -> n frames = n seconds
        print(f"  {name}: {n} stills -> {secs}s")
    print(f"wrote {total} real boat-cam videos (flat) under {out_dir}")
    return 0 if total else 1


if __name__ == "__main__":
    sys.exit(main())
