'use strict';

/**
 * liveTrackingService — LIVE TRACKING (A1 board, Session 11).
 *
 * The read is locked pre-game; the game is watched. This service produces the
 * per-player CURRENT box-line values for in-progress games so the slate can
 * render live progress marks — proto-outcomes in the OUTCOME slot region
 * (specs/LIVE-TRACKING.md, specs/ROW-GRAMMAR.md §2 slot 6). GRADES NEVER
 * CHANGE IN-GAME.
 *
 * Sources (both FREE, zero out-of-pocket):
 *   MLB  — statsapi.mlb.com: schedule?hydrate=linescore identifies Live games
 *          AND carries inning progress in ONE call; then
 *          /api/v1/game/{gamePk}/boxscore per live game.
 *   WNBA — ESPN scoreboard (state 'in' + period) identifies live events; then
 *          the ESPN summary?event= boxscore per live event.
 *
 * POLLING RULE / quota math: getLiveTracking caches `live:{sport}:{date}` for
 * LIVE_TTL (90s). The cost during live windows is 1 schedule call + N boxscore
 * calls per 90s ACROSS ALL USERS (shared cache); when nothing is live the
 * cached { hasLive: false } envelope means one schedule check per 90s while
 * anyone polls, and ZERO boxscore calls. The frontend only polls while live
 * games are on screen.
 *
 * DATA SEMANTICS: only real box-line values. A player who hasn't appeared has
 * EMPTY stats objects in the MLB boxscore (or didNotPlay / an empty stats row
 * on ESPN) → he is ABSENT from the output, never 0.
 *
 * Everything is injectable (fetchJson, cacheGet, cacheSet) → parsers and the
 * whole refresh cycle are unit-tested on REAL captured feed shapes with zero
 * network (fixtures captured live 2026-07-11).
 */

const { nameKey } = require('../utils/playerName');

const LIVE_TTL = 90; // seconds — the shared polling window
const HTTP_TIMEOUT_MS = 10_000;
const MLB_BASE = 'https://statsapi.mlb.com/api/v1';
const ESPN_WNBA_BASE = 'https://site.api.espn.com/apis/site/v2/sports/basketball/wnba';

// ---------------------------------------------------------------------------
// MLB stat map — VYNDR stat_type → { group, field } in a LIVE boxscore player.
//
// This is a LOCAL map on purpose (same rule as outcomeService.MLB_LOG_FIELD):
// settlement reads the post-game GAME LOG, where statsapi has already picked
// ONE stat group for the row by position. The LIVE boxscore instead carries
// BOTH `stats.batting` and `stats.pitching` per player, so each stat must name
// its group explicitly — and innings_pitched must be parsed in thirds
// ('5.2' = 5⅔ ≈ 5.667) for pace math, which parseFloat gets wrong. Coupling
// the two maps would let a settlement-side change silently break live pace
// (or vice versa). If you add an MLB stat_type, wire it HERE for tracking and
// in outcomeService.MLB_LOG_FIELD for settlement.
// ---------------------------------------------------------------------------
const LIVE_BOX_FIELD = {
  hits:            { group: 'batting',  field: 'hits' },
  total_bases:     { group: 'batting',  field: 'totalBases' },
  home_runs:       { group: 'batting',  field: 'homeRuns' },
  rbi:             { group: 'batting',  field: 'rbi' },
  runs:            { group: 'batting',  field: 'runs' },
  stolen_bases:    { group: 'batting',  field: 'stolenBases' },
  doubles:         { group: 'batting',  field: 'doubles' },
  triples:         { group: 'batting',  field: 'triples' },
  walks:           { group: 'batting',  field: 'baseOnBalls' },
  // Pitcher props. `strikeouts` is the PITCHER prop in the VYNDR vocabulary
  // (pitcher_strikeouts market) — resolved from stats.pitching.
  strikeouts:      { group: 'pitching', field: 'strikeOuts' },
  earned_runs:     { group: 'pitching', field: 'earnedRuns' },
  hits_allowed:    { group: 'pitching', field: 'hits' },
  outs:            { group: 'pitching', field: 'outs' },
  innings_pitched: { group: 'pitching', field: 'inningsPitched', parse: ipToDecimal },
};

