'use client';

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
// Session 45 — the live Slate now renders the pre-graded snapshot via the
// VYNDR 2.0 card. Legacy GameCard is kept ONLY for its shared types.
import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
import { PropRowProp, Tier } from '@/components/PropRow';
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams, gradeKey, flattenToEdgeBoard, edgeBoardBreadth } from '@/lib/slateAdapter';
import MobileEdgeBoard from '@/components/vyndr/MobileEdgeBoard';
// Wave 4A (Step 3) — OUTLOOK MODE: the never-empty grid. When there are no
// live games (and it's not a fetch failure) the grid shows REAL data —
// yesterday's proven receipts + tomorrow's date-pinned schedule.
import { buildOutlook, mapTomorrowPreview } from '@/lib/outlook';
// Wave 4A (Step 4) — CONSENSUS vs MODEL: median-book-line vs the model.
import { collectBreadth } from '@/lib/marketBreadth';
import MarketBreadth from '@/components/vyndr/MarketBreadth';
// A1 S11 — LIVE SLATE MODE: pure live-tracking join + proximity sort.
// Grades never change in-game; these marks are tracking, labeled as such.
import { buildLiveIndex, attachLiveProgress, gameLiveProximity, sortLiveFirst } from '@/lib/liveProgress';
import { emptyStateCopy } from '@/lib/emptyState';
import { useAuth } from '@/contexts/AuthContext';
// Session 23 — all-day intelligence layer. The stat filter is the
// navigation system; streaks + hot lists layer ON TOP of the odds the
// Slate already shows, never replacing them.
import StatFilterPills from '@/components/StatFilterPills';
import StreaksPanel from '@/components/StreaksPanel';
import HotListPanel from '@/components/HotListPanel';
// Session 28 — line-movement + book-comparison read-only panels. Both
// self-hide when empty; free users see a top-3 teaser.
import MoversPanel from '@/components/MoversPanel';
import BestLinesPanel from '@/components/BestLinesPanel';

/**
 * The Slate (Session 13).
 *
 * Browse-first dashboard surface. Fetches today's odds across the
 * selected sport(s), groups by game, hands off to GameCard. Owns the
 * graded-prop Map and the in-flight grading key so PropRow loading
 * states are accurate.
 *
 * Backend contract:
 *   /api/odds/nba              — NBA props (existing proxy)
 *   /api/odds/soccer/:league   — soccer per league (existing proxy)
 *   /api/odds/mlb              — MLB props (may not exist yet —
 *                                we surface a friendly "coming soon"
 *                                if the endpoint 404s)
 *   /api/scan                  — submits a grade request (existing)
 *
 * State minimalism: one Map for graded props, one nullable loading
 * key, one error-by-key map. The Slate component is the only writer.
 */

// Session 14 — shimmer skeleton style. Width is a percentage string
// so cards remain responsive at small viewports. The keyframe rule
// lives in globals.css.
function skeletonStyle({ widthPct, height }: { widthPct: number; height: number }): React.CSSProperties {
  return {
    width: `${widthPct}%`,
    height,
    borderRadius: 4,
    background: 'linear-gradient(90deg, #12121A 0%, #1A1A24 50%, #12121A 100%)',
    backgroundSize: '200% 100%',
    animation: 'vyndr-shimmer 1.5s ease-in-out infinite',
  };
}

type SlateTab = 'all' | 'nba' | 'wnba' | 'mlb' | 'soccer';

const TABS: Array<{ id: SlateTab; label: string }> = [
  { id: 'all',    label: 'All' },
  { id: 'nba',    label: 'NBA' },
  { id: 'wnba',   label: 'WNBA' },
  { id: 'mlb',    label: 'MLB' },
  { id: 'soccer', label: 'Soccer' },
];

// Per-tab → list of fetch URLs. `null` indicates "no endpoint yet";
// the Slate renders a soft "coming soon" badge for that sport rather
// than 404-spamming the backend. Session 14 brought WNBA + MLB online.
const FETCH_URLS: Record<Exclude<SlateTab, 'all'>, string[] | null> = {
  nba: ['/api/odds/nba'],
  wnba: ['/api/odds/wnba'],
  mlb: ['/api/odds/mlb'],
  soccer: ['/api/odds/soccer/wc'],
};

// Session 17 — Express `/api/odds/{sport}` returns props in the
// GROUPED shape produced by `src/routes/odds.js#groupProps`:
//   { player, stat_type, home_team, away_team, game_time,
//     lines: [{ book, line, over_odds, under_odds }] }
// not a flat `line`/`direction`/`book` per prop. Pre-Session 17 the
// Slate assumed flat — every prop got filtered out by the
// `Number.isFinite(r.line)` check, which is why WNBA (the only
// active sport at audit time) showed "No games published yet."
//
// RawProp now mirrors both shapes; the unwrapper below picks the
// best available line out of the `lines[]` array when present.
interface RawProp {
  player?: string;
  stat_type?: string;
  // Flat-shape fields (pre-Session 17 contract — still tolerated)
  line?: number;
  direction?: 'over' | 'under';
  book?: string;
  // Grouped-shape fields (actual Express response since Session 7+)
  lines?: Array<{ book?: string; line?: number; over_odds?: number; under_odds?: number }>;
  game_time?: string;
  home_team?: string;
  away_team?: string;
}

interface OddsResponse {
  sport?: string;
  props?: RawProp[];
  error?: string;
}

// Pick the most useful single line out of a grouped prop. Preference:
//   1. A line marked `direction: over` (matches the default scan flow)
//   2. The first numeric line in the array
//   3. The flat-shape `line` field if present (legacy callers)
function pickLine(r: RawProp): { line: number; direction: 'over' | 'under'; book: string } | null {
  // Flat shape wins when present — preserves the older test fixtures.
  if (Number.isFinite(r.line)) {
    return {
      line: r.line as number,
      direction: (r.direction as 'over' | 'under') || 'over',
      book: r.book || 'draftkings',
    };
  }
  if (Array.isArray(r.lines)) {
    const first = r.lines.find((l) => Number.isFinite(l.line));
    if (first && Number.isFinite(first.line)) {
      // The grouped response doesn't carry a per-line direction —
      // each line has both over/under odds. Default to `over` since
      // that's the default scan direction.
      return {
        line: first.line as number,
        direction: 'over',
        book: first.book || 'draftkings',
      };
    }
  }
  return null;
}

interface SlateGame {
  sport: SlateSport;
  homeTeam: string;
  awayTeam: string;
  gameTime?: string;
  venue?: string;
  context?: string;
  props: PropRowProp[];
  // Session 24 — schedule + game-lines layers overlaid onto each game.
  status?: 'pre' | 'in' | 'post';
  score?: { home: number; away: number } | null;
  gameLines?: GameLines | null;
  // Session 25 — team abbreviations (for streak matching) + matched streaks.
  homeAbbr?: string | null;
  awayAbbr?: string | null;
  streaks?: GameStreak[];
}

interface StreakApiRow {
  player: string;
  team?: string | null;
  description: string;
  currentStreak: number;
}
interface StreaksResponse { streaks?: StreakApiRow[] }

