'use strict';

/**
 * liveTrackingService (A1 Session 11) — parsers + cache-aside refresh.
 *
 * Fixtures below are TRIMMED FROM REAL FEEDS captured live on 2026-07-11
 * while PHI @ DET (gamePk 824249) was in the bottom of the 8th:
 *   statsapi.mlb.com/api/v1/schedule?sportId=1&date=2026-07-11&hydrate=linescore
 *   statsapi.mlb.com/api/v1/game/824249/boxscore
 *   site.api.espn.com/.../wnba/scoreboard + summary?event=401857057
 * Player stat values are the real box numbers from that capture.
 */

const svc = require('../../src/services/liveTrackingService');
const {
  parseMlbLiveSchedule,
  parseMlbBoxscore,
  parseWnbaLiveScoreboard,
  parseWnbaBoxscore,
  mlbProgress,
  fetchLiveTracking,
  getLiveTracking,
} = svc;
const { ipToDecimal, LIVE_TTL } = svc.__internals;

// ── MLB fixtures (real shape, captured 2026-07-11) ─────────────────────
const mlbSchedule = {
  dates: [{
    games: [
      {
        gamePk: 823357,
        status: { abstractGameState: 'Final', detailedState: 'Final' },
        teams: { away: { team: { name: 'Milwaukee Brewers' } }, home: { team: { name: 'Pittsburgh Pirates' } } },
      },
      {
        gamePk: 824249,
        status: { abstractGameState: 'Live', detailedState: 'In Progress' },
        teams: { away: { team: { name: 'Philadelphia Phillies' } }, home: { team: { name: 'Detroit Tigers' } } },
        linescore: { currentInning: 8, inningState: 'Bottom', isTopInning: false, scheduledInnings: 9 },
      },
      {
        gamePk: 823276,
        status: { abstractGameState: 'Preview', detailedState: 'Pre-Game' },
        teams: { away: { team: { name: 'Toronto Blue Jays' } }, home: { team: { name: 'San Diego Padres' } } },
      },
    ],
  }],
};

const mlbBoxscore = {
  teams: {
    home: {
      team: { name: 'Detroit Tigers' },
      players: {
        ID669373: { person: { id: 669373, fullName: 'Tarik Skubal' }, position: { abbreviation: 'P' }, stats: { batting: {}, pitching: {} } }, // not in game — absent
        ID123456: {
          person: { id: 123456, fullName: 'Hao-Yu Lee' }, position: { abbreviation: '2B' },
          stats: { batting: { hits: 2, totalBases: 2, homeRuns: 0, rbi: 0, runs: 0, stolenBases: 0, doubles: 0, baseOnBalls: 0, strikeOuts: 0, atBats: 4 }, pitching: {} },
        },
        ID663554: {
          person: { id: 663554, fullName: 'Casey Mize' }, position: { abbreviation: 'P' },
          stats: { batting: {}, pitching: { strikeOuts: 5, earnedRuns: 3, inningsPitched: '5.2', outs: 17, hits: 5, baseOnBalls: 2 } },
        },
      },
    },
    away: {
      team: { name: 'Philadelphia Phillies' },
      players: {
        ID547180: {
          person: { id: 547180, fullName: 'Bryce Harper' }, position: { abbreviation: '1B' },
          stats: { batting: { hits: 2, totalBases: 3, homeRuns: 0, rbi: 0, runs: 0, stolenBases: 0, doubles: 1, baseOnBalls: 0, strikeOuts: 0 }, pitching: {} },
        },
        ID650911: {
          person: { id: 650911, fullName: 'Cristopher Sánchez' }, position: { abbreviation: 'P' },
          stats: { batting: {}, pitching: { strikeOuts: 7, earnedRuns: 1, inningsPitched: '7.0', outs: 21, hits: 9, baseOnBalls: 1 } },
        },
      },
    },
  },
};

// ── WNBA fixtures (real ESPN shapes, captured 2026-07-11; the scoreboard's
// live event is constructed on the documented shape with status.state 'in'
// since all three games were final at capture time — stated honestly) ────
const wnbaScoreboard = {
  events: [
    {
      id: '401857057',
      status: { type: { state: 'in' }, period: 3, displayClock: '4:12' },
      competitions: [{
        competitors: [
          { homeAway: 'home', team: { displayName: 'Minnesota Lynx' } },
          { homeAway: 'away', team: { displayName: 'New York Liberty' } },
        ],
      }],
    },
    {
      id: '401857059',
      status: { type: { state: 'post' }, period: 4 },
      competitions: [{ competitors: [] }],
    },
  ],
};

