r"""Re-process reconstructed GSYS races IN PLACE from the cached manifest — pure-V3, no Firestore.

The raw 1 Hz tracks are ALREADY captured in data/races/manifest.json (each race's `data.frames`
run well past the lap-1 window). So the reviewer fixes for the auto-fit races are a local
re-process, not a re-fetch:

  * marks  — re-fit with the wind-aware, tack-filtered cluster fit (fit_marks.build_course_block),
             so the windward gate is no longer lost in the beat's zig-zag and the marks land where
             the boats actually round (replaces the drifting template-transform).
  * wind   — DIRECTION recomputed from the new marks (bearing start-gate-mid -> windward mark);
             SPEED/gust kept from the existing meta (no Open-Meteo call).
  * gun    — re-estimated with the "fleet bunches and slows, then accelerates" heuristic, falling
             back to the start-line crossing and then the existing gun.
  * window — the corrected marks move the finish to its true (later) time, so the second lap is no
             longer cut; trackEndUtcMs follows the last finisher (+2 min), as the exporter does.

Lost-signal boats (a track that ends mid-race) are left as-is: the renderer already coasts the
boat's last track gracefully (reviewer: "the continued track was well handled").

  ..\..\..\.venv-demo\Scripts\python.exe export\refit_gsys.py                 # gsys08 + gsys09
  ..\..\..\.venv-demo\Scripts\python.exe export\refit_gsys.py --rid gsys08    # one race
"""
from __future__ import annotations

import argparse
import json
import math
import os
import sys
from datetime import UTC, datetime
from pathlib import Path
from statistics import median

_HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(_HERE))
sys.path.insert(0, str(_HERE.parents[2] / "scoring"))
from ai_bake import apply_ai_bake  # noqa: E402  (the ONE event-agnostic author+editor bake)
from course_templates import FIGURE8, TEMPLATES, expand_template  # noqa: E402
from detect_gsys_races import CENTER  # noqa: E402
from fit_marks import build_course_block, min_clearance_m, to_latlon  # noqa: E402
from fit_template import _seg_cross, detect_start, fit_template_course, race_window  # noqa: E402
from gen_gsys_real import PRE_S, refine_gun  # noqa: E402
from gen_gsys_series import _hdg, manifest_entry  # noqa: E402
from score_race import enrich  # noqa: E402
from segment_gsys import to_xy  # noqa: E402

DATA = _HERE.parent / "data" / "races" / "manifest.json"
DEFAULT_RIDS = ["gsys06", "gsys07", "gsys08", "gsys09"]
FIT_LO_S, FIT_HI_S = 30, 13 * 60          # fit the marks from the race laps only (skip the pre-start)
MIN_SENSIBLE_SEC = PRE_S + 6 * 60          # a real finish is well after the gun (drop bogus roundings)
# REVIEWED courses (Martin, 2026-07-09 round — courses.xlsx / course_review.py MARTIN_PINS): the final
# buoy positions per race, local metres (east, north) from CENTER. Buoys: C=M1/M5, D=M2/M6 (windward),
# A=start-stbd/M3/finish-port, B=start-port/M4/finish-stbd. A race listed here is baked DIRECTLY on this
# course with the start detected as the boats' own start-line crossings (every boat verified to sail a
# complete race against these marks in the review) — no auto-fit, no heuristics. Supersedes the earlier
# per-race GUN_EXACT / MARK_OVERRIDE pins, which are kept only as mechanisms for future one-off fixes.
_MAY22 = {"A": (68.5, -12.7), "B": (59.5, 32.7), "C": (-100.9, -29.6), "D": (-90.0, 10.0)}
REVIEWED_COURSES: dict[str, dict[str, tuple[float, float]]] = {
    "gsys01": _MAY22,
    "gsys02": _MAY22,                  # 22 May: marks fixed, same course for its races (confirmed)
    "gsys06": {"A": (48.6, -142.2), "B": (51.9, -68.7), "C": (-160.0, -70.0), "D": (-147.0, -3.6)},
    "gsys07": {"A": (50.0, -125.0), "B": (74.0, -70.0), "C": (-155.3, -69.5), "D": (-145.0, -5.0)},
}
# Reviewer-specified EXACT guns (venue-local CEST comments → UTC) / one-off mark pins for races NOT in
# REVIEWED_COURSES. Empty right now — the reviewed bake replaced the gsys06/07 entries.
GUN_EXACT: dict[str, str] = {}
MARK_OVERRIDE: dict[str, dict] = {}
# Tighter→looser rounding-tolerance scan: with the marks now fitted where the boats actually round, a
# TIGHT capture credits each rounding at the real moment — so the "next mark" the commentary names stays
# correct (the loose 220 m capture used to advance the mark counter ~100 m early → "mark 4" when it's 3).
TOLERANCE_CAPS = (35, 50, 70, 95, 130, 180, 240, 300)
SEED_WIND_DEG = 270.0      # fixed bootstrap for the tack-filter (Stockholm venue ~W); the fit iterates off it
AI_ON = False              # set by --ai: run the bounded LLM colour/camera EDITOR (keep/drop + shot) over the beats
AUTHOR_ON = False          # set by --author: also run the L4 AI AUTHOR (rewrite colour TEXT, validated)
# Declared course SHAPE per race (default the figure-8). The template fitter measures ONLY the buoy
# positions against this topology (`course_templates.py`) — no top-N-cluster / east-west guessing. It
# bootstraps from the race's existing marks and is accepted only if the fitted buoys pass the rectangle
# check; otherwise the old cluster fitter is used. (gsys06 is then re-pinned by MARK_OVERRIDE regardless.)
COURSE_SHAPE = {}          # rid -> template name; default "digital 8"