// Session 45 — pre-graded snapshot response (snapshot:{sport}:latest).
interface SnapshotGrade { player?: string; player_name?: string; stat_type?: string; line?: number; direction?: string; grade?: string; archetype?: string | null; projection?: number | null; model_value?: number | null; gradedAt?: { line: number; odds?: number | null; timestamp?: string } | null }
interface SnapshotDelta { player?: string; stat?: string; side?: string; delta?: number; direction?: string; currentLine?: number }
interface SnapshotResponse { grades?: SnapshotGrade[]; deltas?: SnapshotDelta[]; updated_at?: string | null; refreshed_at?: string | null }

// Session 46 — MLB probable pitchers response.
interface PitcherSide { team?: string | null; pitcher?: string | null; era?: number | null }
interface PitcherGame { home?: PitcherSide; away?: PitcherSide }
interface PitcherResponse { games?: PitcherGame[] }

// A1 S11 — /api/live/:sport response (liveTrackingService envelope).
interface LivePlayerEntry { name?: string; team?: string | null; values?: Record<string, number> }
interface LiveGame { id: string; home?: string | null; away?: string | null; progress?: { label?: string; fraction?: number } | null; players?: Record<string, LivePlayerEntry> }
interface LiveResponse { sport?: string; hasLive?: boolean; games?: LiveGame[] }
type LiveIndex = ReturnType<typeof buildLiveIndex>;

// Sports with a free live box feed wired (specs/LIVE-TRACKING.md).
const LIVE_TRACK_SPORTS = new Set<SlateSport>(['mlb', 'wnba']);

// Session 45 — map a merged SlateGame + the pre-graded snapshot indices into the
// VYNDR 2.0 GameCardData (player name once, archetype, locked grades + deltas).
type GradeIndex = ReturnType<typeof indexGrades>;
type DeltaIndex = ReturnType<typeof indexDeltas>;
type PitcherMap = ReturnType<typeof buildPitcherMap>;
function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex, pitcherMap: PitcherMap, statFilter: string = 'all', viability: Parameters<typeof buildPlayerStripsFromProps>[5] = null, liveIndex: LiveIndex | null = null): GameCardData {
  // Session 60 (night2/C) — ONE stat selection filters every layer: props on
  // the cards narrow together with the streaks + hot-list panels below.
  const props = statFilter && statFilter !== 'all'
    ? g.props.filter((p) => String(p.stat_type || '').toLowerCase() === statFilter)
    : g.props;
  // A1 S11 — overlay live box-line tracking onto the built strips, but ONLY
  // for games the schedule marks in-progress (a shared player name in another
  // game must not leak marks onto a pre-game card).
  const strips = buildPlayerStripsFromProps(props, gradeIndex, deltaIndex, Date.now(), { home: g.homeTeam, away: g.awayTeam }, viability);
  const liveStrips = g.status === 'in' && liveIndex ? attachLiveProgress(strips, liveIndex) : strips;
  return {
    id: `${g.sport}-${g.awayAbbr || g.awayTeam}-${g.homeAbbr || g.homeTeam}`,
    sport: g.sport,
    live: g.status === 'in',
    score: g.score ? { away: g.score.away, home: g.score.home } : undefined,
    away: { abbr: g.awayAbbr || g.awayTeam, name: g.awayTeam },
    home: { abbr: g.homeAbbr || g.homeTeam, name: g.homeTeam },
    time: formatGameTime(g.gameTime),
    venue: g.venue,
    lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [],
    // Session 59 (work-order 1.6) — the join guard drops bad feed rows inside
    // buildPlayerStripsFromProps (game participants passed above).
    playerStrips: liveStrips,
    // Session 46 — MLB starting pitchers (statsapi.mlb.com via /pitchers).
    pitchers: g.sport === 'mlb' ? pitchersForGameTeams(g.awayTeam, g.homeTeam, pitcherMap) : undefined,
    streaks: (g.streaks || []).map((s) => ({ player: s.player, text: s.description || '' })),
  };
}

// ---- Session 24: schedule + game-lines response shapes ----
interface ScheduleTeam { name?: string | null; abbreviation?: string | null }
interface ScheduleGame {
  id?: string;
  homeTeam?: ScheduleTeam;
  awayTeam?: ScheduleTeam;
  gameTime?: string | null;
  status?: 'pre' | 'in' | 'post' | null;
  score?: { home: number; away: number } | null;
  venue?: string | null;
  broadcast?: string | null;
}
interface ScheduleResponse { games?: ScheduleGame[] }
interface GameLinesResponse { games?: Record<string, GameLines> }

// Nickname token (last word) — the most stable cross-source identifier
// between ESPN full names and odds-api full names ("San Antonio Spurs"
// ↔ "spurs"). Falls back to the whole normalized string.
// Session 55 — relative freshness label ("updated 12s ago" → "3m ago").
function freshLabel(ts: number | null, now: number): string {
  if (!ts) return '';
  const s = Math.max(0, Math.round((now - ts) / 1000));
  if (s < 60) return `${s}s ago`;
  const m = Math.round(s / 60);
  if (m < 60) return `${m}m ago`;
  return `${Math.round(m / 60)}h ago`;
}

/** Session 64 — ET date string offset by n days (Yesterday/Tomorrow nav). */
function etDateWithOffset(offset: number): string {
  const d = new Date(Date.now() + offset * 86_400_000);
  return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit' }).format(d);
}

function nickToken(name?: string | null): string {
  const w = String(name || '').trim().split(/\s+/);
  const last = w[w.length - 1] || '';
  return last.toLowerCase().replace(/[^a-z]/g, '');
}

// Match an odds-derived game to a schedule game by both nicknames.
function gamesMatch(scheduleHome: string, scheduleAway: string, oddsHome: string, oddsAway: string): boolean {
  const sh = nickToken(scheduleHome), sa = nickToken(scheduleAway);
  const oh = nickToken(oddsHome), oa = nickToken(oddsAway);
  if (!sh || !sa || !oh || !oa) return false;
  return (sh === oh && sa === oa) || (sh === oa && sa === oh);
}

// Find the Tank01 game-lines entry for a schedule game by team
// abbreviation (ESPN + Tank01 both use standard team abbreviations).
function findGameLines(home?: ScheduleTeam, away?: ScheduleTeam, lines?: Record<string, GameLines>): GameLines | null {
  if (!lines) return null;
  const h = (home?.abbreviation || '').toUpperCase();
  const a = (away?.abbreviation || '').toUpperCase();
  if (!h && !a) return null;
  for (const entry of Object.values(lines)) {
    const eh = String(entry.homeTeam || '').toUpperCase();
    const ea = String(entry.awayTeam || '').toUpperCase();
    if ((eh === h && ea === a) || (eh === a && ea === h)) return entry;
  }
  return null;
}

/**
 * Session 24 — merge the three free/cheap layers into one game list.
 * Schedule is the FOUNDATION (always shows from ESPN); odds props and
 * Tank01 lines overlay onto matching games. Unmatched odds games are
 * appended so we never drop props. When schedule is empty, the odds
 * games become the base (odds-only fallback).
 */
// Match streaks to a game by team abbreviation. A streak's `team` is the
// player's team abbrev (ESPN/Tank01 standard), which lines up with the
// schedule's home/away abbreviations.
function streaksForGame(home?: string | null, away?: string | null, streaks?: StreakApiRow[]): GameStreak[] {
  if (!streaks || streaks.length === 0) return [];
  const h = (home || '').toUpperCase();
  const a = (away || '').toUpperCase();
  if (!h && !a) return [];
  return streaks
    .filter((s) => {
      const t = (s.team || '').toUpperCase();
      return t && (t === h || t === a);
    })
    .map((s) => ({ player: s.player, team: s.team, description: s.description, currentStreak: s.currentStreak }));
}

