// Main app — orchestrates state, filtering, layout
const { useState, useEffect, useMemo } = React;

// Derive season from a date string. Football seasons run Aug → May, so:
//   month >= 7 → "{year}-{(year+1) % 100 zero-padded}"
//   month <  7 → "{year-1}-{year % 100 zero-padded}"
// Used to repair malformed seasons ("14/15", "6/7") and fill in nulls.
const seasonFromDate = (date) => {
  if (!date) return null;
  const m = String(date).match(/^(\d{4})-(\d{2})/);
  if (!m) return null;
  const year = parseInt(m[1], 10);
  const month = parseInt(m[2], 10);
  const start = month >= 7 ? year : year - 1;
  const end = (start + 1) % 100;
  return `${start}-${String(end).padStart(2, '0')}`;
};
const isWellFormedSeason = (s) => typeof s === 'string' && /^\d{4}-\d{2}$/.test(s);

// Catch unhandled render errors from any child component so a single
// throw doesn't blank the whole page. Surfaces a Recover button so the
// user can attempt to clear the error state without reloading.
class ErrorBoundary extends React.Component {
  constructor(props) { super(props); this.state = { err: null }; }
  static getDerivedStateFromError(err) { return { err }; }
  componentDidCatch(err, info) {
    console.error('[ErrorBoundary] React error:', err, info);
  }
  render() {
    if (this.state.err) {
      return (
        <div style={{padding:24, color:'#f3f7f4', fontFamily:'monospace', whiteSpace:'pre-wrap'}}>
          <div style={{color:'#cda349', fontSize:18, marginBottom:12}}>Render error</div>
          <div style={{color:'#e74c3c'}}>{String(this.state.err && this.state.err.stack || this.state.err)}</div>
          <button onClick={()=>this.setState({err:null})}
            style={{marginTop:16, padding:'6px 14px', background:'#cda349', color:'#0a0f0d', border:'none', borderRadius:6, cursor:'pointer'}}>
            Recover
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

/* Tournament-stage bracket strip — sits above the pitch as a horizontal
 * row of stage chips: Groups · R32 · R16 · QF · SF · 3rd · Final.
 * Wired into the existing `filters.stage` Set so clicking a chip toggles
 * goals from that stage on/off (the filter check already lives at
 * line ~217 in the per-row predicate). The "current" stage — the one
 * the tournament has reached based on cached goal data — is highlighted
 * in green even when not selected, so the bar always tells the operator
 * where in the tournament we are.
 *
 * STAGE_ALIASES maps the values upstream sources emit into our 7 canonical
 * bucket ids. ESPN currently labels every WC fixture "Group stage" until
 * the brackets settle, so most data lands in the first chip; Sofa knockout
 * labels populate the rest as matches play.
 */
const STAGE_ORDER = [
  { id: 'group', short: 'Groups',   meta: '11–27 Jun', match: /group|tournament|first round/i },
  { id: 'r32',   short: 'R32',      meta: '28 Jun–1 Jul', match: /round of 32|r32/i },
  { id: 'r16',   short: 'R16',      meta: '4–7 Jul', match: /round of 16|r16|last 16/i },
  { id: 'qf',    short: 'QF',       meta: '9–11 Jul', match: /quarter[- ]?final|qf/i },
  { id: 'sf',    short: 'SF',       meta: '14–15 Jul', match: /semi[- ]?final|sf/i },
  { id: 'third', short: '3rd',      meta: '18 Jul', match: /third[- ]?place|3rd/i },
  { id: 'final', short: 'Final',    meta: '19 Jul · NJ', match: /^final$/i },
];

function canonicalStageId(stage) {
  const s = String(stage || '').trim();
  if (!s) return null;
  for (const x of STAGE_ORDER) {
    if (x.match.test(s)) return x.id;
  }
  return null;
}

function StageStrip({ data, filters, setFilters }) {
  // Tally each canonical stage's goal count from the unfiltered data so
  // the chip subtitle shows "live" totals regardless of other filters.
  // The `currentStageId` (highlighted in green) is the most-advanced
  // stage we have ANY data for — surfacing how far the tournament has
  // actually progressed.
  const stageRaw = React.useMemo(() => {
    const m = {};
    (data || []).forEach(d => {
      const id = canonicalStageId(d.stage);
      if (id) m[id] = (m[id] || 0) + 1;
    });
    return m;
  }, [data]);
  const currentStageId = React.useMemo(() => {
    for (let i = STAGE_ORDER.length - 1; i >= 0; i--) {
      if (stageRaw[STAGE_ORDER[i].id]) return STAGE_ORDER[i].id;
    }
    return 'group';
  }, [stageRaw]);

  // Map the canonical id back to the raw `stage` value(s) that appear in
  // data so we can stuff it into the filters.stage Set, which the row
  // predicate compares string-equal against `d.stage`.
  const toggleStage = (id) => {
    setFilters(f => {
      const next = new Set(f.stage);
      // Find every raw stage string in data that maps to this id and
      // toggle them as a group.
      const seenRaw = new Set();
      (data || []).forEach(d => { if (canonicalStageId(d.stage) === id && d.stage) seenRaw.add(d.stage); });
      // If id has no data yet, store the canonical-id token so the
      // filter still expresses intent (no rows match → empty pitch,
      // which is the honest answer when the stage hasn't started).
      if (!seenRaw.size) seenRaw.add(STAGE_ORDER.find(x => x.id === id).short);
      const allOn = [...seenRaw].every(s => next.has(s));
      if (allOn) seenRaw.forEach(s => next.delete(s));
      else seenRaw.forEach(s => next.add(s));
      return { ...f, stage: next };
    });
  };
  const isActive = (id) => {
    const raws = (data || []).filter(d => canonicalStageId(d.stage) === id).map(d => d.stage);
    if (!raws.length) return filters.stage.has(STAGE_ORDER.find(x => x.id === id).short);
    return raws.some(r => filters.stage.has(r));
  };

  return (
    <div className="px-6 pt-4">
      <div className="panel" style={{ padding: '10px 12px', display: 'flex', alignItems: 'center', gap: 8 }}>
        <div className="font-mono" style={{ fontSize: 10, letterSpacing: '.16em', color: 'var(--muted-2)', textTransform: 'uppercase', padding: '0 12px 0 6px', whiteSpace: 'nowrap' }}>
          Stage
        </div>
        <div style={{ display: 'flex', gap: 6, flex: 1 }}>
          {STAGE_ORDER.map(s => {
            const active = isActive(s.id);
            const isCurrent = s.id === currentStageId;
            const count = stageRaw[s.id] || 0;
            let bg = 'transparent', col = 'var(--muted-2)', bd = '1px solid transparent';
            if (active) {
              bg = 'linear-gradient(160deg, var(--gold), #b88c33)';
              col = 'var(--bg-1)';
            } else if (isCurrent) {
              bg = 'rgba(31,111,58,.25)';
              col = '#7fe0a0';
              bd = '1px solid rgba(42,143,74,.5)';
            } else {
              col = '#cdd6e4';
            }
            return (
              <button
                key={s.id}
                onClick={() => toggleStage(s.id)}
                title={`${s.short} · ${count} goal${count === 1 ? '' : 's'}`}
                style={{
                  flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
                  padding: '9px 4px', borderRadius: 10, border: bd, cursor: 'pointer',
                  background: bg, color: col, transition: 'all .15s',
                }}>
                <span style={{ fontSize: 12, fontWeight: 700 }}>
                  {s.short}
                  {count > 0 && <span className="font-mono" style={{ fontSize: 9.5, opacity: .8, marginLeft: 6 }}>· {count}</span>}
                </span>
                <span className="font-mono" style={{ fontSize: 9.5, opacity: .8 }}>{s.meta}</span>
              </button>
            );
          })}
        </div>
      </div>
    </div>
  );
}

/* MomentStrip — three-card "now" header that surfaces the tournament's
 * current pulse: a Live match, the next scheduled match, and the rest
 * of today's slate. Reads tournament.json's `matches[]` array, plus an
 * optional `liveOverlay` map keyed by fixtureKey (`date|home_abbr|away_abbr`,
 * see fixtureKey() below) with the latest status/score. The Fetch-live
 * button writes that overlay when it pulls a fresh scoreboard from ESPN
 * so the strip shows real-time scores without waiting for the next cron
 * rebuild of data.json.
 *
 * The fixtureKey indirection matters: cached tournament.json uses one
 * set of event IDs (from whichever fetcher built it), ESPN scoreboard
 * returns different IDs for the same fixture. Date + abbr + abbr is the
 * stable cross-source join.
 */
function fixtureKey(m) {
  const date = String(m.date || '').slice(0, 10);
  const ha = m.home && m.home.abbreviation;
  const aa = m.away && m.away.abbreviation;
  return `${date}|${ha}|${aa}`;
}

function MomentStrip({ tournament, liveOverlay, todayISO }) {
  const [todayOpen, setTodayOpen] = React.useState(false);

  const matches = React.useMemo(() => {
    if (!tournament || !Array.isArray(tournament.matches)) return [];
    const overlay = liveOverlay || {};
    return tournament.matches.map(m => {
      const ov = overlay[fixtureKey(m)];
      if (!ov) return m;
      // Overlay only patches mutable fields — the rest of the match
      // stays as cached so we don't lose venue/stage/etc.
      return {
        ...m,
        status_state: ov.status_state || m.status_state,
        status_detail: ov.status_detail || m.status_detail,
        home_score: ov.home_score != null ? ov.home_score : m.home_score,
        away_score: ov.away_score != null ? ov.away_score : m.away_score,
        clock: ov.clock || m.clock,
      };
    });
  }, [tournament, liveOverlay]);

  // Live = any match currently in play. Next = nearest future kickoff.
  // Today = remaining matches whose date string == todayISO and aren't
  // the one we already surfaced as Next.
  const live = matches.find(m => m.status_state === 'in');
  const upcoming = matches
    .filter(m => m.status_state === 'pre')
    .sort((a, b) => (a.kickoff_iso || '').localeCompare(b.kickoff_iso || ''));
  const next = upcoming[0];
  const todays = matches.filter(m =>
    String(m.date || '').slice(0, 10) === todayISO &&
    (!next || m.id !== next.id) &&
    (!live || m.id !== live.id)
  );

  if (!matches.length) return null;

  // Format a kickoff time in the BROWSER's local timezone — the audience
  // for this dashboard is geographically diffuse, so don't hardcode UK.
  // Empty locale (undefined) tells Intl to use the user's runtime locale.
  const fmtKickoff = (iso) => {
    if (!iso) return '';
    try {
      const d = new Date(iso);
      if (isNaN(d.getTime())) return '';
      const day = d.toLocaleDateString(undefined, { weekday: 'short' });
      const hm = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
      return `${day} · ${hm}`;
    } catch { return ''; }
  };
  const fmtTimeOnly = (iso) => {
    if (!iso) return '';
    try {
      const d = new Date(iso);
      if (isNaN(d.getTime())) return '';
      return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
    } catch { return ''; }
  };
  const fmtDateLabel = (iso) => {
    if (!iso) return '';
    try {
      const d = new Date(iso + 'T00:00:00');
      if (isNaN(d.getTime())) return iso;
      return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
    } catch { return iso; }
  };
  // Local-timezone abbreviation (e.g. "BST", "EDT") — pulled from Intl so
  // the operator immediately knows which zone the times are in.
  const tzAbbr = (() => {
    try {
      const parts = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' }).formatToParts(new Date());
      const tz = parts.find(p => p.type === 'timeZoneName');
      return tz ? tz.value : '';
    } catch { return ''; }
  })();
  // All matches happening today (in the user's local sense) — drives the
  // Today card's "+N more" subtitle AND the hover popover. Includes the
  // live + next-up fixtures so the popover always shows the full slate.
  const todaysAll = matches
    .filter(m => String(m.date || '').slice(0, 10) === todayISO)
    .sort((a, b) => (a.kickoff_iso || '').localeCompare(b.kickoff_iso || ''));

  // Crest — the nation's flag. Real entrants resolve to a flag image; not-yet-
  // drawn knockout slots (abbr like "1A") fall back to the coloured abbr chip.
  const Crest = ({ team }) => {
    const abbr = team && team.abbreviation;
    const url = (abbr && window.flagUrl) ? window.flagUrl(abbr) : '';
    if (url) {
      return (
        <img src={url} alt={abbr} title={(team && team.name) || abbr}
          style={{
            width: 28, height: 21, objectFit: 'cover', borderRadius: 4,
            border: '1px solid rgba(255,255,255,.18)', flexShrink: 0,
            boxShadow: '0 1px 3px rgba(0,0,0,.35)',
          }}/>
      );
    }
    const color = team && team.color ? `#${team.color}` : 'var(--panel-2)';
    return (
      <span style={{
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
        width: 28, height: 28, borderRadius: 6,
        background: color, color: '#fff',
        fontSize: 10, fontWeight: 800, letterSpacing: '.05em',
        border: '1px solid rgba(255,255,255,.12)', flexShrink: 0,
      }}>{abbr || '—'}</span>
    );
  };

  const Card = ({ label, accent, m, sub, isLive }) => (
    <div className="panel" style={{
      padding: '12px 14px', minWidth: 0, flex: 1,
      borderColor: isLive ? 'rgba(200,16,46,.55)' : 'var(--line)',
      background: isLive
        ? 'linear-gradient(135deg, rgba(200,16,46,.10), rgba(11,30,55,1))'
        : 'var(--panel)',
    }}>
      <div className="flex items-center justify-between" style={{ marginBottom: 8 }}>
        <span className="font-mono" style={{
          fontSize: 9.5, letterSpacing: '.14em', textTransform: 'uppercase',
          color: accent, fontWeight: 700, display: 'inline-flex', alignItems: 'center', gap: 6,
        }}>
          {isLive && (
            <span style={{
              width: 7, height: 7, borderRadius: '50%', background: 'var(--wc-red)',
              animation: 'wcLivePulse 1.6s ease-in-out infinite',
            }}/>
          )}
          {label}
        </span>
        {sub && <span className="font-mono" style={{ fontSize: 10, color: 'var(--muted-2)' }}>{sub}</span>}
      </div>
      {m ? (
        <div className="flex items-center" style={{ gap: 10, minWidth: 0 }}>
          <Crest team={m.home}/>
          <span style={{ fontSize: 13, color: 'var(--ink)', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
            {m.home && m.home.short_name}
          </span>
          <span className="font-mono num-tabular" style={{
            fontSize: 16, fontWeight: 700,
            color: isLive ? '#ff8d99' : 'var(--ink)',
            padding: '0 6px',
          }}>
            {m.home_score != null ? m.home_score : '—'}
            <span style={{ color: 'var(--muted-2)', margin: '0 4px' }}>v</span>
            {m.away_score != null ? m.away_score : '—'}
          </span>
          <span style={{ fontSize: 13, color: 'var(--ink)', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
            {m.away && m.away.short_name}
          </span>
          <Crest team={m.away}/>
        </div>
      ) : (
        <div style={{ fontSize: 12, color: 'var(--muted-2)', padding: '6px 0' }}>
          No fixture
        </div>
      )}
      {m && (
        <div className="font-mono" style={{ fontSize: 10.5, color: 'var(--muted)', marginTop: 6, display: 'flex', gap: 10, flexWrap: 'wrap' }}>
          <span>{m.venue || m.stage || ''}</span>
        </div>
      )}
    </div>
  );

  // Today-card popover row — compact summary of one fixture inside the
  // hover panel. Score column hides until ESPN/cron data confirms it's
  // a real number (avoids "— v —" noise on a future pre-match row).
  const PopoverRow = ({ m }) => {
    const hasScore = m.home_score != null && m.away_score != null;
    const stateLabel = m.status_state === 'in'
      ? `LIVE${m.status_detail ? ' · ' + m.status_detail : ''}`
      : m.status_state === 'post'
        ? (m.status_detail || 'FT')
        : fmtTimeOnly(m.kickoff_iso);
    const stateColor = m.status_state === 'in' ? '#ff8d99'
      : m.status_state === 'post' ? 'var(--muted)'
      : 'var(--gold-2)';
    return (
      <div className="flex items-center" style={{
        gap: 10, padding: '8px 4px', borderBottom: '1px solid var(--line)',
      }}>
        <Crest team={m.home}/>
        <span style={{ fontSize: 12.5, color: 'var(--ink)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
          {(m.home && m.home.short_name) || '—'}
        </span>
        <span className="font-mono num-tabular" style={{
          fontSize: 13, fontWeight: 700, color: hasScore ? 'var(--ink)' : 'var(--muted-2)',
          minWidth: 56, textAlign: 'center',
        }}>
          {hasScore
            ? `${m.home_score} – ${m.away_score}`
            : 'vs'}
        </span>
        <span style={{ fontSize: 12.5, color: 'var(--ink)', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'right' }}>
          {(m.away && m.away.short_name) || '—'}
        </span>
        <Crest team={m.away}/>
        <span className="font-mono" style={{ fontSize: 10.5, color: stateColor, minWidth: 86, textAlign: 'right', fontWeight: 600 }}>
          {stateLabel}
        </span>
      </div>
    );
  };

  return (
    <div className="px-6 pt-4">
      <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
        <Card
          label={live ? `LIVE · ${live.status_detail || ''}`.trim() : 'NO MATCH LIVE'}
          accent={live ? '#ff8d99' : 'var(--muted-2)'}
          m={live}
          sub={live && live.stage}
          isLive={!!live}
        />
        <Card
          label="NEXT UP"
          accent="var(--gold-2)"
          m={next}
          sub={next ? `${fmtKickoff(next.kickoff_iso)}${tzAbbr ? ' · ' + tzAbbr : ''}` : ''}
        />
        {/* Today card: hover reveals the full slate. Wrapped in a relative
            div so the popover can absolutely-position above the strip
            without escaping the dashboard scroll region. */}
        <div
          style={{ position: 'relative', flex: 1, minWidth: 0 }}
          onMouseEnter={() => setTodayOpen(true)}
          onMouseLeave={() => setTodayOpen(false)}
        >
          <Card
            label={`TODAY · ${fmtDateLabel(todayISO)}`}
            accent="#7fc7ff"
            m={todays[0] || null}
            sub={
              todaysAll.length === 0
                ? 'No fixtures'
                : todaysAll.length > 1
                  ? `${todaysAll.length} fixtures · hover for all`
                  : (todays[0] ? `${fmtKickoff(todays[0].kickoff_iso)}${tzAbbr ? ' · ' + tzAbbr : ''}` : '')
            }
          />
          {todayOpen && todaysAll.length > 0 && (
            <div className="wc-toast" style={{
              position: 'absolute', top: 'calc(100% + 6px)', right: 0,
              minWidth: 420, maxWidth: 'min(560px, 92vw)', zIndex: 60,
              padding: '10px 14px', borderRadius: 12,
              background: '#0d1a2e', border: '1px solid var(--line-2)',
              boxShadow: '0 18px 44px rgba(0,0,0,0.55)',
            }}>
              <div className="font-mono flex items-center justify-between" style={{
                fontSize: 9.5, letterSpacing: '.14em', textTransform: 'uppercase',
                color: 'var(--muted)', marginBottom: 6,
              }}>
                <span>Today's slate · {todaysAll.length} fixtures</span>
                {tzAbbr && <span style={{ color: 'var(--muted-2)' }}>{tzAbbr}</span>}
              </div>
              {todaysAll.map(m => (
                <PopoverRow key={fixtureKey(m)} m={m}/>
              ))}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

// Relative "updated N ago" for the passive freshness pill (data is refreshed
// by the cron → R2, not by a manual button).
function fmtAgo(d) {
  if (!d) return '—';
  const s = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000));
  if (s < 45) return 'just now';
  const m = Math.floor(s / 60);
  if (m < 60) return `${m}m ago`;
  const h = Math.floor(m / 60);
  return m % 60 ? `${h}h ${m % 60}m ago` : `${h}h ago`;
}

function App() {
  const [data, setData] = useState(null);
  const [tournament, setTournament] = useState(null);
  // Live overlay = patches the matches[] array from a direct in-browser
  // ESPN pull. Keys are match ids (string), values are { status_state,
  // status_detail, home_score, away_score, clock }. Wiped on every
  // refreshKey bump so a stale overlay doesn't outlive a fresh cron
  // rebuild of tournament.json.
  const [liveOverlay, setLiveOverlay] = useState({});
  const [refreshNote, setRefreshNote] = useState(null);
  // Bumping `refreshKey` re-fires the data-load useEffect.  The
  // "Fetch live data" button increments it; the useEffect's dep
  // array picks the change up and re-runs the parallel fetch chain.
  const [refreshKey, setRefreshKey] = useState(0);
  const [refreshing, setRefreshing] = useState(false);
  const [filters, setFilters] = useState({
    bodyPart: new Set(),
    situation: new Set(), finish: new Set(), precision: new Set(),
    minMin: 0, maxMin: 120, club: 'All',
    minDist: 0, maxDist: 999,
    year: null, search: '',
    // Tournament filters: nation pills, stage pills, single-scorer (Golden
    // Boot click), single-day (stage-progression click), scorer position.
    nation: new Set(), stage: new Set(), scorer: null, date: null,
    position: new Set(),
    // Specific match(es) — Matches card click. Set of match ids.
    match: new Set(),
    // xG band filter (xG card bar click). Default 0..1 = no filter.
    minXg: 0, maxXg: 1,
    // "Goals in match" — Set of integers. Chip 6 means "6 or more".
    // Empty set = no filter.
    goalsInMatch: new Set(),
    // Opponent filter — Set of canonical opponent strings. Card-click only
    // (no sidebar section); empty Set = no filter.
    opponent: new Set(),
    // Rectangular zone preset on the distance card. One of:
    //   null          — no filter
    //   '6yd'         — inside the 6-yard box  (x∈[114,120], y∈[30,50])
    //   '18yd'        — inside the 18-yard box (x∈[102,120], y∈[18,62])
    //   'outside18yd' — anywhere outside the 18-yard box
    zone: null,
  });
  const [color, setColor] = useState('body');
  const [picked, setPicked] = useState(null);
  const [pickedStack, setPickedStack] = useState(null);
  const [hover, setHover] = useState(null);
  const [tableOpen, setTableOpen] = useState(false);

  const [dataMtime, setDataMtime] = React.useState(null);

  useEffect(() => {
    // Load every config file in parallel with the data. All but data.json
    // are optional — if a file 404s or fails to parse, we fall through to
    // the empty defaults set in util.jsx so the dashboard still renders.
    // Re-runs when `refreshKey` increments (the "Fetch live data" button
    // wires that bump in below) so the operator can pull a fresh snapshot
    // without reloading the page.
    setRefreshing(true);
    const fetchJSON = (url) =>
      fetch(url).then(r => (r.ok ? r.json() : null)).catch(() => null);

    // Live goal data comes from WC_DATA_BASE (R2 in prod, same-origin in dev);
    // the static config files always ship with the Pages shell.
    const DB = (window.WC_DATA_BASE || '').replace(/\/$/, '');
    Promise.all([
      // Cache-bust data.json so a CDN can't serve a stale copy after a cron
      // rebuild (Cloudflare Pages caches static assets aggressively).
      fetch(`${DB}/data.json?v=${Date.now()}`.replace(/^\//, '')).then(async r => ({
        lm: r.headers.get('Last-Modified'),
        rows: await r.json(),
      })),
      fetchJSON(`${DB}/tournament.json?v=${Date.now()}`.replace(/^\//, '')),
    ]).then(([dataResp, tour]) => {
      // HTTP servers (incl. `python -m http.server`) send a Last-Modified
      // header for static files. We treat it as "data.json was rebuilt at
      // this time" so the hero pill reflects when the dashboard data was
      // refreshed, not when the most recent goal was scored.
      if (dataResp.lm) {
        const d = new Date(dataResp.lm);
        if (!isNaN(d.getTime())) setDataMtime(d);
      }

      // Expose the tournament config on window so the colour/order helpers in
      // util.jsx (CLUB_COLORS, CLUB_ORDER) see real values
      // on first render; also kept in React state so MomentStrip re-renders.
      //
      // liveOverlay is deliberately NOT wiped here: an ESPN pull can resolve
      // concurrently with the static refetch, and overlay rows are keyed by
      // fixtureKey so they layer cleanly on top of whatever tournament.json says.
      if (tour) {
        setTournament(tour);
        window.TOURNAMENT_CONFIG = tour;
        window.CLUB_COLORS = tour.clubs || {};
        window.CLUB_ORDER = tour.club_order || [];
      }

      const processed = dataResp.rows.map(g => {
        const out = {...g};
        // Roll tap-ins into normal shots so the dashboard treats them as one.
        if (out.finish_style === 'tap_in') out.finish_style = 'normal_shot';
        // Repair malformed (e.g. "14/15", "6/7") or missing season strings.
        if (!isWellFormedSeason(out.season)) {
          const derived = seasonFromDate(out.date);
          if (derived) out.season = derived;
        }
        return out;
      });
      setData(processed);
      setRefreshing(false);
    }).catch(() => setRefreshing(false));
  }, [refreshKey]);

  // Auto-refresh the static data every 60s so new goals, scores and live
  // status flow in without a manual reload — the FotMob fetcher / cron writes
  // the files on disk, this picks them up (and un-flashes finished matches).
  useEffect(() => {
    const id = setInterval(() => setRefreshKey(k => k + 1), 60000);
    return () => clearInterval(id);
  }, []);

  // Fully-automatic live pipeline. Poll ESPN every 40s (scores update within
  // seconds of a goal) and pull FotMob coords (POST /api/refresh-tournament)
  // whenever there's a live match AND one of:
  //   • first poll with live matches  → reconcile whatever's already on the
  //     board when the tab opens (the original bug: goals scored before load
  //     were never pulled because nothing "increased"),
  //   • the goal total ticks up        → a fresh goal,
  //   • >100s since the last pull       → safety net for FotMob's shotmap lag
  //     and any goal the increase-check missed.
  // One pull at a time; it re-reads every live shotmap so it catches them all.
  // ESPN gives us the *when*, FotMob the *where*.
  const lastGoalTotalRef = React.useRef(null);
  const fotmobPendingRef = React.useRef(false);
  const lastFotmobAtRef = React.useRef(0);
  useEffect(() => {
    let cancelled = false;
    const ymd = (d) => `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
    const triggerFotmob = async () => {
      if (fotmobPendingRef.current) return;
      fotmobPendingRef.current = true;
      lastFotmobAtRef.current = Date.now();
      try { await fetch('/api/refresh-tournament', { method: 'POST' }); } catch (_) {}
      if (!cancelled) setRefreshKey(k => k + 1);
      fotmobPendingRef.current = false;
    };
    const poll = async () => {
      try {
        const now = new Date();
        const yest = new Date(now.getTime() - 24 * 3600 * 1000);
        const tom = new Date(now.getTime() + 24 * 3600 * 1000);
        const url = `https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard?dates=${ymd(yest)}-${ymd(tom)}`;
        const r = await fetch(url);
        if (!r.ok || cancelled) return;
        const events = ((await r.json()) || {}).events || [];
        const overlay = {};
        let total = 0, liveCount = 0;
        events.forEach(e => {
          const comps = (e.competitions || [])[0] || {};
          const status = (e.status || {}).type || {};
          const cs = comps.competitors || [];
          const home = cs.find(c => c.homeAway === 'home') || {};
          const away = cs.find(c => c.homeAway === 'away') || {};
          const date = (e.date || '').slice(0, 10);
          const ha = (home.team && home.team.abbreviation) || '';
          const aa = (away.team && away.team.abbreviation) || '';
          overlay[`${date}|${ha}|${aa}`] = {
            status_state: status.state || null,
            status_detail: status.shortDetail || status.detail || '',
            home_score: home.score != null ? home.score : null,
            away_score: away.score != null ? away.score : null,
            clock: (e.status && e.status.displayClock) || '',
          };
          const hs = parseInt(home.score, 10), as = parseInt(away.score, 10);
          if (Number.isFinite(hs)) total += hs;
          if (Number.isFinite(as)) total += as;
          if (status.state === 'in') liveCount++;
        });
        if (cancelled) return;
        setLiveOverlay(prev => ({ ...prev, ...overlay }));
        const prev = lastGoalTotalRef.current;
        const firstPoll = prev === null;
        lastGoalTotalRef.current = total;
        if (liveCount > 0) {
          const stale = Date.now() - lastFotmobAtRef.current > 45000;
          if (firstPoll || total > prev || stale) triggerFotmob();
        }
      } catch (_) { /* best-effort */ }
    };
    poll();
    const id = setInterval(poll, 40000);
    return () => { cancelled = true; clearInterval(id); };
  }, []);

  // "Today" in UK time — matches the editorial framing of the dashboard
  // "Today" in the user's local timezone — kickoffs that crossed local
  // midnight in the user's zone go on the previous day's card. Computed
  // each render so a session left open across midnight rolls over
  // without a reload.
  const todayISO = (() => {
    const d = new Date();
    return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
  })();


  // Match-identity key used to group goals by single match
  const matchKey = (d) => d.date ? `${d.date}|${d.team}|${d.opponent}` : null;

  // Per-match goal count (matchKey → number of goals he scored in that match).
  // Used by the "Goals in match" chip filter and exposed to the sidebar so it
  // can show the available chips.
  const { goalsPerMatch, goalsInMatchStats } = useMemo(() => {
    if (!data) return { goalsPerMatch: new Map(), goalsInMatchStats: {} };
    const counts = new Map();
    for (const d of data) {
      const k = matchKey(d);
      if (!k) continue;
      counts.set(k, (counts.get(k) || 0) + 1);
    }
    // Aggregate "matches per N goals" so the sidebar can show counts on each chip.
    // 6+ collapses everything ≥ 6 into one bucket.
    const stats = {};
    for (const n of counts.values()) {
      const bucket = n >= 6 ? 6 : n;
      stats[bucket] = (stats[bucket] || 0) + 1;
    }
    return { goalsPerMatch: counts, goalsInMatchStats: stats };
  }, [data]);

  // Single predicate that decides whether a goal passes the current filters,
  // with an optional `skipKey` that skips one filter section (used to compute
  // facet-exclude-self counts: e.g. the body-part counts in the sidebar should
  // narrow when the user picks one club, but ticking "right_foot" shouldn't
  // zero out the other body-part rows).
  const passes = useMemo(() => (d, skipKey) => {
    if (skipKey !== 'club' && filters.club !== 'All' && d.team !== filters.club) return false;
    if (skipKey !== 'nation' && filters.nation && filters.nation.size && !filters.nation.has(d.team)) return false;
    if (skipKey !== 'stage' && filters.stage && filters.stage.size && !filters.stage.has(d.stage)) return false;
    if (skipKey !== 'scorer' && filters.scorer != null && String(d.scorer_id || d.scorer) !== String(filters.scorer)) return false;
    if (skipKey !== 'date' && filters.date && String(d.date).slice(0, 10) !== filters.date) return false;
    if (skipKey !== 'position' && filters.position && filters.position.size && !filters.position.has(d.scorer_role)) return false;
    if (skipKey !== 'match' && filters.match && filters.match.size && !filters.match.has(String(d.match_key || '').split('-')[0])) return false;
    if (skipKey !== 'xg' && (filters.minXg > 0 || filters.maxXg < 1)) {
      if (d.xg == null || d.xg < filters.minXg || d.xg > filters.maxXg) return false;
    }
    if (skipKey !== 'search' && filters.search && filters.search.trim()) {
      const q = filters.search.trim().toLowerCase();
      const isNumeric = /^\d+$/.test(q);
      if (isNumeric) {
        const qn = parseInt(q, 10);
        const gn = d.goal_number;
        if (gn == null || Number(gn) !== qn) return false;
      } else {
        const hay = [
          d.scorer, d.team, d.opponent, d.team_abbr, d.opponent_abbr, d.competition,
        ].map(v => (v || '').toLowerCase());
        if (!hay.some(v => v.includes(q))) return false;
      }
    }
    if (skipKey !== 'opponent' && filters.opponent && filters.opponent.size && !filters.opponent.has(d.opponent)) return false;
    if (skipKey !== 'bodyPart' && filters.bodyPart.size && !filters.bodyPart.has(d.body_part)) return false;
    if (skipKey !== 'situation' && filters.situation.size && !filters.situation.has(d.situation)) return false;
    if (skipKey !== 'finish' && filters.finish.size && !filters.finish.has(d.finish_style)) return false;
    if (skipKey !== 'precision' && filters.precision.size && !filters.precision.has(d.location_precision)) return false;
    if (skipKey !== 'minute') {
      if (d.minute != null) {
        if (d.minute < filters.minMin || d.minute > filters.maxMin) return false;
      } else if (filters.minMin > 0 || filters.maxMin < 120) {
        return false;
      }
    }
    if (skipKey !== 'distance' && (filters.minDist > 0 || filters.maxDist < 999)) {
      const dist = distFromGoal(d.x, d.y);
      if (dist == null || dist < filters.minDist || dist > filters.maxDist) return false;
    }
    if (skipKey !== 'zone' && filters.zone) {
      if (d.x == null || d.y == null) return false;
      const inBox18 = d.x >= 102 && d.x <= 120 && d.y >= 18 && d.y <= 62;
      const inBox6 = d.x >= 114 && d.x <= 120 && d.y >= 30 && d.y <= 50;
      if (filters.zone === '6yd' && !inBox6) return false;
      if (filters.zone === '18yd' && !inBox18) return false;
      if (filters.zone === 'outside18yd' && inBox18) return false;
    }
    if (skipKey !== 'year' && filters.year && (!d.date || !d.date.startsWith(filters.year))) return false;
    if (skipKey !== 'goalsInMatch' && filters.goalsInMatch && filters.goalsInMatch.size > 0) {
      const k = matchKey(d);
      const n = k ? (goalsPerMatch.get(k) || 0) : 0;
      const bucket = n >= 6 ? 6 : n;
      if (!filters.goalsInMatch.has(bucket)) return false;
    }
    return true;
  }, [filters, goalsPerMatch]);

  // Match ids that are CURRENTLY in-progress — derived from the live match
  // status (tournament.json + the ESPN liveOverlay), not a flag baked into the
  // goal at fetch time. This is what drives the "live" flashing, so a goal
  // stops flashing the instant its match reads finished, even before the
  // FotMob fetcher re-runs and rebuilds data.json.
  const liveMatchIds = useMemo(() => {
    const ids = new Set();
    const ms = (tournament && tournament.matches) || [];
    const overlay = liveOverlay || {};
    for (const m of ms) {
      const ov = overlay[fixtureKey(m)];
      const status = (ov && ov.status_state) || m.status_state;
      if (status === 'in') ids.add(String(m.id));
    }
    return ids;
  }, [tournament, liveOverlay]);
  const _withLive = (d) => ({ ...d, live: liveMatchIds.has(String(d.match_key || '').split('-')[0]) });

  const filtered = useMemo(() => {
    if (!data) return [];
    return data.filter(d => passes(d, null)).map(_withLive);
  }, [data, passes, liveMatchIds]);

  // Same as `filtered` but ignoring the match filter — feeds the Matches card
  // so every matching fixture stays visible/toggleable even after one is
  // selected (exclude-self facet, like the sidebar counts).
  const filteredNoMatch = useMemo(() => {
    if (!data) return [];
    return data.filter(d => passes(d, 'match')).map(_withLive);
  }, [data, passes, liveMatchIds]);

  // Sidebar counts use exclude-self semantics — picking a section's value
  // narrows what the *other* sections show, but a section's own checkboxes
  // stay listed with the count they'd have if that section's filter were
  // cleared. So ticking "right_foot" doesn't zero out "left_foot"'s count.
  const counts = useMemo(() => {
    if (!data) return {
      shown: 0, total: 0,
      byBody:{}, bySit:{}, byFinish:{}, byPrec:{},
      byNation:{}, byStage:{},
    };
    const c = {
      shown: filtered.length,
      total: data.length,
      byBody:{}, bySit:{}, byFinish:{}, byPrec:{},
      byNation:{}, byStage:{},
    };
    for (const d of data) {
      if (passes(d, 'bodyPart')) c.byBody[d.body_part] = (c.byBody[d.body_part]||0)+1;
      if (passes(d, 'situation')) c.bySit[d.situation] = (c.bySit[d.situation]||0)+1;
      if (passes(d, 'finish')) c.byFinish[d.finish_style] = (c.byFinish[d.finish_style]||0)+1;
      if (passes(d, 'precision')) c.byPrec[d.location_precision] = (c.byPrec[d.location_precision]||0)+1;
      if (d.team && passes(d, 'nation')) c.byNation[d.team] = (c.byNation[d.team]||0)+1;
      if (d.stage && passes(d, 'stage')) c.byStage[d.stage] = (c.byStage[d.stage]||0)+1;
    }
    return c;
  }, [data, filtered, passes]);

  // Per-N-goals-in-match chip counts, also with exclude-self semantics so
  // ticking 3 doesn't zero out the 2 / 4 / 5 chips.
  const goalsInMatchStatsLive = useMemo(() => {
    const stats = {};
    if (!data) return stats;
    const matchGoals = new Map();
    for (const d of data) {
      if (!passes(d, 'goalsInMatch')) continue;
      const k = matchKey(d);
      if (!k) continue;
      matchGoals.set(k, (matchGoals.get(k) || 0) + 1);
    }
    for (const n of matchGoals.values()) {
      const bucket = n >= 6 ? 6 : n;
      stats[bucket] = (stats[bucket] || 0) + 1;
    }
    return stats;
  }, [data, passes]);

  if (!data) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="text-center">
          <div className="font-serif text-3xl gold-shimmer">Loading goals…</div>
        </div>
      </div>
    );
  }

  // Hero "UPDATED" pill = when data.json was last rebuilt (from HTTP
  // Last-Modified). Falls back to today if the header isn't available
  // (e.g. served from a file:// URL).
  const updatedDate = dataMtime || new Date();
  const updatedISO = `${updatedDate.getFullYear()}-${String(updatedDate.getMonth() + 1).padStart(2, '0')}-${String(updatedDate.getDate()).padStart(2, '0')}`;

  // Tournament hero stats — derived from the data + tournament.json meta so
  // no per-edition numbers are hardcoded.
  const stats = (() => {
    // Use the reactive `tournament` state (falls back to the global) so the
    // hero recomputes whenever fresh data lands, and the ESPN `liveOverlay`
    // so "matches played" flips the instant a match goes final — not only
    // after the next FotMob rebuild.
    const meta = tournament || window.TOURNAMENT_CONFIG || {};
    const totalGoals = data.length;

    // Matches played = effective-status 'post', overlay-aware (live).
    const overlay = liveOverlay || {};
    let matchesPlayed = 0;
    for (const m of (meta.matches || [])) {
      const ov = overlay[fixtureKey(m)];
      const status = (ov && ov.status_state) || m.status_state;
      if (status === 'post') matchesPlayed++;
    }

    const nationsScored = new Set();
    const byScorer = new Map();
    for (const d of data) {
      if (d.team) nationsScored.add(d.team);
      const id = d.own_goal ? '' : String(d.scorer_id || d.scorer || '');
      if (id) {
        const r = byScorer.get(id) || { scorer: d.scorer, team: d.team, team_abbr: d.team_abbr, goals: 0 };
        if (!r.team_abbr && d.team_abbr) r.team_abbr = d.team_abbr;
        r.goals++;
        byScorer.set(id, r);
      }
    }
    const leaders = [...byScorer.values()].sort((a, b) => b.goals - a.goals);
    const top = leaders[0];

    const fmt = (iso) => iso ? `${iso.slice(8,10)} ${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][parseInt(iso.slice(5,7),10)-1]}` : '';
    const dateRange = meta.date_from && meta.date_to ? `${fmt(meta.date_from)} → ${fmt(meta.date_to)} 2026` : '';

    // Derive the top scorer's nation abbreviation from tournament.matches[]
    // so Hero can render a flag. We hold a name→abbr map so re-rendering
    // doesn't have to walk all matches again.
    const nameToAbbr = {};
    (meta.matches || []).forEach(m => {
      if (m.home && m.home.name && m.home.abbreviation) nameToAbbr[m.home.name] = m.home.abbreviation;
      if (m.away && m.away.name && m.away.abbreviation) nameToAbbr[m.away.name] = m.away.abbreviation;
      if (m.home && m.home.short_name && m.home.abbreviation) nameToAbbr[m.home.short_name] = m.home.abbreviation;
      if (m.away && m.away.short_name && m.away.abbreviation) nameToAbbr[m.away.short_name] = m.away.abbreviation;
    });
    const topScorerAbbr = top ? (top.team_abbr || nameToAbbr[top.team] || '') : '';

    return {
      total: totalGoals,
      withCoord: data.filter(d => d.x != null).length,
      latestDate: updatedISO,
      nationsScored: nationsScored.size,
      topScorer: top ? top.scorer : '—',
      topScorerTeam: top ? top.team : '',
      topScorerAbbr,
      topScorerGoals: top ? top.goals : 0,
      matchesPlayed,
      totalMatches: meta.total_matches || (meta.matches || []).length || 0,
      hosts: (meta.hosts || []).join(' · '),
      dateRange,
    };
  })();

  // Goals we know about (master row exists, dashboard has metadata) but
  // whose (x, y) hasn't been published by any event-data source yet.
  // Surfaced as a banner so the user knows the dashboard isn't ignoring
  // them. `coord_status_reason` partitions them into "recent" (data
  // probably coming in the next refresh) vs "unsupported_era"
  // (pre-2009; no coord source will fill these without manual work).
  const pendingGoals = data.filter(d => d.coord_status === 'pending' || d.x == null);
  const pendingRecent = pendingGoals.filter(d => d.coord_status_reason === 'recent');
  const pendingEra = pendingGoals.filter(d => d.coord_status_reason === 'unsupported_era');

  const onPickSeason = (year) => {
    setFilters(f => ({...f, year: f.year === year ? null : year }));
  };
  const onPickScorer = (id) => {
    setFilters(f => ({...f, scorer: String(f.scorer) === String(id) ? null : id }));
  };
  const onPickDate = (day) => {
    setFilters(f => ({...f, date: f.date === day ? null : day }));
  };

  // Name for the active scorer filter, for the banner above the pitch.
  const activeScorerName = filters.scorer != null
    ? (data.find(d => String(d.scorer_id || d.scorer) === String(filters.scorer)) || {}).scorer
    : null;

  return (
    <div className="min-h-screen">
      <Hero stats={stats} freshness={dataMtime ? fmtAgo(dataMtime) : null}/>

      <MomentStrip tournament={tournament} liveOverlay={liveOverlay} todayISO={todayISO}/>

      {pendingGoals.length > 0 && (
        <div className="px-6 pt-4">
          <div
            className="panel px-5 py-3 flex items-start gap-3"
            style={{
              borderColor: 'rgba(205,163,73,0.55)',
              background: 'linear-gradient(90deg, rgba(205,163,73,0.10), rgba(205,163,73,0.03))',
            }}
          >
            <div
              className="text-xs font-mono"
              style={{
                color: COLORS.gold2,
                background: 'rgba(205,163,73,0.18)',
                border: '1px solid rgba(205,163,73,0.5)',
                padding: '3px 9px',
                borderRadius: '99px',
                whiteSpace: 'nowrap',
                letterSpacing: '0.06em',
                textTransform: 'uppercase',
                fontWeight: 700,
              }}
            >
              Coords loading
            </div>
            <div className="flex-1 min-w-0">
              <div className="text-sm" style={{color: COLORS.ink}}>
                <span className="font-serif text-base" style={{color: COLORS.gold2, fontWeight: 700}}>
                  {pendingGoals.length}
                </span>{' '}
                {pendingGoals.length === 1 ? 'goal is' : 'goals are'} waiting for event-data coordinates.
                {pendingEra.length > 0 && (
                  <>
                    {' '}
                    <span style={{color: COLORS.muted2}}>
                      ({pendingEra.length} {pendingEra.length === 1 ? 'is' : 'are'} from a pre-2009 era no configured source covers
                      — those need an additional source enabled or manual annotation.)
                    </span>
                  </>
                )}
                {pendingRecent.length > 0 && pendingEra.length === 0 && (
                  <> The goal{pendingGoals.length === 1 ? '' : 's'} won't appear on the pitch until the source publishes the location.</>
                )}
              </div>
              <div className="text-xs mt-1 font-mono" style={{color: COLORS.muted}}>
                {pendingGoals.slice(0, 4).map(g => (
                  <span key={g.goal_number} className="mr-3">
                    #{g.goal_number || '—'} vs {g.opponent || '—'}{g.minute ? ` · ${g.minute}'` : ''}{g.date ? ` · ${String(g.date).slice(0, 10)}` : ''}
                  </span>
                ))}
                {pendingGoals.length > 4 && <span style={{color: COLORS.muted2}}>+{pendingGoals.length - 4} more</span>}
              </div>
            </div>
          </div>
        </div>
      )}

      <div className="px-6 pt-6 flex gap-4 items-start">
        <Sidebar
          data={data}
          filters={filters}
          setFilters={setFilters}
          counts={counts}
          goalsInMatchStats={goalsInMatchStatsLive}
        />

        <main className="flex-1 min-w-0 pb-32">
          {filters.year && (
            <div className="mb-3 panel px-4 py-2.5 flex items-center justify-between">
              <div className="text-xs flex items-center gap-2">
                <span style={{color: COLORS.muted}}>Filtered to year</span>
                <span className="font-serif text-xl" style={{color: COLORS.gold2}}>{filters.year}</span>
                <span className="text-[11px] font-mono" style={{color: COLORS.muted2}}>{filtered.length} goals</span>
              </div>
              <button onClick={()=>setFilters(f => ({...f, year: null}))} className="text-xs" style={{color: COLORS.muted}}>Clear ✕</button>
            </div>
          )}
          {(activeScorerName || filters.date) && (
            <div className="mb-3 panel px-4 py-2.5 flex items-center justify-between">
              <div className="text-xs flex items-center gap-2 flex-wrap">
                {activeScorerName && (
                  <>
                    <span style={{color: COLORS.muted}}>Scorer</span>
                    <span className="font-serif text-lg" style={{color: COLORS.gold2}}>{activeScorerName}</span>
                  </>
                )}
                {filters.date && (
                  <>
                    <span style={{color: COLORS.muted}}>Matchday</span>
                    <span className="font-serif text-lg" style={{color: COLORS.gold2}}>{fmtDate(filters.date)}</span>
                  </>
                )}
                <span className="text-[11px] font-mono" style={{color: COLORS.muted2}}>{filtered.length} goals</span>
              </div>
              <button onClick={()=>setFilters(f => ({...f, scorer: null, date: null}))} className="text-xs" style={{color: COLORS.muted}}>Clear ✕</button>
            </div>
          )}
          {/* Tournament stage bracket strip — sits directly above the
              pitch, lets the operator filter goals to one or more stages.
              The current stage (most-advanced we have any data for) is
              highlighted in green; selected stages render in gold. */}
          <div className="mb-3 -mx-6">
            <StageStrip data={data} filters={filters} setFilters={setFilters}/>
          </div>
          <div className="grid grid-cols-12 gap-4 items-start">
            <div className="col-span-12 xl:col-span-8 min-w-0">
              <Pitch
                data={filtered}
                allCount={data.length}
                color={color} setColor={setColor}
                club={filters.club} setClub={(v)=>setFilters(f=>({...f, club: v}))}
                onPick={setPicked}
                onPickStack={setPickedStack}
                hover={hover} setHover={setHover}
              />
            </div>
            <div className="col-span-12 xl:col-span-4 min-w-0">
              <GoldenBoot data={data} activeScorer={filters.scorer} onPickScorer={onPickScorer}/>
            </div>
          </div>
          <StageProgression data={filtered} onPickDate={onPickDate} activeDate={filters.date}/>
          <Cards
            data={filtered}
            matchesData={filteredNoMatch}
            matches={(tournament && tournament.matches) || []}
            onPickGoal={setPicked}
            activeMatches={filters.match}
            onPickMatch={(id) => setFilters(f => {
              const s = new Set(f.match);
              if (s.has(id)) s.delete(id); else s.add(id);
              return {...f, match: s};
            })}
            // Toggle: re-clicking the same row clears the search (full picture).
            onSearch={(q) => setFilters(f => ({...f, search: f.search === q ? '' : String(q || '')}))}
            // Toggle: re-clicking the same time bucket resets the range.
            onTimeFilter={(lo, hi) => setFilters(f => {
              const same = f.minMin === lo && f.maxMin === hi;
              return {...f, minMin: same ? 0 : lo, maxMin: same ? 120 : hi};
            })}
            // Toggle: re-clicking the same distance bucket resets the range.
            onDistFilter={(lo, hi) => setFilters(f => {
              const same = f.minDist === lo && f.maxDist === hi;
              return {...f, minDist: same ? 0 : lo, maxDist: same ? 999 : hi};
            })}
            // Rectangle-based zone preset (6-yard / 18-yard / outside-18).
            // Re-clicking the active preset clears it.
            onZone={(zone) => setFilters(f => ({...f, zone: f.zone === zone ? null : zone}))}
            activeZone={filters.zone}
            // Toggle: clicking the donut sets bodyPart to {key}; clicking the
            // already-isolated key clears it. Multi-select is still possible
            // via the sidebar checkboxes.
            onBodyPart={(key) => setFilters(f => {
              const isOnlyThis = f.bodyPart.size === 1 && f.bodyPart.has(key);
              return {...f, bodyPart: isOnlyThis ? new Set() : new Set([key])};
            })}
            // Filter to this team exactly (not text-search, which would
            // over-match goals where the team's name appears as opponent).
            onClub={(team) => setFilters(f => ({...f, club: f.club === team ? 'All' : team}))}
            // Toggle: Opponents card click filters by exact opponent string
            // (avoids the search-substring trap where clicking a club as
            // opponent would also match goals scored *for* that same club).
            onOpponent={(opp) => setFilters(f => {
              const isOnlyThis = f.opponent && f.opponent.size === 1 && f.opponent.has(opp);
              return {...f, opponent: isOnlyThis ? new Set() : new Set([opp])};
            })}
            // Toggle a scorer-position (line) in/out of the position filter set.
            activePositions={filters.position}
            onPosition={(code) => setFilters(f => {
              const s = new Set(f.position);
              if (s.has(code)) s.delete(code); else s.add(code);
              return {...f, position: s};
            })}
            // Toggle an xG band; re-clicking the active band clears it.
            activeXg={[filters.minXg, filters.maxXg]}
            onXgFilter={(lo, hi) => setFilters(f => {
              const same = f.minXg === lo && f.maxXg === hi;
              return {...f, minXg: same ? 0 : lo, maxXg: same ? 1 : hi};
            })}
          />
          <GoalTable data={filtered} open={tableOpen} setOpen={setTableOpen} onPick={setPicked}/>
        </main>
      </div>

      <footer className="px-6 py-6 mt-2 border-t" style={{borderColor: 'var(--line)'}}>
        <div className="flex items-center justify-between flex-wrap gap-3 text-xs" style={{color: 'var(--muted-2)'}}>
          <span>
            Built by{' '}
            <a href="https://github.com/A-Maherr" target="_blank" rel="noopener noreferrer"
               className="hover:underline" style={{color: 'var(--gold-2)', fontWeight: 600}}>Ahmed Ali</a>
            {' · '}goal coordinates from FotMob (Opta event data)
          </span>
          <a href="https://github.com/A-Maherr/wc2026-goalmap" target="_blank" rel="noopener noreferrer"
             className="hover:underline font-mono" style={{color: 'var(--muted)'}}>
            View source on GitHub →
          </a>
        </div>
      </footer>

      <GoalDrawer goal={picked} onClose={()=>setPicked(null)}/>
      <StackDrawer goals={pickedStack} onClose={()=>setPickedStack(null)} onPickGoal={setPicked}/>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(
  <ErrorBoundary><App/></ErrorBoundary>
);
