'use strict';

/**
 * MLB Stats API adapter (Session 30).
 *
 * Official MLB data from statsapi.mlb.com — FREE, no auth, unlimited. The
 * ground truth for MLB prop grading. Does NOT route through the provider
 * gateway (there's no quota to track); it caches in Redis with
 * stat-appropriate TTLs and degrades to null on any failure.
 *
 *   getScheduleWithPitchers(date)            — schedule + probable pitchers
 *   getPlayerGameLog(playerId, season, group)— per-game splits (recent form)
 *   getSeasonAverages(playerId, season, group)— season totals (AVG/OBP/SLG/…)
 *   getBatterVsPitcher(batterId, pitcherId)  — career/season matchup splits
 */

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

const BASE = 'https://statsapi.mlb.com/api/v1';
const HTTP_TIMEOUT_MS = 10_000;
const DEFAULT_SEASON = 2026;

const TTL = Object.freeze({
  schedule:  30 * 60,        // 30 min — lineups/probables update
  gameLog:   6 * 3600,       // 6 h — changes after games complete
  season:    6 * 3600,       // 6 h
  bvp:       24 * 3600,      // 24 h — rarely changes intraday
});

// No auth headers — this is a free, open API.
async function fetchWithCache(url, cacheKey, ttl) {
  const cached = await cacheGet(cacheKey);
  if (cached !== null) return cached;
  try {
    const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS });
    const data = res && res.data;
    if (data && typeof data === 'object') {
      await cacheSet(cacheKey, data, ttl);
      await cacheSet(`${cacheKey}:stale`, data, ttl * 4);
    }
    return data ?? null;
  } catch (err) {
    console.warn('[mlbStats] fetch failed:', url, err.message);
    const stale = await cacheGet(`${cacheKey}:stale`);
    return stale !== null ? stale : null;
  }
}

function ymd(date) {
  return String(date || '').slice(0, 10);
}

/**
 * Schedule + probable pitchers for a date. Returns a normalized array of
 * games, or [] when none / on failure.
 */
async function getScheduleWithPitchers(date) {
  if (!date) return [];
  const d = ymd(date);
  const url = `${BASE}/schedule?sportId=1&date=${d}&hydrate=probablePitcher(note)`;
  const data = await fetchWithCache(url, `mlbstats:schedule:${d}`, TTL.schedule);
  if (!data) return [];
  const games = (data.dates || []).flatMap((day) => day.games || []);
  return games.map((g) => ({
    gamePk: g.gamePk ?? null,
    gameDate: g.gameDate ?? null,
    status: g.status?.abstractGameState ?? null,
    venue: g.venue?.name ?? null,
    home: {
      team: g.teams?.home?.team?.name ?? null,
      teamId: g.teams?.home?.team?.id ?? null,
      probablePitcher: g.teams?.home?.probablePitcher
        ? { id: g.teams.home.probablePitcher.id, name: g.teams.home.probablePitcher.fullName ?? null }
        : null,
    },
    away: {
      team: g.teams?.away?.team?.name ?? null,
      teamId: g.teams?.away?.team?.id ?? null,
      probablePitcher: g.teams?.away?.probablePitcher
        ? { id: g.teams.away.probablePitcher.id, name: g.teams.away.probablePitcher.fullName ?? null }
        : null,
    },
  }));
}

// Pull the splits array out of the standard people/stats response shape.
function extractSplits(data) {
  if (!data || !Array.isArray(data.stats)) return [];
  return data.stats.flatMap((s) => s.splits || []);
}

/**
 * Per-game splits for a player. Returns an array of { date, opponent, stat }
 * (most recent last, as MLB returns chronologically). [] on failure.
 */
async function getPlayerGameLog(playerId, season = DEFAULT_SEASON, group = 'hitting') {
  if (!playerId) return [];
  const url = `${BASE}/people/${playerId}/stats?stats=gameLog&season=${season}&group=${group}`;
  const data = await fetchWithCache(url, `mlbstats:gamelog:${playerId}:${season}:${group}`, TTL.gameLog);
  return extractSplits(data).map((sp) => ({
    date: sp.date ?? null,
    opponent: sp.opponent?.name ?? null,
    isHome: sp.isHome ?? null,
    stat: sp.stat || {},
  }));
}

/**
 * Season averages for a player. Returns the season stat object (AVG, OBP,
 * SLG, OPS, homeRuns, rbi, …) or null.
 */
async function getSeasonAverages(playerId, season = DEFAULT_SEASON, group = 'hitting') {
  if (!playerId) return null;
  const url = `${BASE}/people/${playerId}/stats?stats=season&season=${season}&group=${group}`;
  const data = await fetchWithCache(url, `mlbstats:season:${playerId}:${season}:${group}`, TTL.season);
  const splits = extractSplits(data);
  return splits.length > 0 ? (splits[0].stat || null) : null;
}