/** MLB innings string in thirds → decimal ('5.2' = 5 + 2/3). Null-strict. */
function ipToDecimal(v) {
  if (v == null || v === '') return null;
  const [whole, partial] = String(v).split('.');
  const w = Number(whole);
  if (!Number.isFinite(w)) return null;
  const p = Number(partial) || 0;
  return w + p / 3;
}

/** Strict numeric read — null when absent/unparseable, NEVER a fabricated 0. */
function numOrNull(v) {
  if (v == null || v === '') return null;
  const n = Number(v);
  return Number.isFinite(n) ? n : null;
}

// ---------------------------------------------------------------------------
// MLB parsers (pure)
// ---------------------------------------------------------------------------

/**
 * Pure: statsapi schedule JSON (hydrate=linescore) → live games only:
 * [{ gamePk, home, away, progress }]. progress = { label, fraction, inning,
 * half, scheduledInnings }. Games without a parseable linescore still list
 * (progress null) — the boxscore values are real either way.
 */
function parseMlbLiveSchedule(scheduleJson) {
  const games = ((scheduleJson || {}).dates || []).flatMap((d) => d.games || []);
  return games
    .filter((g) => g && g.status && g.status.abstractGameState === 'Live')
    .map((g) => ({
      gamePk: g.gamePk ?? null,
      home: g.teams?.home?.team?.name ?? null,
      away: g.teams?.away?.team?.name ?? null,
      progress: mlbProgress(g.linescore),
    }));
}

/**
 * Pure: an MLB linescore → { label, fraction, inning, half, scheduledInnings }.
 * Fraction = (inning − (top ? 1 : 0.5)) / scheduled — mid-inning estimate for
 * pace math (Bottom 8th of 9 → 7.5/9 ≈ 0.833). Label uses ▲ (top) / ▼ (bottom):
 * '▲6th' / '▼8th'. Null when the feed carries no inning yet.
 */
function mlbProgress(linescore) {
  const ls = linescore || {};
  const inning = numOrNull(ls.currentInning);
  if (inning == null) return null;
  const scheduled = numOrNull(ls.scheduledInnings) || 9;
  // inningState: Top | Middle | Bottom | End. Middle/End sit between halves —
  // count them as the completed half (top done → same fraction as bottom start).
  const state = String(ls.inningState || (ls.isTopInning ? 'Top' : 'Bottom')).toLowerCase();
  const top = state === 'top';
  const fraction = Math.min(1, Math.max(0, (inning - (top ? 1 : 0.5)) / scheduled));
  const ord = ordinal(inning);
  return {
    label: `${top ? '▲' : '▼'}${ord}`,
    fraction,
    inning,
    half: top ? 'top' : 'bottom',
    scheduledInnings: scheduled,
  };
}

function ordinal(n) {
  const s = ['th', 'st', 'nd', 'rd'];
  const v = n % 100;
  return `${n}${s[(v - 20) % 10] || s[v] || s[0]}`;
}

/**
 * Pure: an MLB live boxscore → { [nameKey]: { name, team, values } } where
 * `values` maps VYNDR stat_type → current number for every wired stat the
 * player has REAL box data for. A player with empty batting AND pitching
 * objects has not appeared → OMITTED entirely (absent, never 0).
 */
function parseMlbBoxscore(boxJson) {
  const out = {};
  const teams = (boxJson || {}).teams || {};
  for (const side of ['home', 'away']) {
    const t = teams[side] || {};
    const teamName = t.team?.name ?? null;
    const players = t.players || {};
    for (const key of Object.keys(players)) {
      const p = players[key];
      const name = p?.person?.fullName;
      if (!name) continue;
      const batting = p.stats?.batting;
      const pitching = p.stats?.pitching;
      const hasBatting = batting && Object.keys(batting).length > 0;
      const hasPitching = pitching && Object.keys(pitching).length > 0;
      if (!hasBatting && !hasPitching) continue; // not in the game yet — absent
      const values = {};
      for (const [statType, m] of Object.entries(LIVE_BOX_FIELD)) {
        const groupObj = m.group === 'batting' ? (hasBatting ? batting : null) : (hasPitching ? pitching : null);
        if (!groupObj) continue;
        const raw = groupObj[m.field];
        const val = m.parse ? m.parse(raw) : numOrNull(raw);
        if (val != null) values[statType] = val;
      }
      if (Object.keys(values).length === 0) continue;
      out[nameKey(name)] = { name, team: teamName, values };
    }
  }
  return out;
}

