r"""
Generate the GSYS 2v2 team-racing mini-series of 2026-05-23/24 — a two-day cup on
the same figure-of-eight ("digital 8") course used by the broadcast demo's real
Race 1. We don't have the recorded tracks for these flights (they live only in
production Firestore), so this SYNTHESISES plausible flights ON THE DEMO'S REAL
COURSE: the marks, the two gates and the start/finish line are lifted verbatim from
data/races/real.json (the exported "digital 8"); only the boat motion is generated.

Format: four 2-boat teams sail a full round-robin each day (6 flights/day = 12
flights), guns 15 min apart. Each flight is scored by the shared engine (figure-8
detection, 2v2 "trailing boat decides", commentary) exactly like the real export.

Output is a SINGLE self-contained data/races/manifest.json: every race's full feed
is inlined on its manifest entry (`data`), the 12 GSYS flights ONLY, ordered by gun
time (May 23 then May 24) — no per-race files and no old May-22 demo race. The render
page/boatcam read the inline `data` when present (else fetch an external `feed`), so
the whole demo is one file. Re-running drops any stale per-race gsysNN.json.

  ..\..\..\.venv-demo\Scripts\python.exe export\gen_gsys_series.py
"""
from __future__ import annotations

import argparse
import json
import math
import sys
from datetime import UTC, datetime
from pathlib import Path

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parents[2] / "scoring"))              # the shared engine
sys.path.insert(0, str(HERE.parents[2] / "ingest"))               # wind_archive (Open-Meteo ERA5)
from navmarks_gsys import NAV_MARKS_GSYS  # noqa: E402
from score_race import enrich  # noqa: E402

DATA = HERE.parent / "data" / "races"

# ---------------------------------------------------------------------------
# Course geometry — the REAL "digital 8" from data/races/real.json (the demo's
# exported Race 1). Two gates: a WEST gate (wp0/wp1, reused as wp4/wp5) and the
# start/finish line on the EAST. The waypoint order 0->1->2->3->4->5 makes the
# route cross itself => the engine classifies it "figure8". Lifted verbatim so the
# placed marks/gates are exactly where GSYS set them on the day.
# ---------------------------------------------------------------------------
CENTER = {"lat": 59.32119602870078, "lon": 18.107211561645112}
START_LINE = {"port": {"lat": 59.32149166043108, "lon": 18.108257372141484},
              "starboard": {"lat": 59.32108252671146, "lon": 18.108418122128164}}
FINISH_LINE = {"port": {"lat": 59.32148994978409, "lon": 18.1082600543505},
               "starboard": {"lat": 59.321082184577946, "lon": 18.108418792680418}}
WAYPOINTS = [
    {"name": "", "rounding": "port", "lat": 59.3209298246853, "lon": 18.105435130246537},
    {"name": "", "rounding": "port", "lat": 59.32119057768516, "lon": 18.105607089641662},
    {"name": "", "rounding": "port", "lat": 59.32108218457797, "lon": 18.108418122128164},
    {"name": "", "rounding": "port", "lat": 59.321489607654605, "lon": 18.10825871324599},
    {"name": "", "rounding": "port", "lat": 59.320929824685315, "lon": 18.105435130246537},
    {"name": "", "rounding": "port", "lat": 59.321191946214896, "lon": 18.105607089641662},
]

REF_LAT, REF_LON = CENTER["lat"], CENTER["lon"]
M_PER_DEG_LAT = 111_320.0
M_PER_DEG_LON = 111_320.0 * math.cos(math.radians(REF_LAT))


def to_xy(lat, lon):
    return ((lon - REF_LON) * M_PER_DEG_LON, (lat - REF_LAT) * M_PER_DEG_LAT)   # (east, north)


def to_latlon(x, y):
    return (REF_LAT + y / M_PER_DEG_LAT, REF_LON + x / M_PER_DEG_LON)