def _load_env() -> None:
    """Load v3/broadcast/.env (KEY=VALUE lines) into the environment if present — so `--ai` can read
    ANTHROPIC_API_KEY without it ever touching the command line or chat. Existing env vars win."""
    envf = _HERE.parents[1] / ".env"           # v3/broadcast/.env (gitignored)
    if not envf.exists():
        return
    for raw in envf.read_text(encoding="utf-8").splitlines():
        line = raw.strip()
        if line and not line.startswith("#") and "=" in line:
            k, v = line.split("=", 1)
            os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))


def apply_ai(scored: dict, feed: dict) -> dict:
    """Thin, GSYS-specific wrapper over the event-agnostic `ai_bake.apply_ai_bake` (the ONE shared bake
    used by every event). `--author` (L4) rewrites the colour text; `--ai` (L2) frames it (keep/drop +
    Part D shot type). Keyless ⇒ deterministic no-op, so always safe. Non-deterministic ⇒ a SNAPSHOT."""
    scored, audit = apply_ai_bake(scored, feed, author=AUTHOR_ON, editor=AI_ON)
    if audit.get("authored") is not None:
        print(f"      author: {sum(1 for d in audit['authored'].values() if d)}/{len(audit['beats'])} colour lines re-authored")
    if audit.get("choices") is not None:
        print(f"      ai: {len(audit['beats'])} beats -> {sum(1 for c in audit['choices'] if not c.get('keep'))} dropped")
    return scored


def tracks_from_frames(frames: dict, teams: list, t0: int) -> dict:
    """Rebuild {deviceId: [{t,lat,lon,speed,course}]} (1 Hz, sorted) from the baked frames. Devices are
    kept in a DETERMINISTIC order (teams/boats order, not a set) — the greedy mark clustering depends on
    the apex ordering, so a set's per-process hash order would make the whole fit non-deterministic."""
    devs = [b["deviceId"] for t in teams for b in t["boats"]]
    pts: dict[str, list] = {d: [] for d in devs}
    for sec, fr in frames.items():
        t = t0 + int(sec) * 1000
        for dev, p in fr.items():
            if dev in pts:
                pts[dev].append({"t": t, "lat": p["lat"], "lon": p["lon"],
                                 "speed": p.get("speed") or 0.0, "course": p.get("course") or 0.0})
    for d in pts:
        pts[d].sort(key=lambda p: p["t"])
    return {d: pl for d, pl in pts.items() if pl}


# --- tracker-antenna normalization (reviewer, round 3) ------------------------------------------
# The GNSS trackers are NOT at the hull centre: boat 5 carries hers on the TRANSOM, 20 cm port of
# the centreline; every other boat is on the centreline 300 cm abaft the bow. Uncorrected, the
# rendered hulls sit up to half a boat-length away from where the boats really were — which is why
# "boats pass through each other" on screen. Every fix is shifted from the antenna to the HULL
# CENTRE before anything else runs (start detection, mark fits, scoring, the baked frames).
LOA_M = 8.0                                # J/80 length overall
TRANSOM_SAIL = "5"                         # the one boat with a transom-mounted tracker


def _tracker_offset(sail: str) -> tuple[float, float]:
    """(fwd_m, stbd_m) of the tracker antenna relative to the hull centre."""
    if sail == TRANSOM_SAIL:
        return (-LOA_M / 2, -0.2)          # transom, 20 cm to PORT of the centreline
    return (LOA_M / 2 - 3.0, 0.0)          # centreline, 300 cm from the bow (1 m fwd of centre)


def normalize_to_hull_centre(pts: dict, teams: list, meta: dict) -> dict:
    """Shift every fix from the tracker antenna to the hull centre, along a SMOOTHED heading —
    a ±4 s speed-weighted circular mean, because raw 1 Hz COG is far too jittery (drifting in the
    pre-start it is near-random) to swing boat 5's 4 m lever arm on directly.

    IDEMPOTENT across re-bakes: the baked meta carries `hullCentreNormalized` and an already-
    normalized manifest is passed through untouched (refit re-processes IN PLACE — without the
    guard every re-run would walk the fleet another antenna-offset across the water)."""
    if meta.get("hullCentreNormalized"):
        return pts
    sail_by_dev = {b["deviceId"]: str(b.get("sailId") or "") for t in teams for b in t["boats"]}
    out = {}
    for dev, pl in pts.items():
        fwd, stbd = _tracker_offset(sail_by_dev.get(dev, ""))
        n = len(pl)
        heads = []
        for i in range(n):                 # speed-weighted vector mean over ±4 samples
            sx = sy = 0.0
            for j in range(max(0, i - 4), min(n, i + 5)):
                w = max(pl[j]["speed"], 0.1)
                a = math.radians(pl[j]["course"])
                sx += w * math.sin(a)
                sy += w * math.cos(a)
            heads.append(math.atan2(sx, sy))
        fixed = []
        for p, a in zip(pl, heads, strict=True):
            de = -(fwd * math.sin(a) + stbd * math.cos(a))   # antenna → centre shift, metres east
            dn = -(fwd * math.cos(a) - stbd * math.sin(a))   # … metres north
            lat = p["lat"] + dn / 111_320.0
            lon = p["lon"] + de / (111_320.0 * math.cos(math.radians(p["lat"])))
            fixed.append({**p, "lat": round(lat, 7), "lon": round(lon, 7)})
        out[dev] = fixed
    return out