const wnbaSummary = {
  boxscore: {
    players: [
      {
        team: { abbreviation: 'NY', displayName: 'New York Liberty' },
        statistics: [{
          keys: ['minutes', 'points', 'fieldGoalsMade-fieldGoalsAttempted', 'threePointFieldGoalsMade-threePointFieldGoalsAttempted', 'freeThrowsMade-freeThrowsAttempted', 'rebounds', 'assists', 'turnovers', 'steals', 'blocks', 'offensiveRebounds', 'defensiveRebounds', 'fouls', 'plusMinus'],
          athletes: [
            { athlete: { displayName: 'Breanna Stewart' }, starter: true, didNotPlay: false, stats: ['37', '17', '7-16', '2-3', '1-1', '7', '3', '4', '2', '1', '3', '4', '1', '-4'] },
            { athlete: { displayName: 'Satou Sabally' }, didNotPlay: true, stats: [] }, // DNP — absent
          ],
        }],
      },
    ],
  },
};

describe('ipToDecimal — innings in thirds', () => {
  test('parses MLB innings notation correctly (5.2 = 5⅔, NOT parseFloat)', () => {
    expect(ipToDecimal('5.2')).toBeCloseTo(5 + 2 / 3, 5);
    expect(ipToDecimal('7.0')).toBe(7);
    expect(ipToDecimal('0.1')).toBeCloseTo(1 / 3, 5);
  });
  test('null-strict — absent is null, never 0', () => {
    expect(ipToDecimal(null)).toBeNull();
    expect(ipToDecimal('')).toBeNull();
    expect(ipToDecimal('x')).toBeNull();
  });
});

describe('parseMlbLiveSchedule', () => {
  test('returns ONLY Live games with inning progress', () => {
    const live = parseMlbLiveSchedule(mlbSchedule);
    expect(live).toHaveLength(1);
    expect(live[0].gamePk).toBe(824249);
    expect(live[0].home).toBe('Detroit Tigers');
    expect(live[0].away).toBe('Philadelphia Phillies');
    expect(live[0].progress.label).toBe('▼8th');
    expect(live[0].progress.fraction).toBeCloseTo(7.5 / 9, 5);
    expect(live[0].progress.half).toBe('bottom');
  });
  test('empty/malformed schedule → no live games, no throw', () => {
    expect(parseMlbLiveSchedule(null)).toEqual([]);
    expect(parseMlbLiveSchedule({})).toEqual([]);
  });
});

describe('mlbProgress', () => {
  test('top of an inning counts the full inning as remaining', () => {
    const p = mlbProgress({ currentInning: 4, inningState: 'Top', scheduledInnings: 9 });
    expect(p.label).toBe('▲4th');
    expect(p.fraction).toBeCloseTo(3 / 9, 5);
  });
  test('Middle (between halves) counts as the completed top', () => {
    const p = mlbProgress({ currentInning: 4, inningState: 'Middle', scheduledInnings: 9 });
    expect(p.fraction).toBeCloseTo(3.5 / 9, 5);
  });
  test('extra innings clamp at 1', () => {
    const p = mlbProgress({ currentInning: 11, inningState: 'Bottom', scheduledInnings: 9 });
    expect(p.fraction).toBe(1);
  });
  test('no inning yet → null (absent beats wrong)', () => {
    expect(mlbProgress({})).toBeNull();
    expect(mlbProgress(null)).toBeNull();
  });
  test('ordinals — 1st/2nd/3rd/11th', () => {
    expect(mlbProgress({ currentInning: 1, inningState: 'Top' }).label).toBe('▲1st');
    expect(mlbProgress({ currentInning: 2, inningState: 'Top' }).label).toBe('▲2nd');
    expect(mlbProgress({ currentInning: 3, inningState: 'Bottom' }).label).toBe('▼3rd');
    expect(mlbProgress({ currentInning: 11, inningState: 'Top' }).label).toBe('▲11th');
  });
});