def _mid(line):
    return ((line["port"]["lat"] + line["starboard"]["lat"]) / 2.0,
            (line["port"]["lon"] + line["starboard"]["lon"]) / 2.0)


# The sailed route (start-line mid -> the six marks -> a touch past the finish line) and everything
# derived from it — wind direction, beat legs, tacking — is built PER DAY by the Course class below,
# because the two days may use slightly different mark positions (see DAY_WIND_SHIFT_DEG).


def _hdg(a, b):
    return (math.degrees(math.atan2(b[0] - a[0], b[1] - a[1])) + 360.0) % 360.0


def route_length(route):
    return sum(math.dist(route[i - 1], route[i]) for i in range(1, len(route)))


# --- tunables shared by every day's course ---
BEAT_CONE_DEG = 55.0          # within this of dead-upwind a leg must be tacked, not sailed straight
MARK_RAMP_M = 7.0             # the lateral offset eases to 0 over this distance at each interior mark
TACK_AMP_M = 16.0            # half-width of the tacking zig-zag on a beat (metres)
TACK_PERIOD_M = 80.0         # made-good distance per tack cycle
RAMP_S = 6.0                 # gentle acceleration off the line (no dead-stop at the gun)


def _tri(x):
    """Triangle wave in [-1, 1], period 1."""
    s = x - math.floor(x)
    return 4.0 * abs(s - 0.5) - 1.0


def _veer(p, pivot, deg):
    """Move XY point p as if the wind VEERED (its FROM-bearing increased) by `deg`: rotate the
    vector pivot->p so it gains `deg` of compass bearing. Used to re-lay the windward gate for a
    day whose wind had shifted from the exported layout."""
    e, n = p[0] - pivot[0], p[1] - pivot[1]
    c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg))
    return (pivot[0] + e * c + n * s, pivot[1] - e * s + n * c)