function mergeSlate(
  sport: SlateSport,
  scheduleGames: ScheduleGame[],
  oddsGames: SlateGame[],
  lines?: Record<string, GameLines>,
  streaks?: StreakApiRow[],
): SlateGame[] {
  const base: SlateGame[] = scheduleGames.map((sg) => ({
    sport,
    homeTeam: sg.homeTeam?.name || '',
    awayTeam: sg.awayTeam?.name || '',
    homeAbbr: sg.homeTeam?.abbreviation || null,
    awayAbbr: sg.awayTeam?.abbreviation || null,
    gameTime: sg.gameTime || undefined,
    venue: sg.venue || undefined,
    status: sg.status || undefined,
    score: sg.score || undefined,
    props: [],
    gameLines: findGameLines(sg.homeTeam, sg.awayTeam, lines),
    streaks: streaksForGame(sg.homeTeam?.abbreviation, sg.awayTeam?.abbreviation, streaks),
  }));

  const unmatched: SlateGame[] = [];
  for (const og of oddsGames) {
    const target = base.find((b) => gamesMatch(b.homeTeam, b.awayTeam, og.homeTeam, og.awayTeam));
    if (target) target.props.push(...og.props);
    else unmatched.push(og);
  }

  const merged = [...base, ...unmatched];
  // Stable order: scheduled tip-off time, unknowns last.
  return merged.sort((a, b) => {
    const ta = a.gameTime ? Date.parse(a.gameTime) : Number.MAX_SAFE_INTEGER;
    const tb = b.gameTime ? Date.parse(b.gameTime) : Number.MAX_SAFE_INTEGER;
    return ta - tb;
  });
}

function groupByGame(rawProps: RawProp[], sport: SlateSport): SlateGame[] {
  const games = new Map<string, SlateGame>();
  for (const r of rawProps) {
    if (!r.player || !r.stat_type) continue;
    // Session 17 — unwrap the grouped `lines[]` shape from Express.
    const lineInfo = pickLine(r);
    if (!lineInfo) continue;
    const home = r.home_team || '?';
    const away = r.away_team || '?';
    const time = r.game_time || '';
    const key = `${away}__${home}__${time}`;
    if (!games.has(key)) {
      games.set(key, {
        sport,
        homeTeam: home,
        awayTeam: away,
        gameTime: time || undefined,
        props: [],
      });
    }
    games.get(key)!.props.push({
      player: r.player,
      stat_type: r.stat_type,
      line: lineInfo.line,
      direction: lineInfo.direction,
      book: lineInfo.book,
      // A1 S3 — keep the full per-book rows so the strip layer can mark
      // the best available price (pickLine alone discards the comparison).
      books: Array.isArray(r.lines) ? r.lines : undefined,
    });
  }
  // Sort each game's props by player + stat for stable rendering.
  for (const g of games.values()) {
    g.props.sort((a, b) => {
      if (a.player !== b.player) return a.player.localeCompare(b.player);
      return a.stat_type.localeCompare(b.stat_type);
    });
  }
  return Array.from(games.values()).sort((a, b) => {
    const ta = a.gameTime ? Date.parse(a.gameTime) : 0;
    const tb = b.gameTime ? Date.parse(b.gameTime) : 0;
    return ta - tb;
  });
}

/** Session 64 (A1-S5) — the Yesterday results panel: that date's settled
 *  public reads (outcome + CLV), straight from the ledger. Self-hides empty. */
function YesterdaySettle({ date }: { date: string }) {
  const [rows, setRows] = useState<Array<{ id: string; player_name: string; stat: string; line: number; side: string; grade: string; outcome?: string | null; actual_value?: number | null; clv_result?: string | null }>>([]);
  useEffect(() => {
    let active = true;
    fetch(`/api/ledger/model?date=${date}&limit=60`)
      .then((r) => (r.ok ? r.json() : null))
      .then((d) => { if (active && d) setRows((d.entries || []).filter((e: { outcome?: string | null }) => e.outcome)); })
      .catch(() => { /* self-hide */ });
    return () => { active = false; };
  }, [date]);
  if (rows.length === 0) return null;
  return (
    <section style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16, marginBottom: 16 }}>
      <div className="mono" style={{ fontSize: 11, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--g-a)', marginBottom: 10 }}>THE SETTLE · {date}</div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
        {rows.map((r) => (
          <div key={r.id} className="mono" style={{ display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 12, flexWrap: 'wrap' }}>
            <span style={{ color: 'var(--text-0)', fontWeight: 700 }}>{r.player_name}</span>
            <span style={{ color: 'var(--text-1)' }}>{r.stat.replace(/_/g, ' ')} {String(r.side).toUpperCase() === 'UNDER' ? 'u' : 'o'}{r.line} · {r.grade}</span>
            <span style={{ fontWeight: 800, color: r.outcome === 'hit' ? 'var(--g-a)' : r.outcome === 'miss' ? 'var(--miss)' : 'var(--text-1)' }}>
              {r.outcome === 'hit' ? '✓ HIT' : r.outcome === 'miss' ? '✕ MISS' : '– PUSH'}{r.actual_value != null ? ` (${r.actual_value})` : ''}
            </span>
            {/* Session 64 — legacy C4 CLV removed from the board. Same broken
                lineage as the ledger chip; the board is also a FREE surface and the
                verified badge is Analyst+Desk. */}
          </div>
        ))}
      </div>
    </section>
  );
}

// The /api/ledger/model settled-row shape (subset the receipts read).
interface ModelReceiptRow {
  player_name?: string; player?: string; sport?: string; stat?: string;
  line?: number; side?: string; grade?: string;
  outcome?: string | null; actual_value?: number | null; clv_result?: string | null;
}
// buildHeroReceipts output (proven yesterday hit).
interface OutlookReceipt { player: string; stat: string; line: number; side: string; grade: string; sport: string; outcome: string; actual: number | null; clvResult: string | null }
// mapTomorrowPreview output.
interface OutlookGame { id: string; away: string; home: string; time: string | null; sport: string | null }

/**
 * Wave 4A (Step 3) — OUTLOOK MODE surface. Renders in the empty game grid in
 * place of the old dead-end CTA: yesterday's PROVEN A-tier receipts +
 * tomorrow's date-pinned schedule preview (both REAL, always-available data —
 * never an invented line). The month-aware header ALWAYS shows, so the grid is
 * never blank. Distinct from the network `fetchError` state.
 */
