r"""
Build the GSYS broadcast manifest from REAL V2 tracks (replaces the synthetic gen_gsys_series).

For each curated race (CURATED — the auto-detected starts the operator kept), per the rebuild plan:
  • resolve each boat → its device + mount for that day (deviceMounts / _mount_at; handles redeploys)
  • pull the real 1 Hz tracks over [gun − 5 min, gun + ~30 min]
  • fit the 4 marks from the fleet's roundings, ≥2 m off every track (fit_marks)
  • wind DIRECTION = bearing(start-gate-mid → Mark 1) — first leg is upwind; SPEED/gusts from Open-Meteo
  • score with the shared engine (enrich), set the window to [gun − 5 min, last-finish + 2 min]
  • title = "YYYY-MM-DD HH:MM" (venue-local CEST start)

Writes a self-contained data/races/manifest.json (one inlined race per entry) that apply_gsys_manifest
and record_demo consume unchanged.

  ..\..\..\.venv-demo\Scripts\python.exe export\gen_gsys_real.py            # all curated races
  ..\..\..\.venv-demo\Scripts\python.exe export\gen_gsys_real.py --only 0  # just the first (demo) race
"""
from __future__ import annotations

import argparse
import json
import math
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path

_HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(_HERE))
sys.path.insert(0, str(_HERE.parents[2] / "migration"))
sys.path.insert(0, str(_HERE.parents[2] / "scoring"))
from course_fit import fit_course  # noqa: E402
from detect_gsys_races import CENTER, _mount_at, sail_to_boat  # noqa: E402
from fit_marks import min_clearance_m  # noqa: E402
from gen_gsys_series import _hdg, archive_wind, director_params, manifest_entry  # noqa: E402
from navmarks_gsys import NAV_MARKS_GSYS  # noqa: E402
from score_race import enrich  # noqa: E402
from segment_gsys import to_xy  # noqa: E402
from v2_reader import (  # noqa: E402
    course as v2_course,
)
from v2_reader import (  # noqa: E402
    device_mounts_for_boat,
    open_firestore,
    stream_points_for_track,
    tracks_for_device_in_window,
)

# The real laid course — all GSYS race instances reference this one "digital 8" doc; its marks are the
# authoritative positions (the same ones real.json + gen_gsys_series use). We read it instead of fitting
# marks from the boats' tracks (12+ min of pre-start jockeying makes the raw tracks too noisy to fit).
COURSE_ID = "JR8eifPuEyy5AweO1JQO"
_COURSE_CACHE: dict | None = None

DATA = _HERE.parent / "data" / "races"
MEDIA_DIR = _HERE.parent / "data" / "media"
PHOTOS_BUCKET = "apps-viewregatta-viewregatta-photos"
VENUE_TZ_H = 2                                  # CEST
PRE_S = 240                                     # show starts 4 min before the gun (4 min is enough)
FETCH_BEFORE_S = 9 * 60                          # fetch this much before the ESTIMATED gun (so the
FETCH_AFTER_S = 18 * 60                          # refined gun + 4-min pre-roll is covered) ... and after
# Marks are fitted from the RACE LAPS only — [gun + RACE_FIT_LO, gun + RACE_FIT_HI] — not the pre-start.
RACE_FIT_LO, RACE_FIT_HI = 30, 13 * 60
# Auto-fit sanity gate: a real figure-8 flight's winner finishes well after the gun, so a "winner" that
# finishes sooner than this (race-seconds from track start = gun − PRE_S) is a bogus rounding, not a race.
MIN_SENSIBLE_SEC = PRE_S + 6 * 60
# A few windows run into the NEXT race (the boats finish, mill ~10 min, then restart) and crossing-gap
# size can't separate that from a long mid-race outer loop. Where it happens, cap the export at this UTC
# HH:MM (same date) so only the first race is kept — keyed by CURATED index. gsys03's race A ends
# 15:37:49 CEST and race B starts ~15:48, so 13:40 UTC (15:40 CEST) sits safely in the gap between them.
RACE_END_CAP = {2: "13:40"}