class Course:
    """One day's digital-8 layout. The base marks are the exported 'digital 8' (our only real sample,
    from May 22). We have no recorded course for May 23/24, so we assume the marks were stable WITHIN
    a day and that BETWEEN days the committee re-laid the WINDWARD (west) gate to follow the wind:
    `shift_deg` swings those two west marks about the fixed, leeward start-line mid. The start/finish
    line and the leeward end stay put. Everything downstream — the derived wind direction (still the
    start->first-mark bearing), which legs are beats, and the tacking — follows from this geometry."""

    WINDWARD_MARKS = (0, 1, 4, 5)        # the two west gate marks (wp4/wp5 reuse wp0/wp1)

    def __init__(self, shift_deg):
        self.shift_deg = shift_deg
        self.smid = to_xy(*_mid(START_LINE))                       # leeward pivot (committee/start end)
        self.wp_xy = [(_veer(to_xy(w["lat"], w["lon"]), self.smid, shift_deg)
                       if i in self.WINDWARD_MARKS else to_xy(w["lat"], w["lon"]))
                      for i, w in enumerate(WAYPOINTS)]
        self.wp_ll = [to_latlon(*p) for p in self.wp_xy]
        fmid = to_xy(*_mid(FINISH_LINE))                           # cross a touch past the finish line
        _in = (fmid[0] - self.wp_xy[-1][0], fmid[1] - self.wp_xy[-1][1])
        _inl = math.hypot(*_in) or 1.0
        fin_through = (fmid[0] + 10.0 * _in[0] / _inl, fmid[1] + 10.0 * _in[1] / _inl)
        self.route = [self.smid, *self.wp_xy, fin_through]
        self.route_len = route_length(self.route)
        self.wind_from = round(_hdg(self.smid, self.wp_xy[0]), 1)  # start -> first mark = the beat
        self.legs, cum = [], 0.0
        for i in range(1, len(self.route)):
            a, b = self.route[i - 1], self.route[i]
            ln = math.dist(a, b) or 1e-9
            u = ((b[0] - a[0]) / ln, (b[1] - a[1]) / ln)
            brg = _hdg(a, b)
            off = abs((brg - self.wind_from + 180.0) % 360.0 - 180.0)
            self.legs.append({"i": i - 1, "a": a, "u": u, "n": (-u[1], u[0]), "len": ln,
                              "cum": cum, "brg": brg, "beat": off < BEAT_CONE_DEG})
            cum += ln
        self.n_legs = len(self.legs)
        _to_m1 = (self.wp_xy[0][0] - self.smid[0], self.wp_xy[0][1] - self.smid[1])
        _tl = math.hypot(*_to_m1) or 1.0
        self.pre_dir = (-_to_m1[0] / _tl, -_to_m1[1] / _tl)        # leeward of the line (pre-start side)
        _lp = to_xy(START_LINE["port"]["lat"], START_LINE["port"]["lon"])
        _ls = to_xy(START_LINE["starboard"]["lat"], START_LINE["starboard"]["lon"])
        _lx = (_ls[0] - _lp[0], _ls[1] - _lp[1])
        _ll = math.hypot(*_lx) or 1.0
        self.line_dir = (_lx[0] / _ll, _lx[1] / _ll)

    def made_good(self, t, finish_s):
        """Made-good distance along the rhumb route at boat-time t (s since the boat started): near-
        constant VMG with a short ramp off the line, arriving at route_len at finish_s (no stalls)."""
        if t <= 0.0:
            return 0.0
        vmg = self.route_len / max(1e-6, finish_s - RAMP_S / 2.0)
        d = vmg * (t * t / (2.0 * RAMP_S)) if t < RAMP_S else vmg * (t - RAMP_S / 2.0)
        return min(d, self.route_len)

    def sail_xy(self, d_mg, idx, phase):
        """Boat position (metres) at made-good distance d_mg: tacking up the beats, a small per-boat
        lane elsewhere, the offset eased to 0 at each interior mark (clean roundings, no leg-join
        jump) but kept across the start/finish line so the fleet stays fanned out there."""
        d_mg = max(0.0, min(d_mg, self.route_len - 1e-3))
        leg = self.legs[-1]
        for lg in self.legs:
            if d_mg <= lg["cum"] + lg["len"]:
                leg = lg
                break
        s = d_mg - leg["cum"]
        bx, by = leg["a"][0] + leg["u"][0] * s, leg["a"][1] + leg["u"][1] * s
        lat = (idx - 1.5) * 4.0
        if leg["beat"]:
            lat += TACK_AMP_M * _tri(s / TACK_PERIOD_M + phase * 0.5)
        env = 1.0
        if leg["i"] > 0:
            env = min(env, s / MARK_RAMP_M)
        if leg["i"] < self.n_legs - 1:
            env = min(env, (leg["len"] - s) / MARK_RAMP_M)
        lat *= max(0.0, min(1.0, env))
        return (bx + leg["n"][0] * lat, by + leg["n"][1] * lat)

    def prestart_xy(self, idx, t, phase):
        """Jockeying to leeward before the gun, anchored on this boat's gun position so it eases
        exactly onto the line as the gun fires, with a little side-to-side so it's visibly working."""
        base = self.sail_xy(0.0, idx, phase)
        away = 1.0 - min(1.0, max(0.0, 1.0 + t / PRE_S))
        back = 52.0 * away
        jitter = 8.0 * away * math.sin(t / 7.0 + idx)
        return (base[0] + self.pre_dir[0] * back + self.line_dir[0] * jitter,
                base[1] + self.pre_dir[1] * back + self.line_dir[1] * jitter)

    def feed_course(self):
        """The feed's course block, with this day's mark positions in the figure-8 order."""
        wps = [{"name": w["name"], "type": "rounding", "rounding": w["rounding"],
                "lat": round(self.wp_ll[i][0], 7), "lon": round(self.wp_ll[i][1], 7)}
               for i, w in enumerate(WAYPOINTS)]
        pts = ([(START_LINE["port"]["lat"], START_LINE["port"]["lon"]),
                (START_LINE["starboard"]["lat"], START_LINE["starboard"]["lon"]),
                (FINISH_LINE["port"]["lat"], FINISH_LINE["port"]["lon"]),
                (FINISH_LINE["starboard"]["lat"], FINISH_LINE["starboard"]["lon"])] + self.wp_ll)
        center = {"lat": sum(p[0] for p in pts) / len(pts), "lon": sum(p[1] for p in pts) / len(pts)}
        return {"name": "digital 8", "center": center,
                "startLine": START_LINE, "finishLine": FINISH_LINE, "waypoints": wps}