describe('parseMlbBoxscore — real live box values, absent beats wrong', () => {
  const players = parseMlbBoxscore(mlbBoxscore);

  test('batter values map to VYNDR stat types (real Bryce Harper line)', () => {
    const harper = players['bryce harper'];
    expect(harper).toBeDefined();
    expect(harper.team).toBe('Philadelphia Phillies');
    expect(harper.values.hits).toBe(2);
    expect(harper.values.total_bases).toBe(3);
    expect(harper.values.doubles).toBe(1);
    expect(harper.values.home_runs).toBe(0); // he HAS batted — real 0, not fabricated
  });

  test('pitcher values come from stats.pitching incl. IP in thirds (real Casey Mize line)', () => {
    const mize = players['casey mize'];
    expect(mize.values.strikeouts).toBe(5);
    expect(mize.values.earned_runs).toBe(3);
    expect(mize.values.outs).toBe(17);
    expect(mize.values.hits_allowed).toBe(5);
    expect(mize.values.innings_pitched).toBeCloseTo(5 + 2 / 3, 5);
  });

  test('a player with EMPTY stats objects has not appeared → absent, never 0', () => {
    expect(players['tarik skubal']).toBeUndefined();
  });

  test('accented names key on the folded nameKey', () => {
    expect(players['cristopher sanchez']).toBeDefined();
    expect(players['cristopher sanchez'].values.strikeouts).toBe(7);
  });

  test('malformed input → empty map, no throw', () => {
    expect(parseMlbBoxscore(null)).toEqual({});
    expect(parseMlbBoxscore({ teams: {} })).toEqual({});
  });
});

describe('parseWnbaLiveScoreboard', () => {
  test('returns only in-progress events with quarter progress', () => {
    const live = parseWnbaLiveScoreboard(wnbaScoreboard);
    expect(live).toHaveLength(1);
    expect(live[0].id).toBe('401857057');
    expect(live[0].home).toBe('Minnesota Lynx');
    expect(live[0].progress.label).toBe('Q3');
    expect(live[0].progress.fraction).toBeCloseTo(2.5 / 4, 5);
  });
  test('overtime labels + clamp', () => {
    const board = { events: [{ id: '1', status: { type: { state: 'in' }, period: 5 }, competitions: [{ competitors: [] }] }] };
    const live = parseWnbaLiveScoreboard(board);
    expect(live[0].progress.label).toBe('OT');
    expect(live[0].progress.fraction).toBe(1);
  });
  test('empty board → []', () => {
    expect(parseWnbaLiveScoreboard(null)).toEqual([]);
  });
});

describe('parseWnbaBoxscore — real ESPN summary shape', () => {
  const players = parseWnbaBoxscore(wnbaSummary);

  test('maps the keys array onto per-athlete stat rows (real Stewart line)', () => {
    const stew = players['breanna stewart'];
    expect(stew).toBeDefined();
    expect(stew.team).toBe('New York Liberty');
    expect(stew.values.points).toBe(17);
    expect(stew.values.rebounds).toBe(7);
    expect(stew.values.assists).toBe(3);
    expect(stew.values.threes).toBe(2); // made, parsed from '2-3'
    expect(stew.values.steals).toBe(2);
    expect(stew.values.blocks).toBe(1);
    expect(stew.values.turnovers).toBe(4);
    expect(stew.values.pra).toBe(17 + 7 + 3);
  });

  test('didNotPlay / empty stats row → absent, never 0', () => {
    expect(players['satou sabally']).toBeUndefined();
  });

  test('malformed input → empty map, no throw', () => {
    expect(parseWnbaBoxscore(null)).toEqual({});
    expect(parseWnbaBoxscore({ boxscore: {} })).toEqual({});
  });
});

