// ─── REPLAY STATS — race analysis computed from a Bituko replay file (shared download) ───────────
// Fetches the replay HTML, reads its REPLAY_DATA, unpacks only the progress arrays (p), then derives
// lap crossings, classification, positions per lap, lap times, tyre stints, pit stops and weather.
// Load order: before ur2d-race-analysis.jsx. Cached per replay id + upload time.
const _frsCache = {};
const _frsB64 = (s) => Uint8Array.from(atob(s), (ch) => ch.charCodeAt(0));
async function _frsUnpack(s) {
  const stream = new Blob([_frsB64(s)]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
  const d = new Int32Array(await new Response(stream).arrayBuffer()), out = new Int32Array(d.length);
  let v = 0; for (let i = 0; i < d.length; i++) { v += d[i]; out[i] = v; }
  return out;
}
async function _frsReadData(r) { // reuses the theater's download (urReplayText, ur2d-final-replays) — one fetch per replay
  const s = typeof urReplayText === 'function' ? await urReplayText(r) : await fetch(encodeURI(r.file)).then((res) => { if (!res.ok) throw new Error('replay ' + res.status); return res.text(); });
  const key = 'window.REPLAY_DATA = ', i = s.indexOf(key);
  if (i < 0) throw new Error('no REPLAY_DATA');
  let js = s.slice(i + key.length, s.indexOf('\n', i)).trim(); if (js.endsWith(';')) js = js.slice(0, -1);
  const D = JSON.parse(js);
  for (const c of D.cars) {
    if (D.packed === 'delta-deflate') c.p = await _frsUnpack(c.p);
    else if (typeof c.p === 'string') c.p = new Int32Array(_frsB64(c.p).buffer);
  }
  return D;
}

function frsCompute(D) {
  const NONE = -2147483648, HZ = D.hz, LAP = D.lapLen, LAPS = D.laps, N = D.frames;
  const cars = D.cars.map((c) => {
    const p = c.p, best = new Float64Array(N); let m = -1e12, first = -1, move = -1, last = -1;
    for (let k = 0; k < N; k++) { if (p[k] !== NONE) { if (first < 0) first = k; if (move < 0 && p[k] - p[first] > 5) move = k; last = k; if (p[k] > m) m = p[k]; } best[k] = m; }
    const mm = String(c.name).match(/^(.*?)\s*\((\d+)\)\s*$/);
    return { id: String(c.id), name: c.name, base: mm ? mm[1] : c.name, num: mm ? +mm[2] : null, ping: c.ping, best, first, move, last, p0: first >= 0 ? p[first] : -1e9 };
  });
  const start = Math.min(...cars.map((c) => (c.move >= 0 ? c.move : N)));
  const cross = (c, d) => { // sub-frame time the car first reached distance d
    if (c.best[N - 1] < d) return null;
    let lo = 0, hi = N - 1; while (lo < hi) { const mid = (lo + hi) >> 1; if (c.best[mid] >= d) hi = mid; else lo = mid + 1; }
    if (lo === 0) return 0; const a = c.best[lo - 1], b = c.best[lo];
    return lo - 1 + (b > a ? Math.max(0, Math.min(1, (d - a) / (b - a))) : 1);
  };
  for (const c of cars) { c.xf = [start]; for (let n = 1; n <= LAPS + 2; n++) { const t = cross(c, n * LAP); if (t == null) break; c.xf.push(t); } }
  const wcar = cars.filter((c) => c.xf[LAPS] != null).sort((a, b) => a.xf[LAPS] - b.xf[LAPS])[0];
  const wf = wcar ? wcar.xf[LAPS] : Infinity;
  for (const c of cars) { // F1 rule: everyone finishes the next time they cross the line after the winner
    let fl = null; for (let n = 1; n < c.xf.length; n++) if (c.xf[n] >= wf) { fl = n; break; }
    c.finLaps = fl; c.done = fl || c.xf.length - 1; c.dnf = fl == null;
    c.x = c.xf.slice(0, c.done + 1).map((f) => (f - start) / HZ);
    c.lt = c.x.slice(1).map((t, i) => t - c.x[i]);
    c.endLap = c.dnf ? Math.max(0, c.best[Math.min(N - 1, c.last)] / LAP) : c.done;
  }
  cars.sort((a, b) => (a.dnf - b.dnf) || (b.done - a.done) || (a.x[a.done] - b.x[b.done]));
  cars.forEach((c, i) => { c.pos = i + 1; c.time = c.dnf ? null : c.x[c.done]; });
  const lead = cars[0];
  cars.forEach((c, i) => { c.gap = i === 0 ? null : c.dnf ? null : c.done < lead.done ? `+${lead.done - c.done} lap${lead.done - c.done > 1 ? 's' : ''}` : c.time - lead.time; });
  // Positions: lap 0 = grid (closest to the line first), lap n = order of crossing the line
  const grid = [...cars].sort((a, b) => b.p0 - a.p0); grid.forEach((c, i) => { c.posByLap = [i + 1]; });
  for (let n = 1; n <= lead.done; n++) [...cars].filter((c) => c.x[n] != null).sort((a, b) => a.x[n] - b.x[n]).forEach((c, i) => { c.posByLap[n] = i + 1; });
  // Tyres + pits, placed by the car's own distance in laps
  const distAt = (c, k) => Math.max(0, c.best[Math.max(0, Math.min(N - 1, Math.round(k)))] / LAP);
  const pits = D.pits || [], tyres = (D.tyres || []).map((t, i) => [...t, i]).sort((a, b) => a[0] - b[0] || a[3] - b[3]);
  for (const c of cars) {
    const mine = tyres.filter((t) => t[1] === c.id), pre = mine.filter((t) => t[0] <= start + 30);
    const myPits = pits.filter((q) => q[0] === c.id && q[1] > start && distAt(c, q[1]) < c.endLap).map((q) => ({ at: distAt(c, q[1]), dur: (q[2] - q[1]) / HZ, k: q[1], ko: q[2] }));
    c.pits = myPits.map((q) => ({ at: q.at, dur: q.dur, lap: Math.floor(q.at) + 1, tIn: (q.k - start) / HZ, tOut: (q.ko - start) / HZ }));
    let cur = pre.length ? pre[pre.length - 1][2] : null, from = 0, lastPit = null; c.stints = [];
    for (const t of mine.filter((t) => t[0] > start + 30)) {
      const pit = myPits.find((q) => t[0] >= q.k - 30 && t[0] <= q.ko + 30), at = pit ? pit.at : distAt(c, t[0]);
      if (at >= c.endLap) break;
      if (pit && pit === lastPit) { cur = t[2]; continue; } // several picks in one stop → the last one goes on
      if (at - from < 0.05) { cur = t[2]; continue; }
      c.stints.push({ c: cur, from, to: at }); cur = t[2]; from = at; lastPit = pit; // fresh set, even the same compound
    }
    c.stints.push({ c: cur, from, to: c.endLap });
    c.pits.forEach((p) => { p.tyres = c.stints.some((s) => Math.abs(s.to - p.at) < 0.08 && s.to < c.endLap); });
  }
  // Fastest lap (lap 1 excluded — standing start)
  let fastest = null;
  for (const c of cars) c.lt.forEach((t, i) => { if (i > 0 && (!fastest || t < fastest.t)) fastest = { id: c.id, lap: i + 1, t }; });
  // Weather by frame, as the replay draws it: rain gauge (state) + track wetness
  const W = D.weather || [];
  const gauge = (k) => { let e = null; for (const x of W) { if (x[0] > k) break; e = x; } e = e || W[0]; return e ? Math.min(1, Math.max(0, e[1] + e[2] * Math.max(0, k - e[0]))) : 0; };
  const wet = (k) => { if (!W.length) return 0; let i = 0; while (i + 1 < W.length && W[i + 1][0] <= k) i++; const a = W[i], b = W[i + 1]; return !b || k <= a[0] ? a[3] : a[3] + (b[3] - a[3]) * ((k - a[0]) / (b[0] - a[0])); };
  const lapWet = (c, n) => { const a = start + c.x[n - 1] * HZ, b = start + c.x[n] * HZ; let s = 0; for (let j = 0; j <= 6; j++) s += wet(a + ((b - a) * j) / 6); return s / 7; }; // mean wetness over the lap
  // Per-lap detail + per-driver / per-stint summaries. Clean lap = not lap 1, not a pit lap, within 107% of the driver's best.
  const mean = (a) => a.reduce((s, v) => s + v, 0) / a.length;
  for (const c of cars) {
    c.laps = c.lt.map((t, i) => {
      const n = i + 1, m = n - 0.5, si = Math.max(0, c.stints.findIndex((s) => m >= s.from && m < s.to)), s = c.stints[si] || c.stints[c.stints.length - 1];
      return { n, t, c: s && s.c, si, age: s ? Math.max(1, n - Math.round(s.from)) : null, pit: c.pits.some((p) => p.tIn < c.x[n] && p.tOut > c.x[n - 1]), wet: W.length ? lapWet(c, n) : 0 };
    });
    const pool = c.laps.filter((l) => l.n > 1 && !l.pit);
    c.pb = pool.length ? Math.min(...pool.map((l) => l.t)) : null;
    c.laps.forEach((l) => { l.clean = l.n > 1 && !l.pit && c.pb != null && l.t <= c.pb * 1.07; });
    const clean = c.laps.filter((l) => l.clean).map((l) => l.t);
    c.pace = clean.length ? mean(clean) : null;
    c.sd = clean.length > 2 ? Math.sqrt(mean(clean.map((t) => (t - c.pace) ** 2))) : null;
    c.grid = c.posByLap[0]; c.gained = c.dnf ? null : c.grid - c.pos;
    c.led = c.posByLap.filter((p, n) => n > 0 && p === 1).length;
    c.stintStats = c.stints.map((s, i) => {
      const L = c.laps.filter((l) => l.si === i), cl = L.filter((l) => l.clean);
      let deg = null;
      if (cl.length >= 4) { const ax = mean(cl.map((l) => l.age)), ay = mean(cl.map((l) => l.t)), sxx = cl.reduce((q, l) => q + (l.age - ax) ** 2, 0); deg = sxx ? cl.reduce((q, l) => q + (l.age - ax) * (l.t - ay), 0) / sxx : null; }
      return { c: s.c, from: s.from, to: s.to, laps: L.length, first: L.length ? L[0].n : null, last: L.length ? L[L.length - 1].n : null, avg: cl.length ? mean(cl.map((l) => l.t)) : null, best: cl.length ? Math.min(...cl.map((l) => l.t)) : null, deg };
    });
  }
  const rankBy = (k) => { const r = cars.filter((c) => c[k] != null).sort((a, b) => a[k] - b[k]); cars.forEach((c) => { c[k + 'Rank'] = c[k] == null ? null : r.indexOf(c) + 1; }); };
  rankBy('pb'); rankBy('pace');
  // Weather against the leader's laps
  const wx = [];
  const endF = Math.min(N - 1, Math.ceil(wf === Infinity ? N - 1 : wf));
  for (let k = start; k <= endF; k += 30) {
    let ld = -1e12; for (const c of cars) if (c.best[k] > ld) ld = c.best[k];
    const v = gauge(k);
    wx.push({ l: Math.max(0, ld / LAP), wet: wet(k), rain: Math.min(1, Math.max(0, (v - 2 / 3) * 3)), cloud: Math.min(1, Math.max(0, (v - 1 / 3) * 3)) });
  }
  // Wet running. A lap is wet once the track averaged ≥ 5% wetness over it — in these replays slicks already lose
  // 2–5 s a lap there. Clean wet lap = not lap 1, not a pit lap, within 107% of the driver's best WET lap (so a slick
  // runner's slow laps still count). Wet pace = mean of those; ranked with at least half the field's median count.
  const WET = 0.05;
  for (const c of cars) { const pool = c.laps.filter((l) => l.n > 1 && !l.pit && l.wet >= WET), b = pool.length ? Math.min(...pool.map((l) => l.t)) : 0; pool.forEach((l) => { l.wetClean = l.t <= b * 1.07; }); }
  const wetClean = (c) => c.laps.filter((l) => l.wetClean);
  const wetN = cars.map((c) => wetClean(c).length).filter(Boolean).sort((a, b) => a - b);
  let wetS = null;
  if (wetN.length >= 3) {
    const minLaps = Math.max(1, Math.round(wetN[Math.floor(wetN.length / 2)] / 2));
    for (const c of cars) {
      const L = wetClean(c), dry = c.laps.filter((l) => l.clean && l.wet < WET), b = L.length ? L.reduce((a, l) => (l.t < a.t ? l : a)) : null, tyres = {};
      L.forEach((l) => { if (l.c) tyres[l.c] = (tyres[l.c] || 0) + 1; });
      c.wet = { n: L.length, laps: c.laps.filter((l) => l.wet >= WET).length, pace: L.length ? mean(L.map((l) => l.t)) : null, best: b && { t: b.t, lap: b.n, c: b.c, wet: b.wet }, dry: dry.length ? mean(dry.map((l) => l.t)) : null, tyres, rank: null };
    }
    const ranked = cars.filter((c) => c.wet.n >= minLaps).sort((a, b) => a.wet.pace - b.wet.pace);
    ranked.forEach((c, i) => { c.wet.rank = i + 1; });
    const windows = []; let seg = null; // wet stretches, on the leader's lap count
    wx.forEach((w) => { if (w.wet < WET) return; const n = Math.min(LAPS, Math.floor(w.l) + 1); if (seg && n <= seg.to + 1) seg.to = Math.max(seg.to, n); else windows.push(seg = { from: n, to: n }); });
    if (ranked.length >= 3) wetS = { th: WET, minLaps, ranked: ranked.map((c) => c.id), windows, peak: Math.max(...wx.map((w) => w.wet)), rained: wx.some((w) => w.rain > 0) };
  }
  // Kept for the Lap comparison panel (frsLapTrace / frsSectors): each car's best-progress index + its packed x/y.
  const tele = { hz: HZ, lap: LAP, n: N, start, carLen: D.carLen || 44, packed: D.packed || null, src: {},
    mini: D.mini && D.mini.src ? { src: D.mini.src, s: D.mini.s || 1, ox: D.mini.ox || 0, oy: D.mini.oy || 0, w: D.mini.w, h: D.mini.h } : null };
  const raw = {}; D.cars.forEach((c) => { raw[String(c.id)] = c; });
  cars.forEach((c) => {
    const b = new Int32Array(N); for (let k = 0; k < N; k++) b[k] = c.best[k] < -2e9 ? _FRS_NONE : c.best[k];
    tele.src[c.id] = { best: b, x: raw[c.id] && raw[c.id].x, y: raw[c.id] && raw[c.id].y };
    delete c.best; delete c.xf;
  });
  return { hz: HZ, laps: LAPS, track: D.trackName || '', cars, fastest, wx, wet: wetS, raceTime: lead.time, tele };
}

// ─── Lap traces: one lap measured on a shared distance grid (every 25 progress units) ──────────
// Distance = the replay's progress around the lap (the same scale for every car); time = when the car got there;
// speed = GROUND speed from the car's x/y. Progress-based speed spikes to ~570 at fixed spots (the progress line
// runs longer than the cars do through some corners) where x/y says ~500, so progress is only the distance axis.
const _FRS_NONE = -2147483648, _FRS_STEP = 25;
function _frsCross(b, N, d) {
  if (b[N - 1] < d) return null;
  let lo = 0, hi = N - 1; while (lo < hi) { const mid = (lo + hi) >> 1; if (b[mid] >= d) hi = mid; else lo = mid + 1; }
  if (lo === 0) return 0; const a = b[lo - 1], c = b[lo];
  return lo - 1 + (c > a ? Math.max(0, Math.min(1, (d - a) / (c - a))) : 1);
}
function _frsFill(a, N) { // x/y with lost-packet frames (-32768) bridged when the gap is short; NaN otherwise
  const out = new Float32Array(N).fill(NaN); let last = -1;
  for (let k = 0; k < N; k++) {
    const v = a[k]; if (v === -32768 || v === _FRS_NONE) continue;
    if (last >= 0 && k - last > 1 && k - last <= 15) { const va = out[last]; for (let j = last + 1; j < k; j++) out[j] = va + ((v - va) * (j - last)) / (k - last); }
    out[k] = v; last = k;
  }
  return out;
}
function _frsXY(T, id) {
  T.xy = T.xy || {};
  if (!T.xy[id]) T.xy[id] = (async () => {
    const s = T.src[id], dec = async (v) => (T.packed === 'delta-deflate' ? _frsUnpack(v) : typeof v === 'string' ? new Int16Array(_frsB64(v).buffer) : v);
    const X = _frsFill(await dec(s.x), T.n), Y = _frsFill(await dec(s.y), T.n), cum = new Float64Array(T.n);
    for (let k = 1; k < T.n; k++) { const d = Math.hypot(X[k] - X[k - 1], Y[k] - Y[k - 1]); cum[k] = cum[k - 1] + (d < 40 ? d : 0); } // NaN / teleport → 0
    return { X, Y, cum };
  })().catch((e) => { delete T.xy[id]; throw e; });
  return T.xy[id];
}
function frsLapTrace(S, id, n) {
  const T = S && S.tele; if (!T || !T.src[id]) return Promise.resolve(null);
  T.traces = T.traces || {}; const key = id + ':' + n;
  if (!T.traces[key]) T.traces[key] = _frsXY(T, id).then(({ X, Y, cum }) => {
    const b = T.src[id].best, N = T.n, LAP = T.lap, HZ = T.hz, K = Math.ceil(LAP / _FRS_STEP), base = (n - 1) * LAP, f = new Float64Array(K + 1);
    for (let k = 0; k <= K; k++) { const v = _frsCross(b, N, base + Math.min(LAP, k * _FRS_STEP)); if (v == null) return null; f[k] = v; }
    const f0 = n === 1 ? Math.min(T.start, f[0]) : f[0]; // lap 1 timed from the start, like the lap times
    const at = (A, fr) => { const a = Math.floor(fr), c = Math.min(N - 1, a + 1), va = A[a], vc = A[c]; return isFinite(va) && isFinite(vc) ? va + (vc - va) * (fr - a) : isFinite(va) ? va : vc; };
    const t = new Float64Array(K + 1), x = new Float32Array(K + 1), y = new Float32Array(K + 1), v = new Float32Array(K + 1);
    for (let k = 0; k <= K; k++) { t[k] = (f[k] - f0) / HZ; x[k] = at(X, f[k]); y[k] = at(Y, f[k]); }
    for (let k = 0; k <= K; k++) { const a = Math.max(0, k - 3), c = Math.min(K, k + 3), dt = (f[c] - f[a]) / HZ; v[k] = dt > 0 ? (at(cum, f[c]) - at(cum, f[a])) / dt : NaN; }
    return { id, n, K, step: _FRS_STEP, t, x, y, v, time: t[K] };
  }).catch((e) => { delete T.traces[key]; throw e; });
  return T.traces[key];
}
// S1/S2/S3 = equal thirds of the lap, for every lap of every car. Race best + personal bests skip lap 1 and pit laps.
function frsSectors(S) {
  const T = S && S.tele; if (!T) return null; if (T._sec) return T._sec;
  const LAP = T.lap, by = {}, best = [null, null, null], pb = {};
  for (const c of S.cars) {
    const b = T.src[c.id] && T.src[c.id].best; if (!b) continue;
    by[c.id] = c.laps.map((l) => {
      const base = (l.n - 1) * LAP, f = [0, 1, 2, 3].map((j) => _frsCross(b, T.n, base + (j * LAP) / 3));
      if (f.some((v) => v == null)) return null; if (l.n === 1) f[0] = Math.min(T.start, f[0]);
      return [(f[1] - f[0]) / T.hz, (f[2] - f[1]) / T.hz, (f[3] - f[2]) / T.hz];
    });
    pb[c.id] = [null, null, null];
    c.laps.forEach((l, i) => { const s = by[c.id][i]; if (!s || l.n === 1 || l.pit) return;
      s.forEach((v, j) => { if (pb[c.id][j] == null || v < pb[c.id][j]) pb[c.id][j] = v; if (!best[j] || v < best[j].t) best[j] = { t: v, id: c.id, n: l.n }; }); });
  }
  return (T._sec = { by, best, pb });
}
function useLapTraces(S, rows) { // rows: [{ id, n }] → { key, traces: [trace|null] | null, error? }
  const key = S && rows.length ? rows.map((r) => r.id + ':' + r.n).join(',') : '';
  const [st, setSt] = React.useState({ key: '', traces: null });
  React.useEffect(() => {
    if (!key) return; let live = true;
    Promise.all(rows.map((r) => frsLapTrace(S, r.id, r.n))).then((traces) => { if (live) setSt({ key, traces }); })
      .catch((e) => { console.warn('[lap traces]', e); if (live) setSt({ key, traces: null, error: true }); });
    return () => { live = false; };
  }, [S, key]);
  return st.key === key ? st : { key, traces: null };
}

const _frsKey = (r) => r.id + '@' + (r.added || ''); // a re-upload (new upload time) is a new replay
function frsLoad(r) {
  const k = _frsKey(r);
  if (!_frsCache[k]) _frsCache[k] = _frsReadData(r).then(frsCompute).catch((e) => { delete _frsCache[k]; throw e; });
  return _frsCache[k];
}
function useReplayStats(r) {
  const k = r ? _frsKey(r) : null;
  const [st, setSt] = React.useState({ status: r ? 'loading' : 'none', data: null });
  React.useEffect(() => {
    if (!r) { setSt({ status: 'none', data: null }); return; }
    let live = true; setSt({ status: 'loading', data: null });
    frsLoad(r).then((data) => { if (live) setSt({ status: 'ready', data, id: k }); }).catch((e) => { console.warn('[replay stats]', e); if (live) setSt({ status: 'error', data: null }); });
    return () => { live = false; };
  }, [k]);
  // Data is only handed out for the replay it was loaded for — on a league switch the previous replay's
  // cars would otherwise be matched against the new league's sheet for one render.
  return st.data && (!r || st.id !== k) ? { status: r ? 'loading' : 'none', data: null } : st;
}

Object.assign(window, { frsLoad, frsCompute, useReplayStats, frsLapTrace, frsSectors, useLapTraces });