// ---------------------------------------------------------------------------
// WNBA parsers (pure) — ESPN site API
// ---------------------------------------------------------------------------

// VYNDR stat_type → the ESPN boxscore `keys` entry. `threes` is the made count
// parsed from the "M-A" pair; `pra` is computed from components (never read).
const WNBA_BOX_KEY = {
  points: 'points',
  rebounds: 'rebounds',
  assists: 'assists',
  steals: 'steals',
  blocks: 'blocks',
  turnovers: 'turnovers',
  threes: 'threePointFieldGoalsMade-threePointFieldGoalsAttempted',
};

/**
 * Pure: ESPN scoreboard JSON → live events only:
 * [{ id, home, away, progress }]. progress fraction = (period − 0.5) / 4,
 * clamped (OT caps at 1) — mid-period estimate, label 'Q{n}' / 'OT'.
 */
function parseWnbaLiveScoreboard(scoreboardJson) {
  const events = (scoreboardJson || {}).events || [];
  return events
    .filter((e) => e?.status?.type?.state === 'in')
    .map((e) => {
      const comp = e.competitions?.[0] || {};
      const competitors = comp.competitors || [];
      const home = competitors.find((c) => c.homeAway === 'home');
      const away = competitors.find((c) => c.homeAway === 'away');
      const period = numOrNull(e.status?.period);
      const progress = period == null ? null : {
        label: period > 4 ? (period === 5 ? 'OT' : `${period - 4}OT`) : `Q${period}`,
        fraction: Math.min(1, Math.max(0, (period - 0.5) / 4)),
        period,
      };
      return {
        id: String(e.id),
        home: home?.team?.displayName ?? null,
        away: away?.team?.displayName ?? null,
        progress,
      };
    });
}

/**
 * Pure: an ESPN summary boxscore → { [nameKey]: { name, team, values } }.
 * ESPN ships per-team `statistics[0]` with a parallel `keys` array and
 * per-athlete `stats` string arrays. didNotPlay or an empty stats row →
 * the player has not appeared → OMITTED (absent, never 0). `pra` is computed
 * only when points, rebounds AND assists are all present.
 */
function parseWnbaBoxscore(summaryJson) {
  const out = {};
  const teams = (summaryJson || {}).boxscore?.players || [];
  for (const t of teams) {
    const teamName = t.team?.displayName ?? t.team?.abbreviation ?? null;
    const block = (t.statistics || [])[0];
    if (!block || !Array.isArray(block.keys) || !Array.isArray(block.athletes)) continue;
    const idx = {};
    block.keys.forEach((k, i) => { idx[k] = i; });
    for (const a of block.athletes) {
      const name = a?.athlete?.displayName;
      if (!name) continue;
      const row = a.stats;
      if (a.didNotPlay || !Array.isArray(row) || row.length === 0) continue; // absent
      const values = {};
      for (const [statType, key] of Object.entries(WNBA_BOX_KEY)) {
        const i = idx[key];
        if (i == null || row[i] == null) continue;
        const raw = String(row[i]);
        const val = statType === 'threes' ? numOrNull(raw.split('-')[0]) : numOrNull(raw);
        if (val != null) values[statType] = val;
      }
      if (values.points != null && values.rebounds != null && values.assists != null) {
        values.pra = values.points + values.rebounds + values.assists;
      }
      if (Object.keys(values).length === 0) continue;
      out[nameKey(name)] = { name, team: teamName, values };
    }
  }
  return out;
}

// ---------------------------------------------------------------------------
// Fetch + cache layer
// ---------------------------------------------------------------------------

