'use strict';

/**
 * espnStatsAdapter — best-effort NBA/WNBA season averages from ESPN (Session 45).
 *
 * The primary NBA/WNBA stats source (`nbaStatsClient`) depends on a Python
 * nba_api service that is frequently offline in prod. This adapter is a FREE,
 * no-auth fallback off ESPN's public site API. It is intentionally DEFENSIVE:
 * any shape it doesn't recognize → null (the caller degrades to found:false),
 * never a throw and never a wrong-but-confident number.
 *
 * Parsing is tolerant by design (ESPN's athlete-stats JSON varies by sport and
 * season), so `parseAthleteStats` is a pure, unit-tested function.
 */

const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const { nameKey } = require('../../utils/playerName');

const SEARCH = 'https://site.web.api.espn.com/apis/common/v3/search';
// Wave 0 — the v3 /search endpoint above now returns count:0 for every query
// (verified live 2026-07-13); the v2 search below is the reliable resolver and
// carries the numeric athlete id inside its `uid` ("s:40~l:59~a:3149391").
const SEARCH_V2 = 'https://site.api.espn.com/apis/search/v2';
const SPORT_PATH = { nba: 'basketball/nba', wnba: 'basketball/wnba' };
const SITE_V2 = 'https://site.api.espn.com/apis/site/v2/sports';
const TTL = 6 * 3600;
const GAMELOG_TTL = 4 * 3600; // per-game logs refresh once per night
const ROSTER_TTL = 24 * 3600; // team rosters change rarely
const TIMEOUT = 10_000;

// ESPN stat label → our classifier-input key. Lowercased, punctuation-stripped.
const STAT_MAP = {
  pointspergame: 'ppg', avgpoints: 'ppg', points: 'ppg', ppg: 'ppg',
  reboundspergame: 'rpg', avgrebounds: 'rpg', rebounds: 'rpg', rpg: 'rpg', totalrebounds: 'rpg',
  assistspergame: 'apg', avgassists: 'apg', assists: 'apg', apg: 'apg',
  blockspergame: 'bpg', avgblocks: 'bpg', blocks: 'bpg', bpg: 'bpg',
  stealspergame: 'spg', avgsteals: 'spg', steals: 'spg', spg: 'spg',
  threepointfieldgoalsmade: 'threes', threepointfieldgoalspergame: 'threes', avg3pointfieldgoalsmade: 'threes',
};

const keyify = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');

/**
 * Walk an ESPN athlete-stats payload and pull out per-game averages we can
 * classify. Returns a classifier-input object (possibly partial) or null when
 * nothing usable is found.
 */
function parseAthleteStats(payload) {
  if (!payload || typeof payload !== 'object') return null;
  const out = {};
  // ESPN nests stats under categories[].stats[] with { name|abbreviation, value|displayValue }.
  const categories = payload?.statistics?.splits?.categories
    || payload?.splits?.categories
    || payload?.categories
    || [];
  const visit = (statArr) => {
    for (const st of statArr || []) {
      const label = keyify(st.name || st.abbreviation || st.label);
      const mapped = STAT_MAP[label];
      if (!mapped) continue;
      const val = Number(st.value != null ? st.value : st.displayValue);
      if (Number.isFinite(val) && out[mapped] == null) out[mapped] = val;
    }
  };
  for (const cat of categories) visit(cat.stats);
  if (Array.isArray(payload.stats)) visit(payload.stats); // flat fallback
  return Object.keys(out).length > 0 ? out : null;
}

async function fetchJson(url, http) {
  const client = http || axios;
  const res = await client.get(url, { timeout: TIMEOUT });
  return res && res.data;
}

/**
 * Resolve a player's NBA/WNBA season averages from ESPN. Returns
 * { found, team, position, classifierInput } or { found:false }. Never throws.
 * opts.http injectable for tests.
 */