# --- GNSS-dropout fill (reviewer round 3) ---------------------------------------------------------
# "In replay mode the path and the heading of the boat shall be smoothly interpolated between the
# positions where it was lost and where it was found again." The bake IS the replay: bridge every
# dropout with a cubic Hermite between the bounding fixes, tangents = their velocity vectors, at
# 1 Hz — position curves smoothly out of the last heading and into the reacquired one, and the
# heading/speed follow the curve's tangent. (Live mode instead dead-reckons and jumps — renderer.)
GAP_FILL_MIN_S = 3          # a real dropout, not a missed sample
GAP_FILL_MAX_S = 600        # beyond this, bridging invents too much track — leave the gap


def fill_gnss_gaps(pts: dict) -> dict:
    out = {}
    for dev, pl in pts.items():
        res: list = []
        for p in pl:
            if res:
                q = res[-1]
                dt = (p["t"] - q["t"]) / 1000.0
                if GAP_FILL_MIN_S < dt <= GAP_FILL_MAX_S:
                    ky = 111_320.0
                    kx = 111_320.0 * math.cos(math.radians(q["lat"]))
                    x1 = (p["lon"] - q["lon"]) * kx
                    y1 = (p["lat"] - q["lat"]) * ky

                    def vel(pt):
                        a = math.radians(pt["course"])
                        return pt["speed"] * math.sin(a), pt["speed"] * math.cos(a)

                    v0, v1 = vel(q), vel(p)
                    for k in range(1, int(dt)):
                        u = k / dt
                        h10 = u**3 - 2 * u**2 + u
                        h01 = -2 * u**3 + 3 * u**2
                        h11 = u**3 - u**2
                        x = h10 * dt * v0[0] + h01 * x1 + h11 * dt * v1[0]
                        y = h10 * dt * v0[1] + h01 * y1 + h11 * dt * v1[1]
                        d10 = 3 * u**2 - 4 * u + 1
                        d01 = -6 * u**2 + 6 * u
                        d11 = 3 * u**2 - 2 * u
                        dx = d10 * v0[0] + d01 * x1 / dt + d11 * v1[0]
                        dy = d10 * v0[1] + d01 * y1 / dt + d11 * v1[1]
                        res.append({"t": q["t"] + k * 1000,
                                    "lat": round(q["lat"] + y / ky, 7),
                                    "lon": round(q["lon"] + x / kx, 7),
                                    "speed": round(math.hypot(dx, dy), 2),
                                    "course": round(math.degrees(math.atan2(dx, dy)) % 360, 1)})
            res.append(p)
        out[dev] = res
    return out


# --- wind auto-adjust (reviewer round 3) ----------------------------------------------------------
# "If the boats are sailing with a smaller angle than 35 degrees to the assumed wind direction for
# longer periods without losing speed, the assumed wind direction shall be adjusted." A boat can't
# hold <35° TWA at full speed — sustained offenders mean the ASSUMED direction is off, and their
# signed shortfall says which way to rotate it.
WIND_MIN_TWA = 35.0
WIND_ADJ_CAP = 10.0         # never swing the geometric estimate by more than this
WIND_ADJ_MIN_S = 45         # "longer periods": require this many offending fast samples (≈ seconds)


def auto_adjust_wind(pts: dict, wind_from: float, gun_ms: int) -> tuple[float, float]:
    """Returns (adjusted_wind_from, applied_delta_deg)."""
    errs = []
    for pl in pts.values():
        for p in pl:
            if p["t"] < gun_ms or p["speed"] < 1.5:            # racing, at speed (m/s)
                continue
            twa = ((p["course"] - wind_from + 180) % 360) - 180   # 0 = sailing straight at the source
            if abs(twa) < WIND_MIN_TWA:
                # rotate the wind AWAY from the boat's course: twa>0 needs a smaller wf ... err<0
                errs.append(-math.copysign(WIND_MIN_TWA - abs(twa), twa))
    if len(errs) < WIND_ADJ_MIN_S:
        return wind_from, 0.0
    delta = max(-WIND_ADJ_CAP, min(WIND_ADJ_CAP, sum(errs) / len(errs)))
    return round((wind_from + delta) % 360, 1), round(delta, 1)


# Reviewer round 3: "where a boat passes almost through a mark, move that mark ~1 m so no boat
# touches it". A J/80 half-beam is ~1.25 m and the buoy body ~0.4 m, so a hull-CENTRE track closer
# than ~2 m reads on screen as sailing over the buoy.
CLEAR_TARGET_M = 2.0       # minimum hull-centre distance to any buoy after the nudge
NUDGE_CAP_M = 2.0          # never move a reviewed buoy further than this. The reviewer said "1 m or
#                            so", but his hard requirement is "no boat touches it" — the distance
#                            penalty in the search keeps every move as small as the pinch allows.