# Day-to-day course assumption (we have no recorded course for May 23/24, only the exported May-22
# layout). Within a day the marks are treated as fixed; between days the committee re-laid the
# windward gate for the wind. May 23 keeps the exported layout (shift 0); May 24 swings the windward
# gate by a plausible overnight veer. Zero it (or set the real value) if the actual wind is known.
DAY_WIND_SHIFT_DEG = {"23": 0.0, "24": 16.0}
_COURSE = {}


def course_for_day(day):
    key = str(day)
    if key not in _COURSE:
        _COURSE[key] = Course(DAY_WIND_SHIFT_DEG[key])
    return _COURSE[key]


# ---------------------------------------------------------------------------
# Fleet — four teams, 2 boats each. Red Hats / Blue Socks carry over from the demo
# (same names, colours and sail numbers); Green Caps / Yellow Gloves complete the
# round-robin. Names are race data (teams[].name), not commentary — the engine
# refers to boats via Boat()/Team() tokens, so this hardcodes no names in commentary.
# ---------------------------------------------------------------------------
TEAMS = {
    "rh": {"teamId": "rh", "name": "Red Hats", "nameLang": "en", "color": "#C8102E",
           "boats": [{"dev": "gsys-rh-2", "sail": "2"}, {"dev": "gsys-rh-4", "sail": "4"}]},
    "bs": {"teamId": "bs", "name": "Blue Socks", "nameLang": "en", "color": "#5BB8E8",
           "boats": [{"dev": "gsys-bs-5", "sail": "5"}, {"dev": "gsys-bs-6", "sail": "6"}]},
    "gc": {"teamId": "gc", "name": "Green Caps", "nameLang": "en", "color": "#2E9E4F",
           "boats": [{"dev": "gsys-gc-7", "sail": "7"}, {"dev": "gsys-gc-8", "sail": "8"}]},
    "yg": {"teamId": "yg", "name": "Yellow Gloves", "nameLang": "en", "color": "#F4C430",
           "boats": [{"dev": "gsys-yg-9", "sail": "9"}, {"dev": "gsys-yg-10", "sail": "10"}]},
}

BASE = 320.0          # winner's trailing boat finishes ~here (s after the gun) — ~2.4 m/s made good
PRE_S = 150           # pre-start window (room for intro + countdown)

# Wind SPEED + gusts come from the REAL Open-Meteo ERA5 archive at the venue for each race's gun time
# (the course geometry still gives the DIRECTION via Course.wind_from, so the boats keep sailing the
# laid figure-8). If the archive is unreachable (offline / too recent), fall back to this per-day guess.
WIND_KTS_FALLBACK = {"23": 7.8, "24": 9.2}
_ARCHIVE: list[dict] | None = None      # cached hourly samples spanning both race days


