'use client';

import { useCallback, useEffect, useMemo, 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 } from '@/lib/slateAdapter';
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; 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[] }

// 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[] }

// 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'): 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;
  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) — pass the game's participants so the join
    // guard can drop bad feed rows (a player whose real team isn't in this game).
    playerStrips: buildPlayerStripsFromProps(props, gradeIndex, deltaIndex, Date.now(), { home: g.homeTeam, away: g.awayTeam }),
    // 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`;
}

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,
    });
  }
  // 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;
  });
}

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[]>([]);
  const [snapDeltas, setSnapDeltas] = useState<SnapshotDelta[]>([]);
  const [pitcherGames, setPitcherGames] = useState<PitcherGame[]>([]);
  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) => {
    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[];
        const [oddsResults, schedule, lines, streaksRes, snap, pitchersRes] = await Promise.all([
          Promise.all(oddsUrls.map((u) => getJson<OddsResponse>(u))),
          SCHEDULE_SPORTS.has(sport) ? getJson<ScheduleResponse>(`/api/schedule/${sport}`) : Promise.resolve(null),
          SCHEDULE_SPORTS.has(sport) ? getJson<GameLinesResponse>(`/api/gamelines/${sport}`) : Promise.resolve(null),
          SCHEDULE_SPORTS.has(sport) ? getJson<StreaksResponse>(`/api/streaks/${sport}`) : Promise.resolve(null),
          // Session 45 — pre-graded snapshot (locked grades + line deltas).
          getJson<SnapshotResponse>(`/api/snapshot/${sport}`),
          // Session 46 — MLB probable starting pitchers.
          sport === 'mlb' ? getJson<PitcherResponse>(`/api/schedule/mlb/pitchers`) : 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 || [], pitcherGames: pitchersRes?.games || [] };
      }),
    );

    const allGames: SlateGame[] = [];
    const allSnapGrades: SnapshotGrade[] = [];
    const allSnapDeltas: SnapshotDelta[] = [];
    const allPitcherGames: PitcherGame[] = [];
    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.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);
    setSnapDeltas(allSnapDeltas);
    setPitcherGames(allPitcherGames);
    setLastRefreshed(Date.now());

    // Odds down but schedule carried the slate → soft notice, not a wall.
    if (!silent && !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); }, [tab, fetchSlate]);

  // 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(() => { fetchSlate(tab, true); }, 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);
  }, []);

  // 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]);

  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]);

  // 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
        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).
          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(--live, #FF4757)', fontWeight: 700 }}>{games.filter((g) => g.status === 'in').length} LIVE</span></>
          )}
          {lastRefreshed && (
            <><span style={{ color: '#3A3A48' }}>·</span><span title="The slate auto-refreshes every 60 seconds">UPDATED {freshLabel(lastRefreshed, 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,
          }}
        />
        <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>
      )}

      {!loading && !fetchError && filteredGames.length === 0 && (
        <div
          className="surface"
          style={{
            padding: 28,
            border: '1px solid var(--border, #1A1A24)',
            borderRadius: 8,
            textAlign: 'center',
            color: 'var(--text-secondary, #8A8A9A)',
          }}
        >
          {searchQuery ? (
            <>
              <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>
            </>
          ) : (
            // Session 57 (Phase 0) — honest per-sport empty copy (spec §6):
            // off-season sports name their return window; in-season = off-day.
            (() => {
              const { title, body } = emptyStateCopy(tab);
              return (
                <>
                  <p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 6 }}>{title}</p>
                  <p>{body}</p>
                </>
              );
            })()
          )}
        </div>
      )}

      <div style={{ display: 'grid', gap: 16 }}>
        {filteredGames.map((g, i) => (
          <VyndrGameCard
            key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
            game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat)}
            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>
  );
}