def nudge_marks_clear(buoys: dict, timed: dict) -> tuple[dict, list[str]]:
    """For each brushed buoy, pick the position within NUDGE_CAP_M that MAXIMIZES the fleet's
    closest approach (0.25 m grid search over every displayed track point near the buoy). A plain
    "step away from the nearest point" fails at roundings — the track arcs AROUND the buoy, so every
    direction is toward some other pass; max-min finds the widest gap the arcs actually leave.
    Clearance benefit is capped at CLEAR_TARGET_M and distance mildly penalized, so a buoy never
    wanders further than the closest fix needs."""
    pts_all = [p for tr in timed.values() for _s, p in tr]
    out, notes = {}, []
    step = 0.25
    n = int(NUDGE_CAP_M / step)
    for k, (bx, by) in buoys.items():
        near = [p for p in pts_all if math.dist(p, (bx, by)) < 25.0]

        def clear(x, y, _near=near):
            return min((math.dist((x, y), p) for p in _near), default=99.0)

        c0 = clear(bx, by)
        if c0 >= CLEAR_TARGET_M:
            out[k] = (bx, by)
            continue
        cands = [(c0, 0.0, bx, by)]
        for i in range(-n, n + 1):
            for j in range(-n, n + 1):
                r = math.hypot(i * step, j * step)
                if r == 0 or r > NUDGE_CAP_M:
                    continue
                cands.append((clear(bx + i * step, by + j * step), r, bx + i * step, by + j * step))
        c, r, x, y = max(cands, key=lambda cd: min(cd[0], CLEAR_TARGET_M) - 0.05 * cd[1])
        if r > 0 and c > c0 + 0.05:
            out[k] = (x, y)
            notes.append(f"{k}+{r:.1f}m {c0:.1f}->{c:.1f}m")
        else:
            out[k] = (bx, by)
            if c0 < CLEAR_TARGET_M:
                notes.append(f"{k} pinched at {c0:.1f}m")
    return out, notes


def first_bow_crossing_ms(pts: dict, A, B, C, t0_old: int, gun_sec: int, spread: int | None) -> int | None:
    """Earliest moment ANY hull part — the BOW, LOA/2 ahead of the (normalized) hull centre — crosses
    the start line onto the course, around the detected mass start. The gun may not be LATER than
    this (reviewer: "under no circumstances shall any part of any hull cross the starting line before
    the start signal"; the centre-crossing gun read 3–5 s late). Sub-second via linear interpolation
    of the 1 Hz bow track; the window starts a tight 12 s before the detected gun so an earlier
    dip-and-return near the line cannot drag the gun away from the real start."""
    dx, dy = B[0] - A[0], B[1] - A[1]
    ext = 0.4
    Ae, Be = (A[0] - dx * ext, A[1] - dy * ext), (B[0] + dx * ext, B[1] + dy * ext)

    def side(p):
        return dx * (p[1] - A[1]) - dy * (p[0] - A[0])

    want = side(C) > 0
    lo_ms, hi_ms = t0_old + (gun_sec - 12) * 1000, t0_old + (gun_sec + (spread or 0) + 10) * 1000
    best = None
    for pl in pts.values():
        bows = []
        for p in pl:
            if not lo_ms <= p["t"] <= hi_ms:
                continue
            a = math.radians(p["course"])
            x, y = to_xy(p["lat"], p["lon"], CENTER)
            bows.append((p["t"], (x + (LOA_M / 2) * math.sin(a), y + (LOA_M / 2) * math.cos(a))))
        for i in range(1, len(bows)):
            (tp, p0), (tq, p1) = bows[i - 1], bows[i]
            s0, s1 = side(p0), side(p1)
            if (s0 > 0) == want or (s1 > 0) != want:      # must go off-course side -> course side
                continue
            if not _seg_cross(p0, p1, Ae, Be):
                continue
            u = abs(s0) / max(abs(s0) + abs(s1), 1e-9)
            tc = tp + u * (tq - tp)
            if best is None or tc < best:
                best = tc
    return int(best) if best is not None else None