describe('fetchLiveTracking — schedule identifies live games, boxscores only for them', () => {
  test('MLB: 1 schedule call + 1 boxscore per LIVE game', async () => {
    const calls = [];
    const fetchJson = jest.fn(async (url) => {
      calls.push(url);
      if (url.includes('/schedule')) return mlbSchedule;
      if (url.includes('/game/824249/boxscore')) return mlbBoxscore;
      throw new Error(`unexpected url ${url}`);
    });
    const out = await fetchLiveTracking('mlb', '2026-07-11', { fetchJson });
    expect(out.hasLive).toBe(true);
    expect(out.games).toHaveLength(1);
    expect(out.games[0].id).toBe('824249');
    expect(out.games[0].progress.label).toBe('▼8th');
    expect(out.games[0].players['bryce harper'].values.total_bases).toBe(3);
    // Quota math: exactly 1 schedule + 1 boxscore (one live game).
    expect(calls.filter((u) => u.includes('/schedule'))).toHaveLength(1);
    expect(calls.filter((u) => u.includes('/boxscore'))).toHaveLength(1);
  });

  test('MLB: no live games → schedule only, ZERO boxscore calls', async () => {
    const fetchJson = jest.fn(async () => ({ dates: [{ games: [{ gamePk: 1, status: { abstractGameState: 'Preview' } }] }] }));
    const out = await fetchLiveTracking('mlb', '2026-07-11', { fetchJson });
    expect(out.hasLive).toBe(false);
    expect(out.games).toEqual([]);
    expect(fetchJson).toHaveBeenCalledTimes(1);
  });

  test('WNBA: scoreboard + one summary per live event', async () => {
    const fetchJson = jest.fn(async (url) => {
      if (url.includes('/scoreboard')) return wnbaScoreboard;
      if (url.includes('summary?event=401857057')) return wnbaSummary;
      throw new Error(`unexpected url ${url}`);
    });
    const out = await fetchLiveTracking('wnba', '2026-07-11', { fetchJson });
    expect(out.hasLive).toBe(true);
    expect(out.games[0].players['breanna stewart'].values.points).toBe(17);
    expect(fetchJson).toHaveBeenCalledTimes(2);
  });

  test('a per-game boxscore failure degrades that game, not the envelope', async () => {
    const fetchJson = jest.fn(async (url) => {
      if (url.includes('/schedule')) return mlbSchedule;
      throw new Error('boom');
    });
    const out = await fetchLiveTracking('mlb', '2026-07-11', { fetchJson });
    expect(out.hasLive).toBe(true);
    expect(out.games[0].players).toEqual({});
  });

  test('unwired sport → honest empty, no fetches', async () => {
    const fetchJson = jest.fn();
    const out = await fetchLiveTracking('nba', '2026-07-11', { fetchJson });
    expect(out).toEqual({ sport: 'nba', date: '2026-07-11', hasLive: false, games: [] });
    expect(fetchJson).not.toHaveBeenCalled();
  });
});

describe('getLiveTracking — cache-aside, TTL 90s (the POLLING RULE)', () => {
  test('cache HIT → no upstream fetch at all', async () => {
    const cached = { sport: 'mlb', hasLive: true, games: [{ id: 'x' }] };
    const fetchJson = jest.fn();
    const cacheGet = jest.fn(async () => cached);
    const cacheSet = jest.fn();
    const out = await getLiveTracking('mlb', { fetchJson, cacheGet, cacheSet, date: '2026-07-11' });
    expect(out).toBe(cached);
    expect(fetchJson).not.toHaveBeenCalled();
    expect(cacheSet).not.toHaveBeenCalled();
    expect(cacheGet).toHaveBeenCalledWith('live:mlb:2026-07-11');
  });

  test('cache MISS + live games → fetch + write with LIVE_TTL', async () => {
    const fetchJson = jest.fn(async (url) => (url.includes('/schedule') ? mlbSchedule : mlbBoxscore));
    const cacheGet = jest.fn(async () => null);
    const cacheSet = jest.fn();
    const out = await getLiveTracking('mlb', { fetchJson, cacheGet, cacheSet, date: '2026-07-11' });
    expect(out.hasLive).toBe(true);
    expect(out.updated_at).toBeTruthy();
    expect(cacheSet).toHaveBeenCalledWith('live:mlb:2026-07-11', expect.objectContaining({ hasLive: true }), LIVE_TTL);
  });

  test('cache MISS + nothing live → the no-live envelope is ALSO cached (idle polling stays cheap)', async () => {
    const fetchJson = jest.fn(async () => ({ dates: [] }));
    const cacheGet = jest.fn(async () => null);
    const cacheSet = jest.fn();
    const out = await getLiveTracking('mlb', { fetchJson, cacheGet, cacheSet, date: '2026-07-11' });
    expect(out.hasLive).toBe(false);
    expect(cacheSet).toHaveBeenCalledWith('live:mlb:2026-07-11', expect.objectContaining({ hasLive: false }), LIVE_TTL);
  });

  test('upstream failure → empty envelope, never a throw', async () => {
    const fetchJson = jest.fn(async () => { throw new Error('down'); });
    const cacheGet = jest.fn(async () => null);
    const cacheSet = jest.fn();
    const out = await getLiveTracking('mlb', { fetchJson, cacheGet, cacheSet, date: '2026-07-11' });
    expect(out.hasLive).toBe(false);
    expect(out.error).toBe('unavailable');
  });
});