def archive_wind(gun_ms: int) -> tuple[float, float | None]:
    """Real (speed_kts, gust_kts) from the Open-Meteo ERA5 archive at the venue, nearest `gun_ms`.
    Fetched once for the whole window and cached. Returns the per-day fallback speed (no gust) if the
    archive can't be reached."""
    global _ARCHIVE
    day = datetime.fromtimestamp(gun_ms / 1000, tz=UTC).strftime("%d")
    try:
        from wind_archive import archive_url, nearest_sample, parse_archive
        from wind_feed import http_get_json
        if _ARCHIVE is None:
            span = (_gun_ms(23, 0, 0), _gun_ms(24, 23, 0))
            _ARCHIVE = parse_archive(http_get_json(archive_url(CENTER["lat"], CENTER["lon"], *span)))
        s = nearest_sample(_ARCHIVE, gun_ms)
        if s:
            return round(s["speed_kts"], 1), (round(s["gust_kts"], 1) if s.get("gust_kts") else None)
    except Exception as e:  # noqa: BLE001 — best-effort; demo must still build offline
        print(f"  (Open-Meteo archive unavailable: {e}; using fallback wind)")
    return WIND_KTS_FALLBACK[day], None


def _gun_ms(day, h, m):
    return int(datetime(2026, 5, day, h, m, 0, tzinfo=UTC).timestamp() * 1000)


# Schedule: a full 4-team round-robin each day (all 6 pairings), guns 15 min apart
# from 08:00 UTC (10:00 CEST). (day, gun H:M UTC, teamA, teamB, winner, margin_s).
# margin_s = how far the loser's trailing (decisive) boat finishes behind the winner's.
FLIGHTS = [
    # ---- Day 1 (2026-05-23) ----
    (23, 8, 0, "rh", "bs", "rh", 12),
    (23, 8, 15, "gc", "yg", "gc", 6),
    (23, 8, 30, "rh", "gc", "gc", 4),
    (23, 8, 45, "bs", "yg", "bs", 20),
    (23, 9, 0, "rh", "yg", "rh", 8),
    (23, 9, 15, "bs", "gc", "gc", 15),
    # ---- Day 2 (2026-05-24) ----
    (24, 8, 0, "rh", "bs", "bs", 5),
    (24, 8, 15, "gc", "yg", "yg", 3),
    (24, 8, 30, "rh", "gc", "rh", 10),
    (24, 8, 45, "bs", "yg", "yg", 7),
    (24, 9, 0, "rh", "yg", "rh", 2),
    (24, 9, 15, "bs", "gc", "bs", 9),
]


def boat_finishes(winner_team, loser_team, margin_s):
    """Deterministic per-boat finish times (s after the gun). The trailing boat of each
    team is decisive; the winner's trailing boat finishes BASE, the loser's BASE+margin.
    Small lead/trail and start-delay spreads give the engine real overtakes to narrate."""
    out = {}
    # winner: lead boat a touch ahead, trailing boat decides at BASE
    out[winner_team] = [(0.0, BASE - 11.0), (2.0, BASE)]
    # loser: lead boat close, trailing boat margin_s back
    out[loser_team] = [(1.0, BASE - 6.0), (1.0, BASE + margin_s)]
    return out