def estimate_gun_fleet(pts: dict, hint_gun: int) -> tuple[int, str]:
    """Martin's start heuristic: before the gun the fleet bunches near the line and SLOWS; at the gun
    they accelerate away and cross. Find the fleet speed *lull* in a window around the hint, then the
    gun = the acceleration onset just after it (smoothed fleet speed recovers past the midpoint between
    the lull and the post-start cruise). Returns (gun_ms, how)."""
    lo, hi = hint_gun - 180_000, hint_gun + 300_000   # the real gun can run a few min past the coarse hint
    by_sec: dict[int, list] = {}
    for pl in pts.values():
        for p in pl:
            if lo <= p["t"] <= hi:
                by_sec.setdefault(p["t"] // 1000, []).append(p["speed"])
    secs = sorted(by_sec)
    if len(secs) < 30:
        return hint_gun, "hint (too few points)"
    mean = {s: sum(by_sec[s]) / len(by_sec[s]) for s in secs}
    sm = {}                                                  # ±5 s moving average
    for i, s in enumerate(secs):
        w = [mean[secs[j]] for j in range(max(0, i - 5), min(len(secs), i + 6))]
        sm[s] = sum(w) / len(w)
    lull = min(secs, key=lambda s: sm[s])
    after = [sm[s] for s in secs if lull < s <= lull + 60]
    if not after:
        return hint_gun, "hint (no post-lull data)"
    cruise = median(after)
    thresh = sm[lull] + 0.5 * (cruise - sm[lull])
    for s in secs:
        if s > lull and sm[s] >= thresh:
            return s * 1000, f"fleet-accel (lull {sm[lull]:.1f}->cruise {cruise:.1f} kn)"
    return hint_gun, "hint (no acceleration found)"


def score_with_tolerance(feed: dict) -> dict:
    """enrich() scanning rounding tolerance tight->loose; take the SMALLEST cap that yields a realistic
    finish (a loose radius credits a bogus/early rounding — which also mis-names the next mark)."""
    scored = None
    for cap in TOLERANCE_CAPS:
        feed["meta"]["roundCaptureM"] = float(cap)
        feed["meta"]["roundDirectM"] = float(min(cap * 0.3, 30))
        scored = enrich(feed)
        res = scored.get("result") or {}
        if (res.get("maxFinishSec") or 0) >= MIN_SENSIBLE_SEC:
            return scored
    return scored               # none sensible — the loosest attempt


def _ts(s: str) -> int:
    return int(datetime.strptime(s, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC).timestamp() * 1000)


def apply_mark_override(rid: str, course: dict, pts: dict, teams: list) -> str | None:
    """Apply a reviewer mark override to the fitted course IN PLACE (the windward gate pinned to where a
    boat rounds at an instant; M2/M6 := the old M1/M5). Returns a short note, or None if no override."""
    ov = MARK_OVERRIDE.get(rid)
    wps = course["waypoints"]
    if not ov or "windward_from_round" not in ov or len(wps) < 6:
        return None
    spec = ov["windward_from_round"]
    dev = next((b["deviceId"] for t in teams for b in t["boats"] if b["sailId"] == spec["sail"]), None)
    pl = pts.get(dev) or []
    at = _ts(spec["at"])
    p = min(pl, key=lambda q: abs(q["t"] - at), default=None) if pl else None
    if not p:
        return None
    # M2/M6 := an explicit reviewer position if given (the M1/M5 spot seen in the prior render), else the
    # auto-fit's old M1/M5. Take it BEFORE moving M1/M5.
    m2m6 = ov.get("m2m6") or {"lat": wps[0]["lat"], "lon": wps[0]["lon"]}
    for i in (0, 4):                                     # M1/M5 := the boat's rounding point at `at`
        wps[i]["lat"], wps[i]["lon"] = round(p["lat"], 7), round(p["lon"], 7)
    for i in (1, 5):                                     # M2/M6 := the previous M1/M5 (north of the new one)
        wps[i]["lat"], wps[i]["lon"] = round(m2m6["lat"], 7), round(m2m6["lon"], 7)
    # leeward gate / start+finish line pinned to reviewer-accepted positions (don't let the re-fit drift it)
    lee = ov.get("leeward")
    if lee:
        for idx, key in ((2, "M3"), (3, "M4")):
            if key in lee:
                wps[idx]["lat"], wps[idx]["lon"] = round(lee[key]["lat"], 7), round(lee[key]["lon"], 7)
        course["startLine"] = {"port": {"lat": wps[2]["lat"], "lon": wps[2]["lon"]},
                               "starboard": {"lat": wps[3]["lat"], "lon": wps[3]["lon"]}}
    ll = [(w["lat"], w["lon"]) for w in wps]
    course["center"] = {"lat": sum(a for a, _ in ll) / len(ll), "lon": sum(b for _, b in ll) / len(ll)}
    tag = "M2/M6=prev-render M1/M5" if ov.get("m2m6") else "M2/M6=old M1/M5"
    if lee:
        tag += ", M3/M4=prev-render leeward"
    return f"M1/M5=sail{spec['sail']}@{spec['at'][-8:]}, {tag}"


def refit_race(entry: dict) -> dict:
    feed = entry["data"]
    meta, teams, frames = feed["meta"], feed["teams"], feed["frames"]
    t0_old, hint_gun = meta["trackStartUtcMs"], meta["gunUtcMs"]
    pts = fill_gnss_gaps(normalize_to_hull_centre(tracks_from_frames(frames, teams, t0_old), teams, meta))

    # 1) gun: a reviewer-pinpointed EXACT gun wins outright; else the fleet bunch-and-accelerate heuristic.
    if entry["id"] in GUN_EXACT:
        gun_ms = int(datetime.strptime(GUN_EXACT[entry["id"]], "%Y-%m-%d %H:%M:%S")
                     .replace(tzinfo=UTC).timestamp() * 1000)
        how = "reviewer-exact"
    else:
        gun_ms, how = estimate_gun_fleet(pts, hint_gun)

    # 2) marks: TEMPLATE fitter — measure the buoy positions of the DECLARED shape (figure-8) from the
    #    race tracks. The buoys are found + LABELLED from scratch by sailing order (not the race's existing
    #    marks, which the old cluster fitter often mislabels). Accepted only if the fitted buoys pass the
    #    rectangle check; else fall back to the old cluster fitter.
    tpl = TEMPLATES.get(COURSE_SHAPE.get(entry["id"], "digital 8"))
    course, how_fit, used_template = None, "", False
    if tpl:
        lo2 = gun_ms + FIT_LO_S * 1000
        tpl_tracks = [[(p["lat"], p["lon"]) for p in pl if p["t"] >= lo2] for pl in pts.values()]
        cand, qa = fit_template_course(tpl_tracks, CENTER, tpl)
        if qa["rect_ok"]:
            course, how_fit, used_template = cand, f"template n={qa['counts']}", True
        else:
            _r = qa.get("rect") or {}
            how_fit = (f"template-REJECT({qa.get('reason') or 'rect'} "
                       f"{_r.get('short_diff')}/{_r.get('long_diff')})->cluster")
    if course is None:                         # fallback: the old wind-iterated cluster fitter
        lo, hi = gun_ms + FIT_LO_S * 1000, gun_ms + FIT_HI_S * 1000
        fit_tracks = [[(p["lat"], p["lon"]) for p in pl if lo <= p["t"] <= hi] for pl in pts.values()]
        w2 = SEED_WIND_DEG
        for _ in range(6):
            course = build_course_block(fit_tracks, CENTER, wind_from=w2)
            sl = course["startLine"]
            smid = to_xy((sl["port"]["lat"] + sl["starboard"]["lat"]) / 2,
                         (sl["port"]["lon"] + sl["starboard"]["lon"]) / 2, CENTER)
            w = round(_hdg(smid, to_xy(course["waypoints"][0]["lat"],
                                       course["waypoints"][0]["lon"], CENTER)), 1)
            done = abs(((w - w2 + 180) % 360) - 180) < 1.0
            w2 = w
            if done:
                break
        how_fit = f"{how_fit}+cluster" if how_fit else "cluster"
    how += f" [{how_fit}]"
    # wind DIR = bearing(start-line mid -> windward mark M1); the template fit is itself wind-independent.
    sl = course["startLine"]
    smid = to_xy((sl["port"]["lat"] + sl["starboard"]["lat"]) / 2,
                 (sl["port"]["lon"] + sl["starboard"]["lon"]) / 2, CENTER)
    wind_from = round(_hdg(smid, to_xy(course["waypoints"][0]["lat"],
                                       course["waypoints"][0]["lon"], CENTER)), 1)

    # 2b) reviewer mark/camera overrides (pin the windward gate to a boat's rounding point, rotate a cam);
    #     recompute the wind from the (possibly moved) windward mark.
    ov_note = apply_mark_override(entry["id"], course, pts, teams)
    if ov_note:
        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)
        how += f" + override[{ov_note}]"

    # 3) for a heuristic gun, snap to the first start-line crossing on the fitted line if they agree.
    if entry["id"] not in GUN_EXACT:
        cross = refine_gun(course, pts, gun_ms)
        if abs(cross - gun_ms) <= 25_000:
            gun_ms, how = cross, how + " + line-crossing"
    t0 = gun_ms - PRE_S * 1000

    # 4) frames re-keyed from the new t0 (drop the pre-window pre-start); media/video reused, tSec re-based.
    new_frames: dict[str, dict] = {}
    for dev, pl in pts.items():
        for p in pl:
            if p["t"] < t0:
                continue
            sec = (p["t"] - t0) // 1000
            new_frames.setdefault(str(sec), {})[dev] = {
                "lat": round(p["lat"], 7), "lon": round(p["lon"], 7),
                "speed": round(p["speed"], 2), "course": round(p["course"], 1)}
    media = {}
    for dev, lst in (feed.get("media") or {}).items():
        shifted = [{**mm, "tSec": round(mm["tSec"] + (t0_old - t0) / 1000, 3)} for mm in lst]
        media[dev] = [mm for mm in shifted if mm["tSec"] >= 0]

    play_to_end = bool((MARK_OVERRIDE.get(entry["id"]) or {}).get("play_to_track_end"))
    new_feed = {
        "schema": feed.get("schema", "vr-broadcast-demo/1"), "source": feed.get("source", "real"),
        "meta": {**meta, "windFromDeg": wind_from, "gunUtcMs": gun_ms, "trackStartUtcMs": t0,
                 "trackEndUtcMs": hint_gun + 18 * 60_000, "preliminaryResult": True,
                 "hullCentreNormalized": True,
                 # multi-lap race the 1-lap route under-models + tracks that end mid-lap → no clean finish:
                 # tell the engine to run a live leaderboard with a preliminary leader, not a lap-1 finish.
                 **({"noCleanFinish": True} if play_to_end else {})},
        "course": course, "teams": teams, "frames": new_frames,
        "video": feed.get("video", []), "media": media, "authoredOverrides": False,
    }
    if play_to_end:
        # no finish to seek — score once with a tight cap so the mark-rounding (and thus the "next mark"
        # the commentary names) stays accurate; the window is set to the track end below.
        new_feed["meta"]["roundCaptureM"] = 80.0
        new_feed["meta"]["roundDirectM"] = 24.0
        scored = enrich(new_feed)
    else:
        scored = score_with_tolerance(new_feed)

    # No realistic finish at any tolerance. Two cases:
    #  (a) the TEMPLATE fit was accepted (buoys form the rectangle) — the marks are good, the race is just
    #      multi-lap / the tracks end mid-lap (like gsys06). Keep the fitted course and play to the track
    #      end with a preliminary leader (auto no-clean-finish).
    #  (b) the fit failed too (noisy tracks / odd course) — don't ship a bogus early finish; fall back to
    #      the existing course and just re-enrich.
    res = scored.get("result") or {}
    if not play_to_end and (res.get("maxFinishSec") or 0) < MIN_SENSIBLE_SEC:
        if used_template:
            print(f"  {entry['id']}: template fit ok but no clean finish — play-to-track-end (preliminary)")
            play_to_end = True
            new_feed["meta"]["noCleanFinish"] = True
            new_feed["meta"]["roundCaptureM"], new_feed["meta"]["roundDirectM"] = 80.0, 24.0
            scored = enrich(new_feed)
            res = scored.get("result") or {}
        else:
            print(f"  {entry['id']}: auto-fit found no realistic finish — keeping existing course (re-enrich, preliminary)")
            feed["meta"]["preliminaryResult"] = True   # still an auto-fit course → provisional
            feed["meta"].pop("suppressWinner", None)
            return manifest_entry(entry["id"], apply_ai(enrich(feed), feed))

    # 5) window ends 2 min after the LAST finisher (the corrected marks push this past lap 1).
    end_sec = res.get("maxFinishSec")
    if end_sec is not None:
        scored["meta"]["trackEndUtcMs"] = t0 + int((end_sec + 120) * 1000)
    # A multi-lap race the route under-models would "finish" early (end of lap 1) — for races flagged
    # play_to_track_end, run the window to the END of the captured tracks so the full race is shown
    # (the result stays preliminary; the data may simply stop before every boat crosses).
    if (MARK_OVERRIDE.get(entry["id"]) or {}).get("play_to_track_end"):
        last_t = max((p["t"] for pl in pts.values() for p in pl), default=t0)
        scored["meta"]["trackEndUtcMs"] = int(last_t)

    all_ll = [[(p["lat"], p["lon"]) for p in pl] for pl in pts.values()]
    win_s = (scored["meta"]["trackEndUtcMs"] - t0) // 1000
    print(f"  {entry['id']}: gun {(gun_ms - hint_gun) / 1000:+.0f}s [{how}]  wind {meta.get('windFromDeg')}->{wind_from}°  "
          f"cap={scored['meta'].get('roundCaptureM')}  clear={min_clearance_m(course, all_ll, CENTER):.1f}m  "
          f"finishSec={end_sec}  window={win_s}s (was {(meta['trackEndUtcMs'] - t0_old) // 1000}s)")
    return manifest_entry(entry["id"], apply_ai(scored, new_feed))