async function defaultFetchJson(url) {
  const axios = require('axios');
  const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS });
  return res && res.data ? res.data : null;
}

function resolveDeps(opts = {}) {
  return {
    fetchJson: opts.fetchJson || defaultFetchJson,
    cacheGet: opts.cacheGet || require('../utils/redis').cacheGet,
    cacheSet: opts.cacheSet || require('../utils/redis').cacheSet,
  };
}

/** Today in ET (sports days roll over on ET, not UTC). */
function todayET() {
  return new Intl.DateTimeFormat('en-CA', {
    timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
  }).format(new Date());
}

/**
 * Build the fresh live envelope for one sport (network path — called only on
 * a cache miss). One schedule/scoreboard call; boxscore calls ONLY for games
 * the schedule marks live. Per-game failures degrade to an empty players map
 * for that game — the rest of the slate still tracks.
 */
async function fetchLiveTracking(sport, date, deps) {
  const sp = String(sport || '').toLowerCase();
  if (sp === 'mlb') {
    const sched = await deps.fetchJson(`${MLB_BASE}/schedule?sportId=1&date=${date}&hydrate=linescore`);
    const live = parseMlbLiveSchedule(sched);
    const games = [];
    for (const g of live) {
      let players = {};
      try {
        const box = await deps.fetchJson(`${MLB_BASE}/game/${g.gamePk}/boxscore`);
        players = parseMlbBoxscore(box);
      } catch (e) {
        console.warn('[liveTracking] mlb boxscore failed:', g.gamePk, e.message);
      }
      games.push({ id: String(g.gamePk), home: g.home, away: g.away, progress: g.progress, players });
    }
    return { sport: sp, date, hasLive: games.length > 0, games };
  }
  if (sp === 'wnba') {
    const board = await deps.fetchJson(`${ESPN_WNBA_BASE}/scoreboard`);
    const live = parseWnbaLiveScoreboard(board);
    const games = [];
    for (const g of live) {
      let players = {};
      try {
        const summary = await deps.fetchJson(`${ESPN_WNBA_BASE}/summary?event=${encodeURIComponent(g.id)}`);
        players = parseWnbaBoxscore(summary);
      } catch (e) {
        console.warn('[liveTracking] wnba summary failed:', g.id, e.message);
      }
      games.push({ id: g.id, home: g.home, away: g.away, progress: g.progress, players });
    }
    return { sport: sp, date, hasLive: games.length > 0, games };
  }
  // Other sports: no free live box feed wired — honest empty, never a guess.
  return { sport: sp, date, hasLive: false, games: [] };
}

/**
 * Cache-aside live read (the route's entrypoint). `live:{sport}:{date}` TTL
 * 90s — the shared polling window. A no-live result is ALSO cached for 90s so
 * idle polling costs one schedule check per window, zero boxscore calls.
 * Never throws: any failure returns an empty envelope.
 */
async function getLiveTracking(sport, opts = {}) {
  const deps = resolveDeps(opts);
  const sp = String(sport || '').toLowerCase();
  const date = opts.date || todayET();
  const key = `live:${sp}:${date}`;
  try {
    const cached = await deps.cacheGet(key);
    if (cached !== null) return cached;
    const fresh = await fetchLiveTracking(sp, date, deps);
    fresh.updated_at = new Date().toISOString();
    await deps.cacheSet(key, fresh, LIVE_TTL);
    return fresh;
  } catch (err) {
    console.warn(`[liveTracking] ${sp} failed:`, err.message);
    return { sport: sp, date, hasLive: false, games: [], error: 'unavailable' };
  }
}

module.exports = {
  getLiveTracking,
  fetchLiveTracking,
  parseMlbLiveSchedule,
  parseMlbBoxscore,
  parseWnbaLiveScoreboard,
  parseWnbaBoxscore,
  mlbProgress,
  __internals: { LIVE_BOX_FIELD, WNBA_BOX_KEY, ipToDecimal, numOrNull, ordinal, LIVE_TTL, todayET, MLB_BASE, ESPN_WNBA_BASE },
};