# Team allocation (from the demo template; left as-is per the user — refine later). sail → team.
TEAMS_CFG = {
    "rh": {"name": "Red Hats", "nameLang": "en", "color": "#C8102E", "sails": ["2", "4"]},
    "bs": {"name": "Blue Socks", "nameLang": "en", "color": "#5BB8E8", "sails": ["5", "6"]},
}
SAIL_TEAM = {s: tid for tid, t in TEAMS_CFG.items() for s in t["sails"]}

# Curated races: (date, gun HH:MM UTC). May 22 uses the real V2 course (JR8eifPuEyy5AweO1JQO) and scores
# exactly (demo == real.json). May 23/24 marks were RE-LAID and V2 has no per-day course, so those days
# are AUTO-FIT: course_fit transforms the known figure-8 onto each day's pooled roundings (course_for_day
# below). The auto-fit isn't engine-scorable for every race; non-scoring ones are dropped at build time.
# These are the detector's 4-boat starts (UTC guns; the build resolves the full roster by telemetry).
CURATED = [
    ("2026-05-22", "13:08"),    # the demo race (validated against real.json)
    ("2026-05-22", "13:21"),
    ("2026-05-22", "13:37"),
    ("2026-05-23", "08:17"),
    ("2026-05-23", "16:14"),
    ("2026-05-24", "08:46"),
    ("2026-05-24", "09:34"),
    ("2026-05-24", "11:11"),
    ("2026-05-24", "12:47"),
    ("2026-05-24", "13:32"),
]


def _gun_ms(date: str, hhmm: str) -> int:
    return int(datetime.strptime(f"{date} {hhmm}", "%Y-%m-%d %H:%M").replace(tzinfo=UTC).timestamp() * 1000)


def _title(gun_ms: int) -> str:
    local = datetime.fromtimestamp(gun_ms / 1000, tz=UTC) + timedelta(hours=VENUE_TZ_H)
    return local.strftime("%Y-%m-%d %H:%M")


def _ll(g: dict | None) -> dict | None:
    return {"lat": g["lat"], "lon": g["lon"]} if g and g.get("lat") is not None else None


def course_block(client) -> dict:  # noqa: ANN001
    """The feed `course` block from the real V2 'digital 8' course doc (cached). Authoritative marks."""
    global _COURSE_CACHE
    if _COURSE_CACHE is None:
        c = v2_course(client, COURSE_ID)
        if not c:
            raise SystemExit(f"V2 course {COURSE_ID} not found")
        sl, fl = c.get("startLine") or {}, (c.get("finishLine") or c.get("startLine") or {})
        wps = [{"name": w.get("name") or "", "type": w.get("type") or "rounding",
                "rounding": w.get("rounding"), "lat": w["lat"], "lon": w["lon"]}
               for w in (c.get("waypoints") or []) if w.get("lat") is not None]
        pts = [p for p in (_ll(sl.get("port")), _ll(sl.get("starboard")),
                           *[{"lat": w["lat"], "lon": w["lon"]} for w in wps]) if p]
        center = {"lat": sum(p["lat"] for p in pts) / len(pts), "lon": sum(p["lon"] for p in pts) / len(pts)}
        _COURSE_CACHE = {"name": c.get("name") or "digital 8", "center": center,
                         "startLine": {"port": _ll(sl.get("port")), "starboard": _ll(sl.get("starboard"))},
                         "finishLine": {"port": _ll(fl.get("port")), "starboard": _ll(fl.get("starboard"))},
                         "waypoints": wps}
    return _COURSE_CACHE