def bake_reviewed(entry: dict) -> dict:
    """Bake a race whose course the reviewer has SIGNED (REVIEWED_COURSES): build the figure-8 straight
    from the reviewed buoy positions, take the gun from the boats' own start-line crossings (each boat
    verified in the review to sail a complete race on these marks), re-key the frames from gun-PRE_S, and
    score. The finish line is real and every boat crosses it → the result is DEFINITIVE."""
    rid = entry["id"]
    feed = entry["data"]
    meta, teams = feed["meta"], feed["teams"]
    t0_old, hint_gun = meta["trackStartUtcMs"], meta["gunUtcMs"]
    pts = fill_gnss_gaps(normalize_to_hull_centre(tracks_from_frames(feed["frames"], teams, t0_old), teams, meta))

    buoys = REVIEWED_COURSES[rid]
    A, B, C = buoys["A"], buoys["B"], buoys["C"]

    # gun = the earliest window in which ALL boats cross the start line within 60 s (the mass start —
    # a lone pre-start commitment minutes earlier must NOT set the gun), then verify each boat sails a
    # complete race FROM that gun (round all six marks in order, cross the finish).
    timed = {dev: [((p["t"] - t0_old) // 1000, to_xy(p["lat"], p["lon"], CENTER)) for p in pl]
             for dev, pl in pts.items()}
    gun_sec, spread, _counts = detect_start(timed, A, B, C)
    if gun_sec is None:
        raise SystemExit(f"{rid}: no 60 s all-boats start window on the REVIEWED line — check REVIEWED_COURSES")

    # buoy-touch nudge (reviewer): ease any buoy the fleet brushes out toward CLEAR_TARGET_M, then
    # build the course from the eased positions (≤ NUDGE_CAP_M, so the reviewed geometry is preserved).
    buoys, nudge_notes = nudge_marks_clear(buoys, timed)
    A, B, C = buoys["A"], buoys["B"], buoys["C"]
    buoy_ll = {k: to_latlon(x, y, CENTER) for k, (x, y) in buoys.items()}
    course = expand_template(FIGURE8, buoy_ll)
    wp_xy = [buoys["C"], buoys["D"], buoys["A"], buoys["B"], buoys["C"], buoys["D"]]
    trimmed = {dev: [q for q in tr if q[0] >= gun_sec - 15] for dev, tr in timed.items()}
    complete = {dev: w for dev, w in ((dev, race_window(tr, wp_xy, A, B, C))
                                      for dev, tr in trimmed.items()) if w is not None}
    if not complete:
        raise SystemExit(f"{rid}: no boat completes the REVIEWED course — check REVIEWED_COURSES")
    gun_ms = t0_old + gun_sec * 1000
    # the gun may not be later than the first BOW on the line (reviewer round 3: signals read 3–5 s
    # late because the crossing was timed on the hull CENTRE — half a hull too late, on whole
    # seconds). Snapped DOWN to the 1 Hz sample grid: it keeps "no hull part over before the signal"
    # (floor ≤ the true crossing) AND keeps t0 grid-aligned, so re-keying the frames never quantizes
    # the sample times and a re-bake reproduces the same gun (the +0 s idempotence check).
    bow_ms = first_bow_crossing_ms(pts, A, B, C, t0_old, gun_sec, spread)
    bow_adj = 0.0
    if bow_ms is not None:
        bow_floor = t0_old + ((bow_ms - t0_old) // 1000) * 1000
        if bow_floor < gun_ms:
            bow_adj = (bow_floor - gun_ms) / 1000
            gun_ms = bow_floor
    smid = ((A[0] + B[0]) / 2, (A[1] + B[1]) / 2)
    wind_from = round(_hdg(smid, C), 1)
    wind_from, wind_adj = auto_adjust_wind(pts, wind_from, gun_ms)   # pinching fleet → wrong assumption
    t0 = gun_ms - PRE_S * 1000

    new_frames: dict[str, dict] = {}
    for dev, pl in pts.items():
        for p in pl:
            if p["t"] < t0:
                continue
            sec = (p["t"] - t0) // 1000
            new_frames.setdefault(str(sec), {})[dev] = {
                "lat": round(p["lat"], 7), "lon": round(p["lon"], 7),
                "speed": round(p["speed"], 2), "course": round(p["course"], 1)}
    media = {}
    for dev, lst in (feed.get("media") or {}).items():
        shifted = [{**mm, "tSec": round(mm["tSec"] + (t0_old - t0) / 1000, 3)} for mm in lst]
        media[dev] = [mm for mm in shifted if mm["tSec"] >= 0]

    new_meta = {**meta, "windFromDeg": wind_from, "gunUtcMs": gun_ms, "trackStartUtcMs": t0,
                "trackEndUtcMs": hint_gun + 18 * 60_000,
                # SI: these J/80 team races carry mainsail + jib only — never hoist a kite (reviewer)
                "noDownwindSails": True,
                # GSYS races on GSYS's own hulls — the fleet's paint scheme (j80.js hull livery)
                "hullLivery": "gsys",
                # show the crew (helmsman + two hikers) on every boat (reviewer) — visibility only,
                # NOT the sim's periodic cockpit-camera cutaway
                "crew": True,
                "hullCentreNormalized": True}
    for k in ("preliminaryResult", "suppressWinner", "noCleanFinish"):
        new_meta.pop(k, None)
    new_feed = {
        "schema": feed.get("schema", "vr-broadcast-demo/1"), "source": feed.get("source", "real"),
        "meta": new_meta, "course": course, "teams": teams, "frames": new_frames,
        "video": feed.get("video", []), "media": media, "authoredOverrides": False,
    }
    scored = score_with_tolerance(new_feed)
    res = scored.get("result") or {}
    if (res.get("maxFinishSec") or 0) < MIN_SENSIBLE_SEC:
        raise SystemExit(f"{rid}: reviewed course scored no realistic finish "
                         f"(maxFinishSec={res.get('maxFinishSec')}) — refusing to bake")
    scored["meta"]["trackEndUtcMs"] = t0 + int((res["maxFinishSec"] + 120) * 1000)
    all_ll = [[(p["lat"], p["lon"]) for p in pl] for pl in pts.values()]
    win_s = (scored["meta"]["trackEndUtcMs"] - t0) // 1000
    print(f"  {rid}: REVIEWED course  gun {(gun_ms - hint_gun) / 1000:+.1f}s vs old (bow {bow_adj:+.1f}s)  "
          f"wind {wind_from}° (adj {wind_adj:+.1f}°)  "
          f"nudge[{' '.join(nudge_notes) or '-'}]  complete={len(complete)}/{len(timed)}  cap={scored['meta'].get('roundCaptureM')}  "
          f"clear={min_clearance_m(course, all_ll, CENTER):.1f}m  finishSec={res['maxFinishSec']}  "
          f"window={win_s}s  winner={res.get('winnerName')}")
    return manifest_entry(rid, apply_ai(scored, new_feed))


REAL_COURSE_DATE = "2026-05-22"   # May-22 races use the authoritative V2 course — re-enrich, never auto-fit


def is_auto_fit(entry: dict) -> bool:
    """An auto-fit reconstructed race (marks fitted from tracks) vs a real-V2-course race (May-22)."""
    return not str(entry["data"]["meta"].get("title", "")).startswith(REAL_COURSE_DATE)


def reenrich_race(entry: dict) -> dict:
    """Real-course race: keep the authoritative marks/gun/window, just re-run the engine so the rings,
    gizmo and commentary match the current code. The finishing order is reliable here (real finish line),
    so the result is DEFINITIVE — no preliminary flag."""
    feed = entry["data"]
    feed["meta"].pop("preliminaryResult", None)
    feed["meta"].pop("suppressWinner", None)
    scored = enrich(feed)
    res = scored.get("result") or {}
    win = (scored["meta"]["trackEndUtcMs"] - scored["meta"]["trackStartUtcMs"]) // 1000
    print(f"  {entry['id']}: re-enriched (real course)  winner={res.get('winnerName')}  window={win}s")
    return manifest_entry(entry["id"], apply_ai(scored, feed))


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--rid", nargs="*", default=DEFAULT_RIDS, help="race id(s) to re-process")
    ap.add_argument("--all", action="store_true",
                    help="regenerate EVERY race: auto-fit the reconstructed ones, re-enrich the real-course ones")
    ap.add_argument("--ai", action="store_true",
                    help="run the bounded LLM colour/camera EDITOR — keep/drop + shot (needs ANTHROPIC_API_KEY; .env loaded)")
    ap.add_argument("--author", action="store_true",
                    help="also run the L4 AI AUTHOR — rewrite colour TEXT, safety-validated (reviewer-gated; snapshot)")
    ap.add_argument("--out", default=str(DATA))
    args = ap.parse_args()
    global AI_ON, AUTHOR_ON
    AI_ON = args.ai
    AUTHOR_ON = args.author
    if AI_ON or AUTHOR_ON:
        _load_env()
        _on = ", ".join(x for x, f in (("editor", AI_ON), ("author", AUTHOR_ON)) if f)
        print(f"--ai/--author: bounded LLM {_on} ON" + ("" if os.environ.get("ANTHROPIC_API_KEY")
              else " (no ANTHROPIC_API_KEY found — deterministic no-op)"))
    doc = json.loads(DATA.read_text(encoding="utf-8"))
    by_id = {r["id"]: i for i, r in enumerate(doc["races"])}
    rids = [r["id"] for r in doc["races"]] if args.all else args.rid
    print(f"re-processing {rids} from {DATA.name} ...")
    for rid in rids:
        if rid not in by_id:
            print(f"  {rid}: not in manifest — skipped")
            continue
        entry = doc["races"][by_id[rid]]
        if rid in REVIEWED_COURSES:                       # reviewer-signed course wins outright
            doc["races"][by_id[rid]] = bake_reviewed(entry)
        else:
            doc["races"][by_id[rid]] = refit_race(entry) if is_auto_fit(entry) else reenrich_race(entry)
    Path(args.out).write_text(json.dumps(doc, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
    print(f"wrote {args.out}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