function OutlookSurface({ tab }: { tab: SlateTab }) {
  const [settled, setSettled] = useState<ModelReceiptRow[] | null>(null);
  const [tomorrow, setTomorrow] = useState<ScheduleGame[] | null>(null);

  useEffect(() => {
    let active = true;
    const sportQ = tab !== 'all' && tab !== 'soccer' ? `&sport=${tab}` : '';
    fetch(`/api/ledger/model?limit=80${sportQ}`)
      .then((r) => (r.ok ? r.json() : null))
      .then((d) => { if (active) setSettled(Array.isArray(d?.entries) ? d.entries : []); })
      .catch(() => { if (active) setSettled([]); });
    return () => { active = false; };
  }, [tab]);

  useEffect(() => {
    let active = true;
    const date = etDateWithOffset(1); // tomorrow, ET — the schedule route is date-pinned
    const SPORTS: SlateSport[] = tab === 'all'
      ? ['mlb', 'nba', 'wnba']
      : (['nba', 'wnba', 'mlb'] as string[]).includes(tab) ? [tab as SlateSport] : [];
    if (SPORTS.length === 0) { setTomorrow([]); return; }
    Promise.all(SPORTS.map(async (sport) => {
      try {
        const r = await fetch(`/api/schedule/${sport}?date=${date}`, { cache: 'no-store' });
        if (!r.ok) return [] as ScheduleGame[];
        const d = (await r.json()) as ScheduleResponse;
        return (Array.isArray(d?.games) ? d.games : []).map((g) => ({ ...g, sport }));
      } catch { return [] as ScheduleGame[]; }
    })).then((lists) => { if (active) setTomorrow(lists.flat()); });
    return () => { active = false; };
  }, [tab]);

  const outlook = useMemo(
    () => buildOutlook({ gamesCount: 0, settledRows: settled || [], tomorrow: tomorrow || [] }) as { mode: string; receipts?: OutlookReceipt[]; tomorrow?: OutlookGame[] },
    [settled, tomorrow],
  );
  const receipts: OutlookReceipt[] = outlook.receipts ?? [];
  const preview: OutlookGame[] = outlook.tomorrow ?? [];
  const { title, body } = emptyStateCopy(tab);

  return (
    <section className="surface" style={{ border: '1px solid var(--border, #1A1A24)', borderRadius: 8, padding: 20, marginBottom: 16 }}>
      <div style={{ marginBottom: receipts.length || preview.length ? 18 : 0 }}>
        <p className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.16em', color: 'var(--amber, #FFB347)', marginBottom: 6 }}>OUTLOOK</p>
        <p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 4 }}>{title}</p>
        <p style={{ color: 'var(--text-secondary, #8A8A9A)', fontSize: 13 }}>{body}</p>
      </div>

      {receipts.length > 0 && (
        <div style={{ marginBottom: preview.length ? 18 : 0 }}>
          <div className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--g-a)', marginBottom: 10 }}>YESTERDAY · PROVEN</div>
          <div style={{ display: 'flex', gap: 10, overflowX: 'auto', paddingBottom: 4 }}>
            {receipts.map((r, i) => (
              <div
                key={`${r.player}-${r.stat}-${i}`}
                className="mono"
                style={{ minWidth: 178, padding: 12, border: '1px solid var(--g-a)', borderRadius: 10, background: 'var(--bg-surface, #101018)', fontVariantNumeric: 'tabular-nums' }}
              >
                <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
                  <span style={{ fontSize: 8.5, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-2)' }}>{String(r.sport || '').toUpperCase()}</span>
                  <span style={{ fontSize: 13, fontWeight: 800, color: 'var(--g-a)' }}>{r.grade}</span>
                </div>
                <div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-0)', marginBottom: 3 }}>{r.player}</div>
                <div style={{ fontSize: 11.5, color: 'var(--text-1)' }}>
                  {String(r.side).toUpperCase().startsWith('U') ? 'u' : 'o'}{r.line} {String(r.stat).replace(/_/g, ' ')}
                </div>
                <div style={{ fontSize: 11.5, fontWeight: 800, color: 'var(--g-a)', marginTop: 6 }}>
                  {/* Session 64 — legacy C4 "· CLV BEAT" removed here too; the
                      result stands on its own. */}
                  ✓ HIT{r.actual != null ? ` (${r.actual})` : ''}
                </div>
              </div>
            ))}
          </div>
        </div>
      )}

      {preview.length > 0 && (
        <div>
          <div className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--text-2)', marginBottom: 10 }}>
            TOMORROW · SCHEDULE <span style={{ color: 'var(--text-2)', fontWeight: 600 }}>— lines post on the day</span>
          </div>
          <div style={{ display: 'grid', gap: 7 }}>
            {preview.map((g) => (
              <div key={g.id} className="mono" style={{ display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 12, flexWrap: 'wrap' }}>
                {g.sport && <span style={{ fontSize: 8.5, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-2)', minWidth: 30 }}>{g.sport}</span>}
                <span style={{ color: 'var(--text-0)', fontWeight: 700 }}>{g.away} @ {g.home}</span>
                {g.time && <span style={{ color: 'var(--text-2)' }}>{formatGameTime(g.time)}</span>}
              </div>
            ))}
          </div>
        </div>
      )}
    </section>
  );
}

export interface SlateProps {
  initialTab?: SlateTab;
  tier?: Tier;
  /** Session 49 — user's preferred books (highlighted in each card's lines). */
  preferredBooks?: string[];
  /** Session 59 (2.1) — the sport tabs are THE filter; parents (dashboard
   *  legacy sections) subscribe instead of running their own tab row. */
  onTabChange?: (tab: SlateTab) => void;
}

const VALID_TABS = new Set<SlateTab>(['all', 'nba', 'wnba', 'mlb', 'soccer']);

/** ?sport= from the URL (deep-linkable tabs, spec §6 SportTabs). */
function tabFromUrl(): SlateTab | null {
  if (typeof window === 'undefined') return null;
  const q = new URLSearchParams(window.location.search).get('sport');
  const t = String(q || '').toLowerCase() as SlateTab;
  return VALID_TABS.has(t) ? t : null;
}