def fetch_points(client, dev: str, t0: int, t1: int) -> list[dict]:  # noqa: ANN001
    """Real 1 Hz points (last fix per second) for `dev` over [t0, t1): {t, lat, lon, speed, course,
    roll, pitch, azimuth}. Tolerant of the Firestore client's transient retry crash."""
    by_sec: dict[int, dict] = {}
    try:
        for tr in tracks_for_device_in_window(client, dev, from_ms=t0, to_ms=t1):
            for p in stream_points_for_track(client, tr["trackId"], from_ms=t0, to_ms=t1):
                if p.get("lat") is None:
                    continue
                t = int(p.get("gnssTimestamp") or p.get("t") or 0)
                by_sec[t // 1000] = {
                    "t": (t // 1000) * 1000, "lat": p["lat"], "lon": p.get("lon"),
                    "speed": p.get("speed") or 0.0, "course": p.get("course"),
                }
    except Exception as e:  # noqa: BLE001
        print(f"      (fetch error for {dev[:8]}: {type(e).__name__})")
    return [by_sec[s] for s in sorted(by_sec)]


def refine_gun(course: dict, pts: dict, est_gun: int) -> int:
    """Gun = the first boat's bow crossing the start line to the COURSE side, minus 1 s (the official
    start is often early; this anchors it so the first boat starts 1 s after the gun). Pre-start dips
    (cross then return) and OCS boats (cross early, come back for the penalty) are skipped by requiring
    the boat to STAY on the course side for ≥ COMMIT_MS. Falls back to `est_gun` if no clean start."""
    from score_race import segments_intersect
    sl = course["startLine"]
    sp = to_xy(sl["port"]["lat"], sl["port"]["lon"], CENTER)
    ss = to_xy(sl["starboard"]["lat"], sl["starboard"]["lon"], CENTER)
    smid = ((sp[0] + ss[0]) / 2, (sp[1] + ss[1]) / 2)
    m1 = to_xy(course["waypoints"][0]["lat"], course["waypoints"][0]["lon"], CENTER)
    upw = (m1[0] - smid[0], m1[1] - smid[1])
    n = math.hypot(*upw) or 1.0
    upw = (upw[0] / n, upw[1] / n)
    commit_ms, candidates = 60_000, []
    for plist in pts.values():
        xy = [(to_xy(p["lat"], p["lon"], CENTER), p["t"]) for p in plist]
        cr = []
        for i in range(1, len(xy)):
            (a0, _t0), (a1, t1) = xy[i - 1], xy[i]
            if segments_intersect(a0, a1, sp, ss):
                cr.append((t1, (a1[0] - a0[0]) * upw[0] + (a1[1] - a0[1]) * upw[1] > 0))
        for t, to_course in cr:
            if to_course and not any(tt for tt, tc in cr if not tc and t < tt <= t + commit_ms):
                candidates.append(t)
    return (min(candidates) - 1000) if candidates else est_gun


def _fname_to_ms(name: str) -> int | None:
    """Parse the UTC time from a boat-cam blob name `<dev>/YYYYMMDDTHHMMSSZ[_cam].ext`."""
    try:
        stem = name.rsplit("/", 1)[-1].split("_")[0].split(".")[0]
        return int(datetime.strptime(stem, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC).timestamp() * 1000)
    except Exception:  # noqa: BLE001
        return None


def download_media(devices, t0: int, t1: int) -> dict:  # noqa: ANN001
    """List + download each device's boat-cam photos/videos in [t0, t1] from GCS to data/media/<dev>/,
    returning {deviceId: [{tSec, file, video}]} (tSec from t0). Best-effort — missing bucket/blob is
    logged and skipped (the tiles just fall back to graphics)."""
    try:
        from google.cloud import storage
        bucket = storage.Client(project="apps-viewregatta").bucket(PHOTOS_BUCKET)
    except Exception as e:  # noqa: BLE001
        print(f"      media: storage unavailable ({e}); skipping photos")
        return {}
    days, d0 = [], datetime.fromtimestamp(t0 / 1000, tz=UTC).date()
    d1 = datetime.fromtimestamp(t1 / 1000, tz=UTC).date()
    while d0 <= d1:
        days.append(d0.strftime("%Y%m%d"))
        d0 += timedelta(days=1)
    out, total = {}, 0
    for dev in devices:
        lst = []
        try:
            blobs = [b for day in days for b in bucket.list_blobs(prefix=f"{dev}/{day}")]
        except Exception as e:  # noqa: BLE001
            print(f"      media list failed {dev[:8]}: {e}")
            continue
        for blob in blobs:
            ts = _fname_to_ms(blob.name)
            if ts is None or ts < t0 or ts > t1:
                continue
            base = blob.name.rsplit("/", 1)[-1]
            rel = f"{dev}/{base}"
            local = MEDIA_DIR.joinpath(*rel.split("/"))
            if not local.exists():
                local.parent.mkdir(parents=True, exist_ok=True)
                try:
                    blob.download_to_filename(str(local))
                except Exception:  # noqa: BLE001
                    continue
            lst.append({"tSec": round((ts - t0) / 1000, 3), "file": rel,
                        "video": base.lower().endswith(".mp4")})
            total += 1
        if lst:
            lst.sort(key=lambda m: m["tSec"])
            out[dev] = lst
    print(f"      media: {total} photos/videos across {len(out)} boats")
    return out


def _resolve(client, sails: list[str], gun_ms: int):  # noqa: ANN001
    """sail → (deviceId, deviceFrontDeg, mountType) for this race's gun (handles the day-2 redeploy)."""
    s2b = sail_to_boat(client)
    out = {}
    for sail in sails:
        bid = s2b.get(sail)
        m = _mount_at(device_mounts_for_boat(client, bid), gun_ms) if bid else None
        if not m or not m.get("deviceId"):
            print(f"      sail {sail}: no device mount at gun — skipped")
            continue
        out[sail] = (m["deviceId"], float(m.get("deviceFrontDeg") or 0.0),
                     m.get("mountType") or "Mast")
    return out


def fetch_race(client, est_gun: int):  # noqa: ANN001
    """Resolve the race's boats (all team boats at the gun) + pull their tracks over the fetch window.
    Returns (resolved, pts) or None if <3 boats have telemetry."""
    f0, f1 = est_gun - FETCH_BEFORE_S * 1000, est_gun + FETCH_AFTER_S * 1000
    resolved = _resolve(client, list(SAIL_TEAM), est_gun)
    if len(resolved) < 3:
        return None
    pts = {sail: fetch_points(client, dev, f0, f1) for sail, (dev, _d, _m) in resolved.items()}
    pts = {s: p for s, p in pts.items() if len(p) > 60}
    return (resolved, pts) if len(pts) >= 3 else None


def race_lap_tracks(pts: dict, est_gun: int) -> list:
    """Per-boat lat/lon over the RACE LAPS only (for fitting the day's marks; excludes the pre-start)."""
    lo, hi = est_gun + RACE_FIT_LO * 1000, est_gun + RACE_FIT_HI * 1000
    return [[(p["lat"], p["lon"]) for p in plist if lo <= p["t"] <= hi] for plist in pts.values()]


def assemble_race(rid: str, est_gun: int, resolved: dict, pts: dict, course: dict,
                  *, do_refine: bool = True, autofit: bool = False, cap_ms: int | None = None):
    f1 = est_gun + FETCH_AFTER_S * 1000
    # Refine the gun to the first boat's start-line crossing − 1 s (the official start runs early), then
    # window the show as [gun − 4 min, …]. Only when the start line is PRECISE (the real V2 course); an
    # auto-fit course's start line isn't accurate enough, so those keep the detector's estimate.
    gun_ms = refine_gun(course, pts, est_gun) if do_refine else est_gun
    t0 = gun_ms - PRE_S * 1000

    # wind: direction = bearing(start-gate-mid → Mark 1) (first leg upwind); speed/gust from Open-Meteo
    sl = course["startLine"]
    smid = to_xy((sl["port"]["lat"] + sl["starboard"]["lat"]) / 2,
                 (sl["port"]["lon"] + sl["starboard"]["lon"]) / 2, CENTER)
    m1 = to_xy(course["waypoints"][0]["lat"], course["waypoints"][0]["lon"], CENTER)
    wind_from = round(_hdg(smid, m1), 1)
    wind_kts, gust = archive_wind(gun_ms)

    # teams (demo allocation), carrying each phone's mount geometry (how the device sat on the boat —
    # kept in the feed as a fact about the capture; no renderer surface reads it any more)
    teams = []
    for tid, cfg in TEAMS_CFG.items():
        boats = [{"deviceId": resolved[s][0], "sailId": s, "visualId": f'{cfg["name"]} {s}',
                  "boatId": None, "color": cfg["color"], "sternM": 0,
                  "deviceFrontDeg": resolved[s][1], "mountType": resolved[s][2]}
                 for s in cfg["sails"] if s in pts]
        if boats:
            teams.append({"teamId": tid, "name": cfg["name"], "nameLang": cfg["nameLang"], "boats": boats})

    # frames keyed from t0 (gun lands at race-second PRE_S); drop the pre-window pre-start
    frames: dict[str, dict] = {}
    for sail, plist in pts.items():
        dev = resolved[sail][0]
        for p in plist:
            if p["t"] < t0:
                continue
            sec = (p["t"] - t0) // 1000
            frames.setdefault(str(sec), {})[dev] = {
                "lat": round(p["lat"], 7), "lon": round(p["lon"], 7),
                "speed": round(p["speed"], 2), "course": round(p["course"] or 0.0, 1)}

    if cap_ms is not None:                                    # window runs into the next race — keep race 1
        frames = {s: v for s, v in frames.items() if t0 + int(s) * 1000 <= cap_ms}

    media = download_media({resolved[s][0] for s in pts}, t0, f1)

    feed = {
        "schema": "vr-broadcast-demo/1", "source": "real",
        "meta": {"title": _title(gun_ms), "user": "GSYS", "instanceId": None, "groupId": None,
                 "openEnded": False, "raceType": "team_race_2",
                 "trackStartUtcMs": t0, "gunUtcMs": gun_ms, "trackEndUtcMs": f1,
                 "frameStepMs": 1000, "windFromDeg": wind_from, "windKts": wind_kts,
                 # auto-fit races have their marks/finish line FITTED from the tracks, so the result is
                 # provisional → declare a PRELIMINARY winner (reviewer note). Real-V2-course races
                 # (May-22) have authoritative marks + a real finish line, so they stay definitive.
                 **({"preliminaryResult": True} if autofit else {}),
                 **({"windGustKts": gust} if gust is not None else {}),
                 "venue": {"name": "Saltsjön", "navMarks": NAV_MARKS_GSYS}},
        "course": course, "teams": teams, "frames": frames, "video": [], "media": media,
        "authoredOverrides": False,         # the demo's hand-authored lines aren't for these races
    }
    if autofit:
        # The reconstructed course is approximate, so a fixed rounding tolerance scores some races and
        # not others. Scan from tight to loose and take the SMALLEST tolerance that yields a REALISTIC
        # finish (>= MIN_SENSIBLE_SEC after track start) — a loose radius otherwise credits bogus
        # roundings (a boat "finishing" seconds after the gun). None sensible ⇒ drop the race.
        scored = None
        for cap in (50, 90, 150, 220, 300):
            feed["meta"]["roundCaptureM"] = float(cap)
            feed["meta"]["roundDirectM"] = float(min(cap * 0.3, 30))
            s = enrich(feed)
            r = s.get("result") or {}
            if r.get("winnerName") and (r.get("winnerSec") or 0) >= MIN_SENSIBLE_SEC:
                scored = s
                break
        if scored is None:
            print(f"  {rid}: {_title(gun_ms)} — auto-fit found no realistic result; dropped")
            return None
    else:
        # V2-course (May-22) race: the start/finish LINE is authoritative, so any detected finish is a
        # real line crossing and the winner is trustworthy. Default tolerance scores gsys01/02; a race
        # whose ROUNDING marks were re-laid mid-day (gsys03) needs a looser rounding radius — scan up
        # from the default and take the first (tightest) that scores.
        scored = enrich(feed)
        if not (scored.get("result") or {}).get("winnerName"):
            for cap in (90, 150, 220):
                feed["meta"]["roundCaptureM"] = float(cap)
                feed["meta"]["roundDirectM"] = float(min(cap * 0.3, 30))
                s = enrich(feed)
                if (s.get("result") or {}).get("winnerName"):
                    scored = s
                    break
    # window ends 2 min after the LAST boat finishes (decision 6); fall back to the fetch end
    res = scored.get("result") or {}
    end_sec = res.get("maxFinishSec")
    if end_sec is not None:
        scored["meta"]["trackEndUtcMs"] = t0 + int((end_sec + 120) * 1000)
    fit_lo, fit_hi = gun_ms + RACE_FIT_LO * 1000, gun_ms + RACE_FIT_HI * 1000
    tracks_fit = [[(p["lat"], p["lon"]) for p in pts[s] if fit_lo <= p["t"] <= fit_hi] for s in pts]
    nmedia = sum(len(v) for v in media.values())
    print(f"  {rid}: {_title(gun_ms)} CEST  gun={round((gun_ms-est_gun)/1000):+d}s  boats={sorted(pts)}  "
          f"wind={wind_from}°/{wind_kts}kn  clear={min_clearance_m(course, tracks_fit, CENTER):.1f}m  "
          f"winner={res.get('winnerName')}  frames={len(frames)}  photos={nmedia}")
    return manifest_entry(rid, scored)


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--only", type=int, default=None, help="build just CURATED[i] (e.g. 0 = demo race)")
    ap.add_argument("--out", default=str(DATA / "manifest.json"))
    args = ap.parse_args()
    client = open_firestore("apps-viewregatta")
    picks = [CURATED[args.only]] if args.only is not None else CURATED
    print(f"fetching {len(picks)} real-track race(s) ...")

    # 1. fetch every race's tracks.
    fetched: dict[str, tuple] = {}      # rid -> (date, est_gun, resolved, pts)
    for i, (date, hhmm) in enumerate(picks):
        rid = f"gsys{(args.only if args.only is not None else i) + 1:02d}"
        est_gun = _gun_ms(date, hhmm)
        fr = fetch_race(client, est_gun)
        if fr is None:
            print(f"  {rid} {date} {hhmm}: <3 boats with telemetry — skipping")
            continue
        fetched[rid] = (date, est_gun, fr[0], fr[1])

    # 2. one course per day: May 22 = the real V2 course; other days = auto-fit (transform the V2
    #    figure-8 onto that day's pooled roundings, since the marks were re-laid).
    v2 = course_block(client)
    day_course: dict[str, dict | None] = {}
    for date in sorted({d for d, _e, _r, _p in fetched.values()}):
        if date == "2026-05-22":
            day_course[date] = v2
        else:
            pooled = [t for (d, eg, _r, pts) in fetched.values() if d == date
                      for t in race_lap_tracks(pts, eg)]
            day_course[date] = fit_course(v2, pooled, CENTER)
            print(f"  {date}: auto-fit course {'OK' if day_course[date] else 'FAILED (no clusters)'}")

    # 3. assemble each race against its day course; auto-fit races that don't SCORE are dropped.
    print("building ...")
    races = []
    for rid, (date, est_gun, resolved, pts) in fetched.items():
        course = day_course.get(date)
        if course is None:
            continue
        is_may22 = date == "2026-05-22"
        cap = RACE_END_CAP.get(int(rid[4:]) - 1)              # cap a window that runs into the next race
        cap_ms = _gun_ms(date, cap) if cap else None
        entry = assemble_race(rid, est_gun, resolved, pts, course,
                              do_refine=is_may22, autofit=not is_may22, cap_ms=cap_ms)
        if entry is None:
            continue
        if date != "2026-05-22" and not entry.get("winnerName"):
            print(f"  {rid} {entry['title']}: auto-fit course didn't score — dropped")
            continue
        races.append(entry)

    races.sort(key=lambda r: r["gunUtcMs"])
    if races:
        out = Path(args.out)
        out.write_text(json.dumps({"races": races, "params": director_params()},
                                  ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
        print(f"\nwrote {len(races)} races → {out}  ({out.stat().st_size/1e6:.1f} MB)")
    return 0 if races else 1


if __name__ == "__main__":
    sys.exit(main())