/**
 * Batter-vs-pitcher matchup splits. Returns the matchup stat object or null.
 */
async function getBatterVsPitcher(batterId, pitcherId, group = 'hitting') {
  if (!batterId || !pitcherId) return null;
  const url = `${BASE}/people/${batterId}/stats?stats=vsPlayer&opposingPlayerId=${pitcherId}&group=${group}`;
  const data = await fetchWithCache(url, `mlbstats:bvp:${batterId}:${pitcherId}:${group}`, TTL.bvp);
  const splits = extractSplits(data);
  return splits.length > 0 ? (splits[0].stat || null) : null;
}

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

/**
 * Resolve a player name → MLB person record (Session 43; rebuilt Session 59,
 * work-order 1.6). Pulls the season player list (cached 24h) and matches on
 * the CANONICAL nameKey — accent-folded, suffix/nickname-resolved — so
 * "Sánchez" ≡ "Sanchez" and "Matt" ≡ "Matthew" resolve to ONE player.
 *
 * The old matcher lower-cased and stripped non-[a-z0-9], which DELETED
 * accented letters ("Sánchez" → "snchez" ≠ "sanchez") and then fell back to
 * a raw substring match that could silently return the WRONG player — the
 * audit's "last-10 opponents don't match his team" bug. The fallback now
 * requires a UNIQUE same-last-name + same-first-initial candidate; anything
 * ambiguous returns null (a missing profile beats another player's log).
 */
/**
 * Session 60 (night2/E, audit fix 4.1) — fuzzy MULTI-match against the
 * canonical list. Pure: rank = exact nameKey > last-name prefix > folded
 * substring. Case/diacritic-insensitive via nameKey's folding, so
 * "ohtani", "Sánchez", "sanchez", "Chisholm Jr" all resolve.
 */
function matchPlayers(people, query, limit = 12) {
  const qKey = nameKey(query);
  if (!qKey) return [];
  const qLast = qKey.split(' ').pop();
  const scored = [];
  for (const p of people || []) {
    const k = nameKey(p.fullName);
    if (!k) continue;
    let score = null;
    if (k === qKey) score = 0;
    else if (k.split(' ').some((w) => w.startsWith(qLast)) && qLast.length >= 3) score = 1;
    else if (k.includes(qKey)) score = 2;
    if (score == null) continue;
    scored.push({ score, p });
  }
  scored.sort((a, b) => a.score - b.score);
  return scored.slice(0, limit).map(({ p }) => ({
    id: p.id,
    fullName: p.fullName,
    team: p.currentTeam?.name ?? null,
    position: p.primaryPosition?.abbreviation ?? null,
  }));
}

/** Multi-result fuzzy search (scan search box). Cached list, free API. */
async function searchPlayers(query, opts = {}) {
  const season = opts.season || DEFAULT_SEASON;
  const url = `${BASE}/sports/1/players?season=${season}`;
  const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600);
  const people = (data && Array.isArray(data.people)) ? data.people : [];
  return matchPlayers(people, query, opts.limit || 12);
}

// ── Namesake disambiguation (Wave 1 · trust bug) ─────────────────────────────
// Two different players can share an EXACT nameKey ("James Wood" — the Nationals
// star + a Cubs-affiliate namesake). Taking the first `.find` match silently
// tagged the wrong team → wrong opponents → "built vs AL East" fabrication in
// streaks/rosterlogs. Doctrine: NEVER guess among namesakes. Resolve only with a
// confident team hint (the prop's game participants); otherwise refuse (null).

// The odds feed sends ESPN-style abbrs; statsapi uses its own for a few clubs.
// Canonicalize both sides so AZ↔ARI, CHW↔CWS, WSN↔WSH, … compare equal.
const ABBR_ALIAS = Object.freeze({
  AZ: 'ARI', ARI: 'ARI',
  CHW: 'CWS', CWS: 'CWS',
  WSN: 'WSH', WSH: 'WSH',
  SDP: 'SD', SD: 'SD',
  SFG: 'SF', SF: 'SF',
  TBR: 'TB', TB: 'TB',
  KCR: 'KC', KC: 'KC',
});
function canonAbbr(a) {
  const u = String(a || '').toUpperCase().trim();
  return ABBR_ALIAS[u] || u;
}
function teamNorm(s) {
  return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}