def build_flight(day, h, m, a, b, winner, margin, wind_kts, gust_kts=None, foot_gag=False):
    gun = _gun_ms(day, h, m)
    crs = course_for_day(day)                     # this day's mark layout (constant across the day)
    fins = boat_finishes(winner, b if winner == a else a, margin)
    teams_cfg = [TEAMS[a], TEAMS[b]]

    # flatten to (race_idx, dev, team, sail, delay, finish, tack_phase)
    flat = []
    for t in teams_cfg:
        f = fins[t["teamId"]]
        for j, boat in enumerate(t["boats"]):
            delay, finish = f[j]
            idx = len(flat)
            flat.append((idx, boat["dev"], t["teamId"], boat["sail"], delay, finish, idx * 0.37))

    last_finish = max(x[5] for x in flat)
    post_s = int(math.ceil(last_finish)) + 25
    track_start = gun - PRE_S * 1000
    track_end = gun + post_s * 1000

    def pos_at(tt, idx, delay, finish, phase):
        """Boat position at race-time tt (s since the gun): jockeying before the gun, then sailing
        the route (tacking the beats) so it reaches the finish at finish_s."""
        if tt < 0.0:
            return crs.prestart_xy(idx, tt, phase)
        return crs.sail_xy(crs.made_good(tt - delay, finish - delay), idx, phase)

    frames = {}
    for sec in range(0, PRE_S + post_s + 1):
        t = float(sec - PRE_S)
        fr = {}
        for idx, dev, _team, _sail, delay, finish, phase in flat:
            x, y = pos_at(t, idx, delay, finish, phase)
            px, py = pos_at(t - 1.0, idx, delay, finish, phase)     # previous second -> course & speed
            spd = math.dist((px, py), (x, y))                       # m/s over the 1 s step
            hdg = _hdg((px, py), (x, y)) if spd > 0.05 else crs.legs[0]["brg"]
            la, lo = to_latlon(x, y)
            fr[dev] = {"lat": round(la, 7), "lon": round(lo, 7),
                       "speed": round(spd, 2), "course": round(hdg, 1)}
        frames[str(sec)] = fr

    teams = []
    for t in teams_cfg:
        team = {"teamId": t["teamId"], "name": t["name"],
                "boats": [{"deviceId": bt["dev"], "sailId": bt["sail"],
                           "visualId": f'{t["name"]} {bt["sail"]}', "boatId": None,
                           "color": t["color"], "sternM": 1.5} for bt in t["boats"]]}
        if t.get("nameLang"):
            team["nameLang"] = t["nameLang"]
        teams.append(team)

    date_str = datetime.fromtimestamp(gun / 1000, tz=UTC).strftime("%b %d")
    title = (f"GSYS Sommarcup — {date_str} · Digital 8  "
             f"({TEAMS[a]['name']} v {TEAMS[b]['name']})")
    feed = {
        "schema": "vr-broadcast-demo/1", "source": "synthetic",
        "meta": {"title": title, "user": "GSYS", "instanceId": None, "groupId": None,
                 "openEnded": False, "raceType": "team_race_2",
                 "trackStartUtcMs": track_start, "gunUtcMs": gun, "trackEndUtcMs": track_end,
                 "frameStepMs": 1000,
                 "windFromDeg": crs.wind_from, "windKts": wind_kts,
                 "windGustKts": gust_kts,
                 "venue": {"name": "Saltsjön", "navMarks": NAV_MARKS_GSYS}},
        "course": crs.feed_course(), "teams": teams, "frames": frames, "video": [],
    }
    if foot_gag:
        feed["footGag"] = True          # scorer emits the foot_drop link at this heat's pre-start opening
    return enrich(feed)


def manifest_entry(rid, feed):
    """A manifest race entry with the FULL feed inlined on `data` (self-contained manifest)."""
    meta, res = feed["meta"], feed.get("result")
    return {"id": rid, "title": meta["title"],
            "trackStartUtcMs": meta["trackStartUtcMs"], "gunUtcMs": meta["gunUtcMs"],
            "trackEndUtcMs": meta["trackEndUtcMs"],
            "winnerSec": res["winnerSec"] if res else None,
            "winnerName": res["winnerName"] if res else None,
            "data": feed}


# --- Monty Python foot gag (demo only) --------------------------------------------------------------
# Policy: drop the giant foot ONLY between races (never a day's first heat), only when there is real
# SPARE TIME before the next lineup, and AT MOST 3 times per race day — spread across the day.
FOOT_GAG_MAX_PER_DAY = 3
FOOT_GAG_MIN_SPARE_S = 240        # need ≥4 min of dead time before the next lineup to justify the gag


def _flight_track_end_ms(fl):
    """When a flight's tracking window ends (gun + last finish + wind-down), in epoch ms — used to
    measure the dead time before the next race's lineup opens."""
    day, h, m, a, b, winner, margin = fl
    loser = b if winner == a else a
    fins = boat_finishes(winner, loser, margin)
    last_finish = max(t for pairs in fins.values() for (_delay, t) in pairs)
    post_s = int(math.ceil(last_finish)) + 25
    return _gun_ms(day, h, m) + post_s * 1000