async function getSeasonAverages(name, sport, opts = {}) {
  const sp = String(sport || '').toLowerCase();
  const path = SPORT_PATH[sp];
  if (!path || !name) return { found: false };
  const cacheKey = `espnstats:${sp}:${keyify(name)}`;
  try {
    const cached = await cacheGet(cacheKey);
    if (cached) return cached;
  } catch { /* ignore */ }

  try {
    // 1. Resolve the athlete id via ESPN search.
    const search = await fetchJson(`${SEARCH}?query=${encodeURIComponent(name)}&limit=5&sport=${encodeURIComponent(path)}`, opts.http);
    const items = (search && (search.items || search.results)) || [];
    const athlete = items.find((it) => keyify(it.displayName || it.name) === keyify(name)) || items[0];
    const id = athlete && (athlete.id || athlete.uid || (athlete.athlete && athlete.athlete.id));
    if (!id) return { found: false };

    // Wave 2A — the REAL ESPN athlete id for the headshot CDN
    // (a.espncdn.com/i/headshots/{league}/players/full/{espnId}.png). Prefer the
    // pure numeric id; a `uid` string ("s:40~l:46~a:…") is NOT a valid headshot
    // id, so it degrades to null → monogram. Never fabricate.
    const numericId = (athlete && (athlete.id ?? (athlete.athlete && athlete.athlete.id))) ?? null;
    const espnId = numericId != null && /^\d+$/.test(String(numericId)) ? String(numericId) : null;

    // 2. Fetch that athlete's stats overview.
    const stats = await fetchJson(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/stats`, opts.http);
    const classifierInput = parseAthleteStats(stats);
    if (!classifierInput) return { found: false };

    const result = {
      found: true,
      team: (athlete.team && (athlete.team.abbreviation || athlete.team.displayName)) || '',
      position: (athlete.position && athlete.position.abbreviation) || '',
      classifierInput,
      // Wave 2A — surfaced so resolvePlayerStats can thread it to the grade →
      // slate strip → headshot. Absent → monogram (doctrine).
      espnId,
    };
    try { await cacheSet(cacheKey, result, TTL); } catch { /* ignore */ }
    return result;
  } catch (err) {
    console.warn('[espnStats] season averages failed:', name, sp, err.message);
    return { found: false };
  }
}

// ─────────────────────────────────────────────────────────────────────────
// Wave 0 — FREE per-game logs for NBA/WNBA (the grade unlock).
//
// The Python nba_api service (gameLogService) is offline in prod, so
// featureCache's non-MLB branch produced no l5/l20 → projectionFor returned
// null → the ENTIRE NBA/WNBA slate refused. ESPN's per-athlete gamelog is free,
// no-auth, and returns per-game rows (VERIFIED LIVE: NBA id 1966 → 73 rows,
// WNBA id 3149391 → 23 rows). This mirrors mlbStatsAdapter.getPlayerGameLog's
// output contract ({ found, id, last10:[{ date, opponent, isHome, stat:{…} }] })
// so it drops straight into the existing feature pipeline.
//
// The per-game `stats[]` array is aligned to the response's OWN `names[]`
// array — and that order DIFFERS between NBA and WNBA — so columns are indexed
// by name token, never by a hardcoded position.
// ─────────────────────────────────────────────────────────────────────────

// ESPN `names[]` token → our per-game stat field. Compound cells ("9-23" =
// made-attempted) take the MADE portion. pra is COMPUTED (pts+reb+ast), never
// a raw column.
function toNum(v) {
  const n = parseFloat(v);
  return Number.isFinite(n) ? n : null;
}
function madeOf(v) {
  if (v == null) return null;
  const s = String(v);
  const dash = s.indexOf('-');
  const n = parseInt(dash >= 0 ? s.slice(0, dash) : s, 10);
  return Number.isFinite(n) ? n : null;
}

function buildGameStat(statArr, idxOf) {
  if (!Array.isArray(statArr)) return null;
  const at = (token, fn = toNum) => {
    const i = idxOf[token];
    return i == null ? null : fn(statArr[i]);
  };
  const out = {};
  const points = at('points');
  const rebounds = at('totalRebounds');
  const assists = at('assists');
  const threes = at('threePointFieldGoalsMade-threePointFieldGoalsAttempted', madeOf);
  const steals = at('steals');
  const blocks = at('blocks');
  const turnovers = at('turnovers');
  const minutes = at('minutes');
  // Absent beats zero — only attach a field ESPN actually reported.
  if (points != null) out.points = points;
  if (rebounds != null) out.rebounds = rebounds;
  if (assists != null) out.assists = assists;
  if (threes != null) out.threes = threes;
  if (steals != null) out.steals = steals;
  if (blocks != null) out.blocks = blocks;
  if (turnovers != null) out.turnovers = turnovers;
  if (minutes != null) out.minutes = minutes;
  if (points != null && rebounds != null && assists != null) out.pra = points + rebounds + assists;
  return Object.keys(out).length ? out : null;
}

/**
 * PURE: parse an ESPN athlete-gamelog payload into per-game rows, most-recent
 * first. Returns an array or null (unrecognized shape). NEVER throws.
 */
function parseGameLog(payload) {
  if (!payload || typeof payload !== 'object') return null;
  const names = Array.isArray(payload.names) ? payload.names : null;
  if (!names || names.length === 0) return null;
  const idxOf = {};
  names.forEach((n, i) => { if (idxOf[String(n)] == null) idxOf[String(n)] = i; });
  const evMap = (payload.events && typeof payload.events === 'object') ? payload.events : {};
  const seen = new Set();
  const rows = [];
  for (const st of Array.isArray(payload.seasonTypes) ? payload.seasonTypes : []) {
    for (const cat of (st && Array.isArray(st.categories)) ? st.categories : []) {
      for (const ev of (cat && Array.isArray(cat.events)) ? cat.events : []) {
        const eid = ev && ev.eventId;
        if (!eid || seen.has(eid)) continue;
        const stat = buildGameStat(ev.stats, idxOf);
        if (!stat) continue;
        seen.add(eid);
        const meta = evMap[eid] || {};
        const isHome = meta.atVs === 'vs' ? true : (meta.atVs === '@' ? false : null);
        rows.push({
          date: meta.gameDate || null,
          opponent: (meta.opponent && (meta.opponent.abbreviation || meta.opponent.displayName)) || null,
          isHome,
          stat,
        });
      }
    }
  }
  if (rows.length === 0) return null;
  // Most-recent first. Undated rows sink to the end without reordering.
  rows.sort((a, b) => {
    const ta = a.date ? Date.parse(a.date) : NaN;
    const tb = b.date ? Date.parse(b.date) : NaN;
    if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
    if (Number.isNaN(ta)) return 1;
    if (Number.isNaN(tb)) return -1;
    return tb - ta;
  });
  return rows.slice(0, 20);
}

async function fetchJsonG(url, opts = {}) {
  if (typeof opts.fetchImpl === 'function') return opts.fetchImpl(url);
  const client = opts.http || axios;
  const res = await client.get(url, { timeout: TIMEOUT });
  return res && res.data;
}

// Seasons to try when a gamelog comes back league-ambiguous (filters-only).
// ESPN keys WNBA/NBA seasons on a calendar year; the current + previous year
// cover the in-season window and the year boundary. Newest first.
function candidateSeasons() {
  const y = new Date().getFullYear();
  return [y, y - 1];
}

// ─────────────────────────────────────────────────────────────────────────
// Resolver hardening — ESPN TEAM ROSTERS are the PRIMARY id source.
//
// The v2 /search resolver is unreliable at the edges (near-miss namesakes,
// dual-league athletes) and depends on a live search call per name. ESPN's
// team-roster feed is a COMPLETE, cacheable id source: every athlete on every
// team of the league, keyed by the canonical accent-folded `nameKey`. We build
// it once (bounded concurrency over the ~15–30 teams), cache it (Redis
// `espnroster:{sport}` 24h + in-memory mirror), and look names up there FIRST;
// the v2 search stays as a backstop for a roster miss. A unique roster hit wins
// (S59 doctrine) — a missing name beats guessing another player's id.
// ─────────────────────────────────────────────────────────────────────────

// A roster's `athletes` may be a flat array of athlete objects OR grouped by
// position ([{ position, items:[…] }]). Flatten both; skip anything unusable.
function flattenAthletes(athletes) {
  if (!Array.isArray(athletes)) return [];
  const out = [];
  for (const a of athletes) {
    if (!a || typeof a !== 'object') continue;
    if (Array.isArray(a.items)) out.push(...a.items); // grouped shape
    else out.push(a);
  }
  return out;
}

const rosterMem = new Map(); // sport → { nameKey → { id, displayName, teamId } }

/**
 * Aggregate every team's roster for an NBA/WNBA league into a
 * { nameKey → { id, displayName, teamId } } map. Cached (Redis
 * `espnroster:{sport}` 24h + in-memory mirror). Bounded concurrency over the
 * team fetches; a team that fails is skipped (partial index, never a throw).
 * Returns {} on total failure. opts.fetchImpl/opts.http injectable for tests.
 */
async function buildAthleteRosterIndex(sport, opts = {}) {
  const sp = String(sport || '').toLowerCase();
  const path = SPORT_PATH[sp];
  if (!path) return {};
  if (rosterMem.has(sp)) return rosterMem.get(sp);
  const ck = `espnroster:${sp}`;
  try {
    const cached = await cacheGet(ck);
    if (cached && cached.index && Object.keys(cached.index).length) {
      rosterMem.set(sp, cached.index);
      return cached.index;
    }
  } catch { /* ignore cache read */ }

  try {
    const teamsData = await fetchJsonG(`${SITE_V2}/${path}/teams`, opts);
    const teams = teamsData?.sports?.[0]?.leagues?.[0]?.teams || [];
    const teamIds = teams.map((t) => t && t.team && t.team.id).filter((x) => x != null).map(String);
    if (teamIds.length === 0) return {};

    const index = {};
    let cursor = 0;
    const CONCURRENCY = 6;
    async function worker() {
      while (cursor < teamIds.length) {
        const tid = teamIds[cursor++];
        try {
          const r = await fetchJsonG(`${SITE_V2}/${path}/teams/${tid}/roster`, opts);
          for (const a of flattenAthletes(r && r.athletes)) {
            const id = a && a.id;
            const dn = a && (a.displayName || a.fullName);
            if (id == null || !/^\d+$/.test(String(id)) || !dn) continue;
            const k = nameKey(dn);
            if (!k) continue;
            // First writer wins — a team is a full roster; don't overwrite.
            if (!index[k]) index[k] = { id: String(id), displayName: String(dn), teamId: String(tid) };
          }
        } catch { /* skip a team that fails — partial index beats none */ }
      }
    }
    await Promise.all(Array.from({ length: Math.min(CONCURRENCY, teamIds.length) }, worker));

    if (Object.keys(index).length === 0) return {}; // never cache/mirror an empty index
    rosterMem.set(sp, index);
    try { await cacheSet(ck, { index }, ROSTER_TTL); } catch { /* ignore cache write */ }
    return index;
  } catch (err) {
    console.warn('[espnStats] roster index failed:', sp, err.message);
    return {};
  }
}

/** PRIMARY: name → numeric id via the complete team-roster index. null on miss. */
async function resolveAthleteIdViaRoster(name, sport, opts = {}) {
  const index = await buildAthleteRosterIndex(sport, opts);
  const hit = index[nameKey(name)];
  return hit ? String(hit.id) : null;
}

/**
 * BACKSTOP: resolve name → ESPN numeric athlete id via the v2 search.
 * Disambiguates by `defaultLeagueSlug` (nba/wnba — an NCAA namesake is NOT
 * returned for a WNBA query), then prefers an exact canonical-name match.
 * Returns the numeric id string or null (a missing id beats the wrong player).
 */
async function resolveAthleteIdViaSearch(name, sport, opts = {}) {
  const data = await fetchJsonG(`${SEARCH_V2}?query=${encodeURIComponent(name)}&limit=10`, opts);
  const section = (data && Array.isArray(data.results) ? data.results : []).find((r) => r && r.type === 'player');
  const players = (section && Array.isArray(section.contents)) ? section.contents : [];
  if (players.length === 0) return null;
  const inLeague = players.filter((p) => String(p.defaultLeagueSlug || '').toLowerCase() === sport);
  const pool = inLeague.length ? inLeague : players;
  const target = nameKey(name);
  const exact = pool.find((p) => nameKey(p.displayName || p.name || '') === target);
  const chosen = exact || pool[0];
  if (!chosen) return null;
  const m = /a:(\d+)/.exec(String(chosen.uid || ''));
  if (m) return m[1];
  return /^\d+$/.test(String(chosen.id)) ? String(chosen.id) : null;
}

/**
 * Resolve name → ESPN numeric athlete id. Tries the complete team-roster index
 * FIRST (reliable, cached), falls back to the v2 search on a roster miss.
 */
async function resolveAthleteId(name, sport, opts = {}) {
  const viaRoster = await resolveAthleteIdViaRoster(name, sport, opts);
  if (viaRoster) return viaRoster;
  return resolveAthleteIdViaSearch(name, sport, opts);
}

const gameLogMem = new Map();

/**
 * NBA/WNBA per-game logs from ESPN. Returns { found, id, last10 } (most-recent
 * first) or { found:false }. Never throws. Cached (Redis `espngamelog:{sport}:
 * {id}` 4h + in-memory mirror). opts.fetchImpl/opts.http injectable for tests.
 */
async function getPlayerGameLog(name, sport, opts = {}) {
  const sp = String(sport || '').toLowerCase();
  const path = SPORT_PATH[sp];
  if (!path || !name) return { found: false };
  try {
    const id = await resolveAthleteId(name, sp, opts);
    if (!id) return { found: false };
    const ck = `espngamelog:${sp}:${id}`;
    if (gameLogMem.has(ck)) return gameLogMem.get(ck);
    try {
      const cached = await cacheGet(ck);
      if (cached) { gameLogMem.set(ck, cached); return cached; }
    } catch { /* ignore cache read */ }

    const gamelogUrl = `https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/gamelog`;
    let last10 = parseGameLog(await fetchJsonG(gamelogUrl, opts));
    if (!last10) {
      // Dual-league athletes (WNBA + NCAA — e.g. Napheesa Collier, Brionna
      // Jones) get a filters-only gamelog until a season is specified (verified
      // live 2026-07). Retry with candidate seasons before giving up. Only fires
      // on a parse miss, so the common path is untouched.
      for (const yr of candidateSeasons()) {
        last10 = parseGameLog(await fetchJsonG(`${gamelogUrl}?season=${yr}`, opts));
        if (last10) break;
      }
    }
    if (!last10) return { found: false, id };
    const result = { found: true, id, last10 };
    gameLogMem.set(ck, result);
    try { await cacheSet(ck, result, GAMELOG_TTL); } catch { /* ignore cache write */ }
    return result;
  } catch (err) {
    console.warn('[espnStats] game log failed:', name, sp, err.message);
    return { found: false };
  }
}

module.exports = {
  getSeasonAverages,
  parseAthleteStats,
  getPlayerGameLog,
  parseGameLog,
  resolveAthleteId,
  buildAthleteRosterIndex,
  __internals: {
    STAT_MAP, keyify, SPORT_PATH, buildGameStat, madeOf, toNum,
    gameLogMem, rosterMem, flattenAthletes,
    resolveAthleteIdViaRoster, resolveAthleteIdViaSearch, candidateSeasons,
  },
};