/**
 * Does a resolved team record (`{ id, name }`, from a candidate's currentTeam)
 * match ANY identifier in the prop's team hint? A hint entry may be an abbr
 * ("WSH") OR a full/partial team name ("Washington Nationals" / "Nationals").
 * teams = the cached statsapi `/teams` list ([{ id, abbr, name }]) used to turn
 * an abbr hint into a team id.
 */
function teamRecordMatchesHint(team, teamHint, teams) {
  if (!team || !Array.isArray(teamHint) || teamHint.length === 0) return false;
  const tId = team.id;
  const tName = teamNorm(team.name);
  for (const h of teamHint) {
    if (!h) continue;
    // 1) hint as a name (equal / either-contains — handles "Nationals" vs full)
    const hn = teamNorm(h);
    if (hn && tName && (hn === tName || tName.includes(hn) || hn.includes(tName))) return true;
    // 2) hint as an abbr → resolve to a team id via the teams list, compare ids
    const rec = (teams || []).find((t) => canonAbbr(t.abbr) === canonAbbr(h));
    if (rec && tId != null && rec.id === tId) return true;
  }
  return false;
}

/** Among namesake candidates, return the SINGLE one whose currentTeam matches
 *  the hint, else null (2+ or 0 matches → refuse; never guess). */
function disambiguateByHint(candidates, teamHint, teams) {
  if (!Array.isArray(teamHint) || teamHint.length === 0) return null;
  const matches = (candidates || []).filter((p) =>
    p.currentTeam && teamRecordMatchesHint({ id: p.currentTeam.id, name: p.currentTeam.name }, teamHint, teams));
  return matches.length === 1 ? matches[0] : null;
}

/** S59 fallback for the NO-exact-match case: a unique last-name + first-initial
 *  hit, else null. A missing profile beats another player's log. */
function lastNameInitialFallback(people, targetKey) {
  const parts = targetKey.split(' ');
  const first = parts[0] || '';
  const last = parts[parts.length - 1] || '';
  if (!(first && last && first !== last)) return null;
  const cands = (people || []).filter((p) => {
    const k = nameKey(p.fullName).split(' ');
    return k[k.length - 1] === last && k[0] && k[0][0] === first[0];
  });
  return cands.length === 1 ? cands[0] : null;
}

/**
 * Resolve a name → statsapi person. `opts.teamHint` (array of the prop's game
 * team identifiers) disambiguates namesakes AND enforces the join invariant:
 * when a hint is present but the resolved player's team is NOT a participant of
 * the prop's game, the team is DROPPED (returned null) rather than tagging a
 * foreign team downstream. `opts.people`/`opts.teams` inject fixtures for tests.
 */