def select_foot_gag_flights(flights):
    """Return the set of 0-based indices into `flights` that open with the foot gag. Deterministic:
    per race day, take the between-race transitions that have ≥FOOT_GAG_MIN_SPARE_S of dead time before
    the next lineup, then keep at most FOOT_GAG_MAX_PER_DAY of them spread evenly across the day."""
    by_day = {}
    for i, fl in enumerate(flights):
        by_day.setdefault(fl[0], []).append(i)             # fl[0] = race day; list stays gun-order
    picked = set()
    for _day, idxs in by_day.items():
        cand = []
        for k in range(1, len(idxs)):                      # skip the day's first race (nothing to link from)
            cur = flights[idxs[k]]
            spare_ms = _gun_ms(cur[0], cur[1], cur[2]) - PRE_S * 1000 - _flight_track_end_ms(flights[idxs[k - 1]])
            if spare_ms >= FOOT_GAG_MIN_SPARE_S * 1000:
                cand.append(idxs[k])
        n = min(FOOT_GAG_MAX_PER_DAY, len(cand))
        for j in range(n):                                 # spread the picks evenly over the candidates
            pos = round(j * (len(cand) - 1) / (n - 1)) if n > 1 else len(cand) // 2
            picked.add(cand[pos])
    return picked


def director_params():
    """Director timing params — reuse the existing manifest's if present, else the defaults."""
    params = {"winnerGraceS": 40, "preStartS": 30, "fastReplayX": 5}
    mf = DATA / "manifest.json"
    if mf.exists():
        params = json.loads(mf.read_text(encoding="utf-8")).get("params", params)
    return params


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--foot-gag", action="store_true",
                    help="drop the Monty Python giant foot between races (demo gag; ≤3/day, needs spare time)")
    args = ap.parse_args()
    DATA.mkdir(parents=True, exist_ok=True)
    for day in ("23", "24"):
        c = course_for_day(day)
        print(f"May {day}: figure-8 marks={len(WAYPOINTS)}, route ~{c.route_len:.0f} m, "
              f"wind {c.wind_from} deg (geometry), windward gate shifted {DAY_WIND_SHIFT_DEG[day]:.0f} deg")
    gag = select_foot_gag_flights(FLIGHTS) if args.foot_gag else set()
    if gag:
        print(f"foot gag: {len(gag)} race(s) -> " + ", ".join(f"gsys{i + 1:02d}" for i in sorted(gag)))
    params = director_params()
    races = []
    for i, (day, h, m, a, b, winner, margin) in enumerate(FLIGHTS, 1):
        wind_kts, gust_kts = archive_wind(_gun_ms(day, h, m))
        feed = build_flight(day, h, m, a, b, winner, margin, wind_kts, gust_kts,
                            foot_gag=(i - 1) in gag)
        races.append(manifest_entry(f"gsys{i:02d}", feed))
    # play in chronological order: by gun time (May 23 flights, then May 24)
    races.sort(key=lambda r: r["gunUtcMs"])
    for r in races:
        print(f"  {r['id']}: {r['title']}  -> winner {r['winnerName']} @ {r['winnerSec']}s  "
              f"frames={len(r['data']['frames'])}")
    for old in DATA.glob("gsys*.json"):          # drop now-redundant per-race files (all inline now)
        old.unlink()
    with open(DATA / "manifest.json", "w", encoding="utf-8") as f:
        json.dump({"races": races, "params": params}, f, ensure_ascii=False, separators=(",", ":"))
    size_mb = (DATA / "manifest.json").stat().st_size / 1_000_000
    print(f"wrote self-contained manifest.json: {len(races)} GSYS races (May 23-24, "
          f"time order), {size_mb:.1f} MB")


if __name__ == "__main__":
    main()