export default function Slate({ initialTab = 'all', tier = 'free', preferredBooks, onTabChange }: SlateProps) {
  const router = useRouter();
  const { session } = useAuth();
  // Session 59 (2.1) — the URL is the source of truth on load (?sport=mlb
  // deep-links a filtered slate); user prefs are the fallback default.
  const [tab, setTabState] = useState<SlateTab>(() => tabFromUrl() || initialTab);
  const setTab = (t: SlateTab) => {
    setTabState(t);
    if (typeof window !== 'undefined') {
      const url = new URL(window.location.href);
      if (t === 'all') url.searchParams.delete('sport');
      else url.searchParams.set('sport', t);
      window.history.replaceState(null, '', url.toString());
    }
    if (onTabChange) onTabChange(t);
  };
  // Session 23 — active stat category for the intelligence panels. 'all'
  // shows everything; selecting one narrows streaks + hot list. Schedule
  // and game lines stay visible regardless (handled inside GameCard).
  const [activeStat, setActiveStat] = useState<string>('all');
  const [games, setGames] = useState<SlateGame[]>([]);
  // Session 45 — merged pre-graded snapshot across the loaded sports.
  const [snapGrades, setSnapGrades] = useState<SnapshotGrade[]>([]);
  // P1-6 — the PIPELINE's freshness (refreshed_at, the same field the app-bar
  // clock reads). "UPDATED Xs ago" must show the DATA age, not the client's poll
  // time — else the strip claims "0s ago" while the clock (honestly) says 46m.
  const [snapRefreshedAt, setSnapRefreshedAt] = useState<number | null>(null);
  const [snapDeltas, setSnapDeltas] = useState<SnapshotDelta[]>([]);
  const [pitcherGames, setPitcherGames] = useState<PitcherGame[]>([]);
  // Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire).
  const [viability, setViability] = useState<{ lineups?: { byPlayer: Record<string, { status: string; slot?: number }>; postedTeams: string[] }; injuries?: Record<string, { status: string; detail?: string | null }> } | null>(null);
  // A1 S11 — live tracking responses per sport (polled only while live games
  // are on screen; the backend cache makes this ~1 upstream call per game/90s).
  const [liveBySport, setLiveBySport] = useState<Record<string, LiveResponse>>({});
  // Session 64 (A1-S5) — date navigation: -1 = Yesterday (results surface),
  // 0 = Today, +1 = Tomorrow (schedule until lines post).
  const [dateOffset, setDateOffset] = useState(0);
  const dateOffsetRef = useRef(0);
  useEffect(() => { dateOffsetRef.current = dateOffset; }, [dateOffset]);
  const [loading, setLoading] = useState(false);
  const [fetchError, setFetchError] = useState<string | null>(null);
  // Session 55 — real-time freshness: when the slate last pulled fresh data,
  // and a ticking clock so "updated Xs ago" advances between polls.
  const [lastRefreshed, setLastRefreshed] = useState<number | null>(null);
  const [nowTick, setNowTick] = useState<number>(() => Date.now());
  // Session 26 — per-sport schedule counts for the tab labels, fetched
  // ONCE on mount for every schedule-backed sport (free ESPN, cached 60s).
  // This makes "MLB (15)" / "WNBA (2)" show on their tabs even while the
  // user is viewing a different sport — the count was previously only
  // known for sports loaded by the active tab.
  const [scheduleCounts, setScheduleCounts] = useState<Partial<Record<SlateSport, number>>>({});
  // Session 24 — when odds are unavailable but the schedule still has
  // games, this becomes a soft inline notice instead of a wall-of-error.
  const [oddsNotice, setOddsNotice] = useState(false);

  // Search filter (Phase 3.4 — kept here so the Slate owns its own filtering).
  const [searchQuery, setSearchQuery] = useState('');

  // Session 24 — fetch ALL free/cheap layers per sport in parallel:
  //   odds (odds-api props) · schedule (ESPN) · gamelines (Tank01)
  // Schedule is the foundation — games render even when odds are
  // empty/503. Odds + lines overlay on top. The slate is never empty
  // just because one provider is down.
  // Session 55 — real-time layer. `silent` background refreshes keep the slate
  // alive (polling) without the skeleton flash or clearing the current view.
  const fetchSlate = useCallback(async (active: SlateTab, silent = false, offset = dateOffsetRef.current) => {
    const dateParam = offset === 0 ? '' : `?date=${etDateWithOffset(offset)}`;
    const isToday = offset === 0;
    if (!silent) {
      setLoading(true);
      setFetchError(null);
      setOddsNotice(false);
    }

    // Sports that carry a schedule/streaks feed (ESPN-backed). Soccer
    // has no schedule endpoint, so it stays odds-only.
    const SCHEDULE_SPORTS = new Set<SlateSport>(['nba', 'wnba', 'mlb']);

    const sportsToFetch: SlateSport[] = [];
    const consider = (s: Exclude<SlateTab, 'all'>) => {
      if (FETCH_URLS[s] !== null) sportsToFetch.push(s as SlateSport);
    };
    if (active === 'all') {
      consider('nba'); consider('wnba'); consider('mlb'); consider('soccer');
    } else {
      consider(active);
    }

    if (sportsToFetch.length === 0) {
      setGames([]);
      setLoading(false);
      return;
    }

    const getJson = async <T,>(url: string): Promise<T | null> => {
      try {
        const r = await fetch(url, { cache: 'no-store' });
        if (!r.ok) return null;
        return (await r.json()) as T;
      } catch {
        return null;
      }
    };

    // Per sport: odds + schedule + gamelines, all settled independently.
    const perSport = await Promise.all(
      sportsToFetch.map(async (sport) => {
        const oddsUrls = FETCH_URLS[sport] as string[];
        // Session 64 — Yesterday/Tomorrow are schedule+results surfaces: the
        // odds/grades/pitcher layers are TODAY's and never fake other dates.
        const [oddsResults, schedule, lines, streaksRes, snap, pitchersRes, lineupsRes, injuriesRes] = await Promise.all([
          isToday ? Promise.all(oddsUrls.map((u) => getJson<OddsResponse>(u))) : Promise.resolve([] as (OddsResponse | null)[]),
          SCHEDULE_SPORTS.has(sport) ? getJson<ScheduleResponse>(`/api/schedule/${sport}${dateParam}`) : Promise.resolve(null),
          isToday && SCHEDULE_SPORTS.has(sport) ? getJson<GameLinesResponse>(`/api/gamelines/${sport}`) : Promise.resolve(null),
          isToday && SCHEDULE_SPORTS.has(sport) ? getJson<StreaksResponse>(`/api/streaks/${sport}`) : Promise.resolve(null),
          // Session 45 — pre-graded snapshot (locked grades + line deltas).
          isToday ? getJson<SnapshotResponse>(`/api/snapshot/${sport}`) : Promise.resolve(null),
          // Session 46 — MLB probable starting pitchers.
          isToday && sport === 'mlb' ? getJson<PitcherResponse>(`/api/schedule/mlb/pitchers`) : Promise.resolve(null),
          // Session 64 (A1-S5) — lineup confirmation (MLB) + injury wire.
          isToday && sport === 'mlb' ? getJson<{ byPlayer: Record<string, { status: string; slot?: number }>; postedTeams: string[] }>(`/api/schedule/mlb/lineups`) : Promise.resolve(null),
          isToday && SCHEDULE_SPORTS.has(sport) ? getJson<{ byPlayer: Record<string, { status: string; detail?: string | null }> }>(`/api/schedule/${sport}/injuries`) : Promise.resolve(null),
        ]);

        const oddsOk = oddsResults.some((o) => o !== null);
        const oddsProps = oddsResults.flatMap((o) => o?.props || []);
        const oddsGames = groupByGame(oddsProps, sport);
        const scheduleGames = schedule?.games || [];
        const merged = mergeSlate(sport, scheduleGames, oddsGames, lines?.games, streaksRes?.streaks);
        return { sport, merged, oddsOk, hadSchedule: scheduleGames.length > 0, snapGrades: snap?.grades || [], snapDeltas: snap?.deltas || [], snapRefreshed: snap?.refreshed_at || snap?.updated_at || null, pitcherGames: pitchersRes?.games || [], lineups: lineupsRes || null, injuries: injuriesRes?.byPlayer || null };
      }),
    );

    const allGames: SlateGame[] = [];
    const allSnapGrades: SnapshotGrade[] = [];
    const allSnapDeltas: SnapshotDelta[] = [];
    const allPitcherGames: PitcherGame[] = [];
    let mergedLineups: { byPlayer: Record<string, { status: string; slot?: number }>; postedTeams: string[] } | null = null;
    const mergedInjuries: Record<string, { status: string; detail?: string | null }> = {};
    let anyOddsOk = false;
    let anyScheduleShown = false;
    for (const s of perSport) {
      allGames.push(...s.merged);
      allSnapGrades.push(...s.snapGrades);
      allSnapDeltas.push(...s.snapDeltas);
      allPitcherGames.push(...s.pitcherGames);
      if (s.lineups && s.lineups.byPlayer) mergedLineups = s.lineups;
      if (s.injuries) Object.assign(mergedInjuries, s.injuries);
      if (s.oddsOk) anyOddsOk = true;
      if (s.hadSchedule) anyScheduleShown = true;
    }

    // A silent background poll that came back empty (transient blip) must NOT
    // wipe the current view or flash an error — keep what the user is seeing.
    if (silent && allGames.length === 0) {
      setLoading(false);
      return;
    }

    setGames(allGames);
    setSnapGrades(allSnapGrades);
    // P1-6 — newest pipeline refreshed_at across the loaded sports (the real
    // data age, shared with the app-bar clock — one source of truth).
    {
      const stamps = perSport.map((s) => (s.snapRefreshed ? Date.parse(s.snapRefreshed) : NaN)).filter((t) => Number.isFinite(t));
      setSnapRefreshedAt(stamps.length ? Math.max(...stamps) : null);
    }
    setSnapDeltas(allSnapDeltas);
    setPitcherGames(allPitcherGames);
    setViability({ lineups: mergedLineups || undefined, injuries: Object.keys(mergedInjuries).length ? mergedInjuries : undefined });
    setLastRefreshed(Date.now());

    // Odds down but schedule carried the slate → soft notice, not a wall.
    // (Only meaningful for today — other dates are schedule surfaces.)
    if (!silent && isToday && !anyOddsOk && anyScheduleShown) setOddsNotice(true);
    // Genuine total failure (no odds, no schedule, anywhere) → error.
    if (!silent && !anyOddsOk && !anyScheduleShown && allGames.length === 0) {
      setFetchError('No games available right now. Check back soon.');
    }
    setLoading(false);
  }, []);

  useEffect(() => { fetchSlate(tab, false, dateOffset); }, [tab, fetchSlate, dateOffset]);

  // Session 55 — auto-refresh: poll the slate every 60s so fresh snapshot grades
  // + schedule/score updates appear without a page reload. Silent (no skeleton).
  useEffect(() => {
    const id = setInterval(() => { if (dateOffsetRef.current === 0) fetchSlate(tab, true, 0); }, 60_000);
    return () => clearInterval(id);
  }, [tab, fetchSlate]);

  // A 15s ticking clock so the "updated Xs ago" freshness label stays honest.
  useEffect(() => {
    const id = setInterval(() => setNowTick(Date.now()), 15_000);
    return () => clearInterval(id);
  }, []);

  // A1 S11 — LIVE SLATE MODE poll. Fetch /api/live/{sport} every 60s ONLY
  // while live games for a track-able sport are on screen (today's slate).
  // Nothing live → no polling at all; the shared backend cache (90s) means
  // the upstream cost is ~1 boxscore call per live game per window TOTAL.
  const liveSportsKey = useMemo(() => {
    if (dateOffset !== 0) return '';
    const sports = new Set<string>();
    for (const g of games) {
      if (g.status === 'in' && LIVE_TRACK_SPORTS.has(g.sport)) sports.add(g.sport);
    }
    return [...sports].sort().join(',');
  }, [games, dateOffset]);
  useEffect(() => {
    if (!liveSportsKey) { setLiveBySport({}); return; }
    const sports = liveSportsKey.split(',');
    let cancelled = false;
    const poll = async () => {
      const entries = await Promise.all(sports.map(async (sport) => {
        try {
          const r = await fetch(`/api/live/${sport}`, { cache: 'no-store' });
          if (!r.ok) return [sport, null] as const;
          return [sport, (await r.json()) as LiveResponse] as const;
        } catch { return [sport, null] as const; }
      }));
      if (cancelled) return;
      setLiveBySport((prev) => {
        const next: Record<string, LiveResponse> = {};
        for (const [sport, resp] of entries) {
          // A transient fetch failure keeps the previous live view (never blank
          // a good in-progress mark on a blip).
          if (resp) next[sport] = resp;
          else if (prev[sport]) next[sport] = prev[sport];
        }
        return next;
      });
    };
    poll();
    const id = setInterval(poll, 60_000);
    return () => { cancelled = true; clearInterval(id); };
  }, [liveSportsKey]);

  // Session 24 — switching sport resets the stat filter. The categories
  // differ per sport (Points vs Hits), so a stale "points" filter would
  // silently blank the MLB panels. Always land back on 'all'.
  useEffect(() => { setActiveStat('all'); }, [tab]);

  // Session 26 — fetch schedule counts for every schedule-backed sport
  // once on mount, so all sport tabs show their game count regardless of
  // which tab is active. Free + cached; failures leave the count absent.
  useEffect(() => {
    let cancelled = false;
    const SPORTS: SlateSport[] = ['nba', 'wnba', 'mlb'];
    (async () => {
      const entries = await Promise.all(
        SPORTS.map(async (sport) => {
          try {
            const r = await fetch(`/api/schedule/${sport}`, { cache: 'no-store' });
            if (!r.ok) return [sport, undefined] as const;
            const data = (await r.json()) as ScheduleResponse;
            return [sport, Array.isArray(data?.games) ? data.games.length : undefined] as const;
          } catch {
            return [sport, undefined] as const;
          }
        }),
      );
      if (cancelled) return;
      const next: Partial<Record<SlateSport, number>> = {};
      for (const [sport, count] of entries) if (count != null) next[sport] = count;
      setScheduleCounts(next);
    })();
    return () => { cancelled = true; };
  }, []);

  // Session 45 — the on-demand "Read" grade flow is RETIRED. Grades come from
  // the scheduled snapshot pipeline (snapshot:{sport}:latest), overlaid onto the
  // slate below. The legacy onGrade call site was removed with the legacy card.

  // Filter pipeline — searchQuery applied to games + props.
  // Session 45 — index the pre-graded snapshot once for the overlay.
  const gradeIndex = useMemo(() => indexGrades(snapGrades), [snapGrades]);
  const deltaIndex = useMemo(() => indexDeltas(snapDeltas), [snapDeltas]);
  const pitcherMap = useMemo(() => buildPitcherMap(pitcherGames), [pitcherGames]);
  // A1 S11 — one merged live index across the polled sports.
  const liveIndex = useMemo(() => buildLiveIndex(Object.values(liveBySport)), [liveBySport]);

  const filteredGames = useMemo(() => {
    // Session 44 — drop completed games >24h old so a 5-day-old FINAL never
    // lingers on the dashboard. Upcoming + live always show.
    const fresh = games.filter((g) => isRelevantGame(g));
    if (!searchQuery.trim()) return fresh;
    const q = searchQuery.toLowerCase();
    return fresh
      .map((g) => {
        const homeMatch = g.homeTeam.toLowerCase().includes(q);
        const awayMatch = g.awayTeam.toLowerCase().includes(q);
        if (homeMatch || awayMatch) return g;
        const matchedProps = g.props.filter(
          (p) => p.player.toLowerCase().includes(q) || p.stat_type.toLowerCase().includes(q),
        );
        if (matchedProps.length === 0) return null;
        return { ...g, props: matchedProps };
      })
      .filter((g): g is SlateGame => g !== null);
  }, [games, searchQuery]);

  // A1 S11 — live games with TRACKED props float to the top, ordered by
  // proximity-to-hit (pure sortLiveFirst; everything else keeps tip-off order).
  const orderedGames = useMemo<SlateGame[]>(() => {
    if (!liveIndex || liveIndex.count === 0) return filteredGames;
    return sortLiveFirst(filteredGames, (g: SlateGame) => (
      g.status === 'in'
        ? gameLiveProximity(g.props, gradeIndex, liveIndex)
        : { tracked: false, proximity: 0 }
    )) as SlateGame[];
  }, [filteredGames, gradeIndex, liveIndex]);

  // Wave 4A (Step 4) — CONSENSUS vs MODEL breadth. For every graded prop that
  // carries ≥2 book lines, compare the market's median line to the model's
  // projection (signed by the graded side). collectBreadth drops <2-book props
  // and ranks by |edge| — an empty result self-hides the strip. This is the
  // REAL data behind the DeskShowcase "consensus vs model" claim.
  const breadthItems = useMemo(() => {
    const items: Array<{ player: string; stat: string; side: string; line: number; books: PropRowProp['books']; modelValue: number | null }> = [];
    for (const g of filteredGames) {
      for (const p of g.props) {
        if (!Array.isArray(p.books) || p.books.length < 2) continue;
        const grade = (gradeIndex as Record<string, SnapshotGrade>)[gradeKey(p.player, p.stat_type)];
        const mv = grade ? (grade.projection ?? grade.model_value ?? null) : null;
        items.push({
          player: p.player,
          stat: p.stat_type,
          side: (grade && grade.direction) || p.direction || 'over',
          line: p.line,
          books: p.books,
          modelValue: mv == null ? null : Number(mv),
        });
      }
    }
    return collectBreadth(items, 6);
  }, [filteredGames, gradeIndex]);

  // Session 25 — per-sport game counts for the tab labels, derived from
  // the MERGED list (schedule + odds), so a tab reads "MLB (8)" off the
  // free ESPN schedule even when odds are empty. Counts only appear for
  // sports currently loaded (the active tab fetches its own sports).
  const countBySport = useMemo(() => {
    const m: Partial<Record<SlateSport, number>> = {};
    for (const g of games) m[g.sport] = (m[g.sport] || 0) + 1;
    return m;
  }, [games]);
  const tabCount = (id: SlateTab): number | null => {
    if (id === 'all') {
      // Prefer the loaded total; fall back to the sum of schedule counts
      // so the ALL tab reflects every sport even before its games load.
      if (games.length > 0) return games.length;
      const sum = Object.values(scheduleCounts).reduce((a, b) => a + (b || 0), 0);
      return sum || null;
    }
    // Loaded games are the most accurate (they include odds-only games);
    // otherwise fall back to the mount-time schedule count.
    return countBySport[id as SlateSport] ?? scheduleCounts[id as SlateSport] ?? null;
  };

  // Manual scan fallback URL — pre-fills /scan with the search query
  // so the user lands on a partially-filled form instead of empty.
  const manualScanHref = `/scan?q=${encodeURIComponent(searchQuery)}`;

  return (
    <div style={{ display: 'grid', gap: 24, paddingBottom: 24 }}>
      {/* Sticky header — search + tabs */}
      <div
        className="slate-sticky-head"
        style={{
          position: 'sticky',
          // Clears nav (60) + heartbeat (30) + the dynamic ticker (Session 57:
          // --ticker-h is 32px only when the ticker has ≥4 real items). P0-4:
          // on mobile the header collapses to the 60px app bar (ticker +
          // heartbeat hidden), so .slate-sticky-head is overridden to top:60px
          // in globals.css — otherwise it stuck 62px below the bar.
          top: 'calc(90px + var(--ticker-h, 0px))',
          zIndex: 5,
          background: 'var(--bg-0, #0A0A0F)',
          paddingTop: 12,
          paddingBottom: 12,
        }}
      >
        {/* Session 55 — the live signal strip: proves the data is alive. A
            pulsing dot, the graded-prop count, any in-progress games, and a
            ticking "updated Xs ago" freshness stamp fed by the 60s poll. */}
        <div
          className="mono"
          style={{
            display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap',
            fontSize: 10, letterSpacing: '0.08em', color: 'var(--text-secondary, #8A8A9A)',
            marginBottom: 10,
          }}
        >
          <span className="live-dot" aria-hidden style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--grade-a, #00D4A0)', display: 'inline-block' }} />
          <span style={{ color: 'var(--grade-a, #00D4A0)', fontWeight: 700 }}>SIGNAL LIVE</span>
          {snapGrades.length > 0 && (
            <><span style={{ color: '#3A3A48' }}>·</span><span>{snapGrades.length} PROPS GRADED</span></>
          )}
          {games.some((g) => g.status === 'in') && (
            <><span style={{ color: '#3A3A48' }}>·</span><span style={{ color: 'var(--g-a)', fontWeight: 700 }}>{games.filter((g) => g.status === 'in').length} LIVE</span></>
          )}
          {/* P1-6 — DATA age from the pipeline's refreshed_at (the same field the
              app-bar clock reads), NOT the client poll time. "UPDATED 0s ago"
              while the clock said "SYNC 46:03" was the contradiction; both now
              read refreshed_at. The clock owns the amber/STALE reaction. */}
          {snapRefreshedAt && (
            <><span style={{ color: '#3A3A48' }}>·</span><span title="Data age — the pipeline's last refresh (one source of truth with the app-bar clock)">UPDATED {freshLabel(snapRefreshedAt, nowTick)}</span></>
          )}
        </div>
        <input
          type="search"
          value={searchQuery}
          onChange={(e) => setSearchQuery(e.target.value)}
          placeholder="Search teams, players, stat types…"
          aria-label="Filter the slate"
          style={{
            width: '100%',
            padding: '10px 14px',
            background: 'var(--bg-2, #12121A)',
            border: '1px solid var(--border, #1A1A24)',
            borderRadius: 6,
            color: 'var(--text-0, #F0F0F5)',
            fontSize: 14,
            marginBottom: 12,
          }}
        />
        {/* Session 64 (A1-S5) — date navigation. Yesterday = results surface
            (finals + settled reads); Tomorrow = schedule until lines post. */}
        <div className="mono" style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
          {([[-1, 'YESTERDAY'], [0, 'TODAY'], [1, 'TOMORROW']] as [number, string][]).map(([off, label]) => (
            <button
              key={off}
              onClick={() => setDateOffset(off)}
              className="mono"
              style={{
                cursor: 'pointer', padding: '5px 12px', borderRadius: 6, fontSize: 10.5, fontWeight: 700, letterSpacing: '0.08em',
                background: dateOffset === off ? 'var(--g-a, #00D4A0)' : 'transparent',
                color: dateOffset === off ? '#06060B' : 'var(--text-1)',
                border: `1px solid ${dateOffset === off ? 'var(--g-a, #00D4A0)' : 'var(--border-hi)'}`,
              }}
            >
              {label}
            </button>
          ))}
          {dateOffset !== 0 && (
            <span className="mono" style={{ alignSelf: 'center', marginLeft: 8, fontSize: 10.5, color: 'var(--text-2)' }}>
              {etDateWithOffset(dateOffset)} · {dateOffset < 0 ? 'results + settled reads' : 'schedule — lines post on the day'}
            </span>
          )}
        </div>
        <div
          role="tablist"
          aria-label="Sport"
          style={{
            display: 'flex',
            gap: 6,
            overflowX: 'auto',
            paddingBottom: 2,
            WebkitOverflowScrolling: 'touch',
          }}
        >
          {TABS.map((t) => {
            const active = t.id === tab;
            return (
              <button
                key={t.id}
                type="button"
                role="tab"
                aria-selected={active}
                onClick={() => setTab(t.id)}
                className="mono"
                style={{
                  flexShrink: 0,
                  padding: '6px 14px',
                  fontSize: 11,
                  fontWeight: 700,
                  letterSpacing: '0.08em',
                  textTransform: 'uppercase',
                  border: active ? '1px solid var(--grade-a, #00D4A0)' : '1px solid var(--border, #1A1A24)',
                  background: active ? 'var(--grade-a, #00D4A0)' : 'transparent',
                  color: active ? 'var(--bg-0, #0A0A0F)' : 'var(--text-secondary, #8A8A9A)',
                  borderRadius: 4,
                  cursor: 'pointer',
                }}
              >
                {t.label}{tabCount(t.id) != null ? ` (${tabCount(t.id)})` : ''}
              </button>
            );
          })}
        </div>
        {/* Session 23/24 — stat filter pills, below the sport tabs and
            above all content. Sport-specific categories. Hidden on the
            ALL tab: filtering by "points" makes no sense when the slate
            mixes NBA + MLB + soccer. Pills appear only on a single sport. */}
        {tab !== 'all' && (
          <StatFilterPills
            sport={tab}
            activeStat={activeStat}
            onChange={setActiveStat}
          />
        )}
      </div>

      {/* Body */}
      {loading && (
        // Session 14 — shimmer skeletons replace the bare "Loading…" text.
        // Three placeholder cards approximating GameCard dimensions; the
        // shimmer animation lives in globals.css (`@keyframes
        // vyndr-shimmer`) so multiple loading surfaces stay in sync.
        <div style={{ display: 'grid', gap: 16 }} role="status" aria-label="Loading the slate">
          {[0, 1, 2].map((i) => (
            <div
              key={i}
              style={{
                background: 'var(--bg-2, #12121A)',
                border: '1px solid var(--border, #1A1A24)',
                borderRadius: 8,
                padding: 16,
                display: 'grid',
                gap: 10,
              }}
              aria-hidden="true"
            >
              <div style={skeletonStyle({ widthPct: 60, height: 18 })} />
              <div style={skeletonStyle({ widthPct: 40, height: 10 })} />
              <div style={{ display: 'grid', gap: 8, marginTop: 8 }}>
                <div style={skeletonStyle({ widthPct: 88, height: 14 })} />
                <div style={skeletonStyle({ widthPct: 70, height: 14 })} />
                <div style={skeletonStyle({ widthPct: 80, height: 14 })} />
              </div>
            </div>
          ))}
        </div>
      )}

      {/* Session 24 — soft notice when props are loading but the schedule
          (and lines) carry the slate. NOT a wall-of-error: the games are
          right below it. */}
      {oddsNotice && !loading && !fetchError && (
        <div
          style={{
            padding: '10px 14px',
            border: '1px solid var(--border, #1A1A24)',
            background: 'rgba(255,255,255,0.02)',
            color: 'var(--text-secondary, #8A8A9A)',
            borderRadius: 6,
            fontSize: 13,
          }}
        >
          Player props are loading — today&apos;s schedule, game lines, and stats are shown below.
        </div>
      )}

      {fetchError && !loading && (
        <div
          role="alert"
          style={{
            padding: 14,
            border: '1px solid var(--grade-d, #FF6B6B)',
            color: 'var(--grade-d, #FF6B6B)',
            borderRadius: 6,
            fontSize: 13,
          }}
        >
          {fetchError}
        </div>
      )}

      {/* A search miss keeps its own scan-it CTA. */}
      {!loading && !fetchError && filteredGames.length === 0 && searchQuery && (
        <div
          className="surface"
          style={{
            padding: 28,
            border: '1px solid var(--border, #1A1A24)',
            borderRadius: 8,
            textAlign: 'center',
            color: 'var(--text-secondary, #8A8A9A)',
          }}
        >
          <p style={{ marginBottom: 12 }}>
            No props found for &ldquo;{searchQuery}&rdquo;.
          </p>
          <a
            href={manualScanHref}
            className="btn-primary"
            style={{
              display: 'inline-block',
              padding: '8px 16px',
              background: 'var(--grade-a, #00D4A0)',
              color: 'var(--bg-0, #0A0A0F)',
              borderRadius: 4,
              textDecoration: 'none',
              fontSize: 13,
              fontWeight: 700,
            }}
          >
            Scan it manually →
          </a>
        </div>
      )}

      {/* Wave 4A (Step 3) — OUTLOOK MODE. Today's grid is never a dead-end CTA:
          when there are no live games (and no search, no fetch failure) it shows
          yesterday's proven receipts + tomorrow's real schedule. Yesterday/
          Tomorrow date nav keep the plain honest copy (those are explicit date
          surfaces; -1 already has THE SETTLE below). */}
      {!loading && !fetchError && filteredGames.length === 0 && !searchQuery && dateOffset === 0 && (
        <OutlookSurface tab={tab} />
      )}
      {!loading && !fetchError && filteredGames.length === 0 && !searchQuery && dateOffset !== 0 && (
        <div
          className="surface"
          style={{
            padding: 28,
            border: '1px solid var(--border, #1A1A24)',
            borderRadius: 8,
            textAlign: 'center',
            color: 'var(--text-secondary, #8A8A9A)',
          }}
        >
          {(() => {
            const { title, body } = emptyStateCopy(tab);
            return (
              <>
                <p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 6 }}>{title}</p>
                <p>{body}</p>
              </>
            );
          })()}
        </div>
      )}

      {dateOffset === -1 && <YesterdaySettle date={etDateWithOffset(-1)} />}

      {/* Wave 4A (Step 4) — the CONSENSUS vs MODEL strip. Self-hides unless a
          graded prop has a real ≥2-book median to compare the model against. */}
      {dateOffset === 0 && <MarketBreadth items={breadthItems} />}

      {(() => {
        // Build each game's card once, then present two ways (Rev 3, screen 01):
        // MOBILE = Design's FLAT edge-ranked board (all props, one list, sorted
        // by edge); DESKTOP = the game-grouped cards. Same data, different IA —
        // toggled by width in globals.css (.edge-board-mobile / -desktop).
        const cards = orderedGames.map((g) => slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat, viability, liveIndex));
        const edgeRows = flattenToEdgeBoard(cards);
        const breadth = edgeBoardBreadth(edgeRows, cards);
        const hasBoard = edgeRows.length > 0;
        return (
          <>
            {hasBoard && (
              <div className="edge-board-mobile" style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden' }}>
                <MobileEdgeBoard rows={edgeRows} breadth={breadth} />
              </div>
            )}
            {/* Cards: on mobile they hide ONLY when the flat board is present
                (an ungraded slate still shows the game cards on phones). */}
            <div className={hasBoard ? 'edge-board-desktop' : ''} style={{ display: 'grid', gap: 16 }}>
              {cards.map((card, i) => (
                <VyndrGameCard
                  key={`${card.sport}-${card.away.abbr}-${card.home.abbr}-${i}`}
                  game={card}
                  preferredBooks={preferredBooks}
                  onOpen={() => router.push('/scan')}
                />
              ))}
            </div>
          </>
        );
      })()}

      {/* Session 23 — intelligence layer. These coexist WITH the odds
          above; they never replace games. Both self-hide when empty, so
          an off-hours slate with no warm logs simply shows the games. */}
      <StreaksPanel sport={tab === 'all' ? 'nba' : tab} tier={tier} stat={activeStat} />
      <HotListPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} stat={activeStat} />
      {/* Session 28 — market layers: how lines are MOVING and where the
          BEST price sits. Both read cached data (zero credits) and
          self-hide until there's something to show. */}
      <MoversPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} />
      <BestLinesPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} />

      {/* Session 24 — removed the developer-facing "odds endpoint not
          configured yet" footer note. A sport with no data simply doesn't
          render a row; users never see internal wiring state. */}
    </div>
  );
}