async function searchPlayer(name, season = DEFAULT_SEASON, opts = {}) {
  const targetKey = nameKey(name);
  if (!targetKey) return null;

  let people = opts.people;
  if (!Array.isArray(people)) {
    const url = `${BASE}/sports/1/players?season=${season}`;
    const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600);
    people = (data && Array.isArray(data.people)) ? data.people : [];
  }

  const teamHint = Array.isArray(opts.teamHint) && opts.teamHint.length ? opts.teamHint : null;
  let teams = Array.isArray(opts.teams) ? opts.teams : null;
  const ensureTeams = async () => {
    if (teams) return teams;
    try { teams = await getTeams(season); } catch { teams = []; }
    return teams;
  };

  const exact = people.filter((p) => nameKey(p.fullName) === targetKey);
  let hit = null;
  let teamConfirmed = true; // stays true when there's no hint to check against

  if (exact.length === 1) {
    hit = exact[0];
    if (teamHint && hit.currentTeam) {
      teamConfirmed = teamRecordMatchesHint(
        { id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams());
    }
  } else if (exact.length >= 2) {
    // Namesake collision — resolve ONLY with a confident hint, else refuse.
    hit = teamHint ? disambiguateByHint(exact, teamHint, await ensureTeams()) : null;
    // a hit here is team-confirmed by construction.
  } else {
    hit = lastNameInitialFallback(people, targetKey);
    if (hit && teamHint && hit.currentTeam) {
      teamConfirmed = teamRecordMatchesHint(
        { id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams());
    }
  }

  if (!hit) return null;
  return {
    id: hit.id,
    fullName: hit.fullName ?? name,
    team: teamConfirmed ? (hit.currentTeam?.name ?? null) : null,
    teamId: teamConfirmed ? (hit.currentTeam?.id ?? null) : null,
    position: hit.primaryPosition?.abbreviation ?? null,
  };
}

/**
 * Name-keyed convenience: resolve the player, then fetch the season stat
 * object for the right group (pitching for pitchers, hitting otherwise) plus a
 * recent game log. Returns { found, id, name, team, position, group, season,
 * last10 } — `season` is the raw MLB stat object, mapped by the caller. Returns
 * { found: false } on any miss/failure (never throws).
 */
async function getPlayerStats(name, season = DEFAULT_SEASON, opts = {}) {
  try {
    const person = await searchPlayer(name, season, opts);
    if (!person) return { found: false };
    const group = person.position === 'P' ? 'pitching' : 'hitting';
    const [seasonStat, log] = await Promise.all([
      getSeasonAverages(person.id, season, group),
      getPlayerGameLog(person.id, season, group),
    ]);
    if (!seasonStat) return { found: false, id: person.id, name: person.fullName, team: person.team, position: person.position, group };
    return {
      found: true,
      id: person.id,
      name: person.fullName,
      team: person.team,
      position: person.position,
      group,
      season: seasonStat,
      last10: (log || []).slice(-10),
      // Session 64 — the FULL season log, already fetched above. Settlement
      // needs a specific DATE, and a rolling window silently loses games as it
      // rolls (verified: a Jul 12 game outside a last10 starting Jul 6). This
      // costs nothing extra — same request, same cache. Projections keep using
      // last10; only settlement reads fullLog.
      fullLog: log || [],
    };
  } catch (err) {
    console.warn('[mlbStats] getPlayerStats failed:', name, err.message);
    return { found: false };
  }
}

/**
 * All MLB teams (Session 51) → [{ id, abbr, name }]. Cached 24h. Used to map a
 * UI abbreviation ("NYY") to the statsapi team id.
 */
/**
 * Session 64 — LEAGUE-WIDE TEAM PITCHING SPLITS. All 30 teams in ONE free,
 * unauthenticated call. This is the source for MLB `opp_rank_stat`: ESPN's MLB
 * team endpoint carries no defensive metric at all, so engine1's +/-1.0
 * opponent factor had never fired for MLB.
 * Returns [{ teamId, name, stat }]. [] on failure — the caller degrades to a
 * null rank rather than a guessed one.
 */
async function getTeamPitchingStats(season = DEFAULT_SEASON) {
  const url = `${BASE}/teams/stats?season=${season}&group=pitching&stats=season&sportIds=1`;
  const data = await fetchWithCache(url, `mlbstats:teampitching:${season}`, TTL.season);
  const splits = extractSplits(data);
  return splits
    .map((sp) => ({
      teamId: sp.team && sp.team.id ? sp.team.id : null,
      name: sp.team && sp.team.name ? sp.team.name : null,
      stat: sp.stat || {},
    }))
    .filter((t) => t.teamId != null);
}

async function getTeams(season = DEFAULT_SEASON) {
  const url = `${BASE}/teams?sportId=1&season=${season}`;
  const data = await fetchWithCache(url, `mlbstats:teams:${season}`, 24 * 3600);
  const teams = (data && Array.isArray(data.teams)) ? data.teams : [];
  return teams.map((t) => ({ id: t.id, abbr: t.abbreviation || null, name: t.name || null }));
}

/** Resolve a team abbreviation → { id, abbr, name } or null. */
async function resolveTeam(abbr, season = DEFAULT_SEASON) {
  const a = String(abbr || '').toUpperCase();
  if (!a) return null;
  const teams = await getTeams(season);
  return teams.find((t) => String(t.abbr).toUpperCase() === a) || null;
}

/**
 * Active roster for a team id (Session 51) → [{ id, name, position, jersey }].
 * Cached 6h. [] on failure.
 */
async function getTeamRoster(teamId, season = DEFAULT_SEASON) {
  if (!teamId) return [];
  const url = `${BASE}/teams/${teamId}/roster?rosterType=active&season=${season}`;
  const data = await fetchWithCache(url, `mlbstats:roster:${teamId}:${season}`, 6 * 3600);
  const roster = (data && Array.isArray(data.roster)) ? data.roster : [];
  return roster.map((r) => ({
    id: r.person?.id ?? null,
    name: r.person?.fullName ?? null,
    position: r.position?.abbreviation ?? null,
    jersey: r.jerseyNumber ?? null,
  })).filter((p) => p.id && p.name);
}

module.exports = {
  getScheduleWithPitchers,
  getPlayerGameLog,
  getSeasonAverages,
  getBatterVsPitcher,
  searchPlayer,
  searchPlayers,
  matchPlayers,
  getPlayerStats,
  getTeams,
  getTeamPitchingStats,
  resolveTeam,
  getTeamRoster,
  __internals: {
    BASE, TTL, extractSplits, ymd, DEFAULT_SEASON,
    // Wave 1 — pure namesake-disambiguation helpers (unit-tested with fixtures).
    canonAbbr, teamNorm, teamRecordMatchesHint, disambiguateByHint, lastNameInitialFallback,
  },
};
