/**
 * Feature cache — the central feature-vector builder for every prop.
 *
 * Philosophy: features are OMITTED when the underlying data source is
 * unavailable, never zeroed. Engine 2 handles variable-length feature
 * sets; a zero would lie to the model about what we actually know.
 *
 * Per-feature TTL categories (Redis):
 *   game_log:    4h    — game logs refresh once per night
 *   team:        24h   — opponent stats are daily
 *   coach:       30d   — coach profiles are rare to change
 *   ref:         12h   — assignments published morning of game day
 *   injury:      2h    — injuries change at shootaround
 *   line:        2m    — line state changes constantly during the day
 *   context:     none  — computed on demand (home/away, rest days)
 *
 * Cache key: features:{sport}:{playerId}:{statType}:{gameId}
 * The full vector is cached for 2 minutes so repeated calls during the
 * same grading cycle don't recompute. After 2 minutes, individual
 * features get refreshed from their own caches.
 */

const { cacheGet, cacheSet } = require('../../utils/redis');
const { getTeamStats, getOpponentRank } = require('./teamStatsCache');
const { getRefImpact } = require('./refSignals');
const { getCoachImpact } = require('./coachSignals');
const { roleValue } = require('./lineupSignals');
const { getTeamInjuries } = require('./injuryParser');
const { getLineMovement } = require('./lineMovement');
const gameLogs = require('./gameLogService');

const VECTOR_TTL_SECONDS = 120;

function avg(values) {
  const clean = values.filter((v) => Number.isFinite(v));
  if (clean.length === 0) return null;
  return clean.reduce((a, b) => a + b, 0) / clean.length;
}

function stddev(values) {
  const clean = values.filter((v) => Number.isFinite(v));
  if (clean.length < 2) return null;
  const mean = avg(clean);
  const sq = clean.reduce((sum, v) => sum + (v - mean) ** 2, 0);
  return Math.sqrt(sq / (clean.length - 1));
}

// Extract a stat value from a single game-log entry by stat type. Game-log
// rows out of the Python service are keyed by stat name (points,
// rebounds, etc.) and combo stats need to be summed at read time.
function statFromGameLog(row, statType) {
  if (!row) return null;
  switch (statType) {
    case 'pts_reb_ast': {
      const s = (Number(row.points) || 0) + (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
      return s;
    }
    case 'pts_reb':
      return (Number(row.points) || 0) + (Number(row.rebounds) || 0);
    case 'pts_ast':
      return (Number(row.points) || 0) + (Number(row.assists) || 0);
    case 'reb_ast':
      return (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
    case 'stl_blk':
      return (Number(row.steals) || 0) + (Number(row.blocks) || 0);
    default: {
      const v = Number(row[statType]);
      return Number.isFinite(v) ? v : null;
    }
  }
}

function daysBetween(aIso, bIso) {
  const ms = new Date(aIso).getTime() - new Date(bIso).getTime();
  if (!Number.isFinite(ms)) return null;
  return Math.floor(ms / (1000 * 60 * 60 * 24));
}

// MLB stat_type → the per-game field name in a statsapi.mlb.com game-log row.
const MLB_LOG_FIELD = {
  total_bases: 'totalBases', home_runs: 'homeRuns', hits: 'hits', rbi: 'rbi',
  runs: 'runs', stolen_bases: 'stolenBases', walks: 'baseOnBalls',
  strikeouts: 'strikeOuts', earned_runs: 'earnedRuns', hits_allowed: 'hits',
  innings_pitched: 'inningsPitched',
  // Session 56 audit — real boxscore/game-log fields (Braves@Pirates verified).
  doubles: 'doubles', triples: 'triples', outs: 'outs',
};

function mlbStatValue(statObj, statType) {
  const f = MLB_LOG_FIELD[statType];
  if (!f || !statObj) return null;
  const n = parseFloat(statObj[f]);
  return Number.isFinite(n) ? n : null;
}

// Wave 0 — NBA/WNBA stat_type → the per-game field on an espnStatsAdapter
// gamelog row's `stat` object. A SEPARATE local map from MLB_LOG_FIELD (the
// S11 three-map-split rule — never merge). Combo stats route to statFromGameLog
// (which sums points/rebounds/assists at read time); `pra`/`threes` are already
// normalized fields on the row. A stat_type absent here does NOT grade via this
// path (absent beats a fabricated projection). Add a new NBA/WNBA stat here to
// unlock it.
const NBA_LOG_FIELD = {
  points: 'points', rebounds: 'rebounds', assists: 'assists',
  threes: 'threes', steals: 'steals', blocks: 'blocks', turnovers: 'turnovers',
  pra: 'pra',
  // combos (statFromGameLog sums the raw components)
  pts_reb_ast: 'pts_reb_ast', pts_reb: 'pts_reb', pts_ast: 'pts_ast',
  reb_ast: 'reb_ast', stl_blk: 'stl_blk',
};

/**
 * Session 46 — derive recent/season averages from a real MLB game log
 * (mlbStatsAdapter.getPlayerStats result). PURE so it's unit-testable without
 * the network. Produces l5_avg / l10_avg (recent form) + l20_avg (the season
 * per-game reference) — exactly the fields buildIntelFields consumes, which the
 * NBA/WNBA-only Python game-log path never populated for MLB.
 */
function mlbGameLogFeatures(res, statType) {
  if (!res || !res.found) return {};
  const out = {};
  const logs = Array.isArray(res.last10) ? res.last10 : [];
  const vals = logs.map((g) => mlbStatValue(g.stat, statType)).filter((v) => v != null);
  if (vals.length) {
    const m5 = avg(vals.slice(-5)); // game logs are chronological (recent last)
    const m10 = avg(vals.slice(-10));
    const s10 = stddev(vals.slice(-10));
    if (m5 != null) out.l5_avg = m5;
    if (m10 != null) out.l10_avg = m10;
    if (s10 != null) out.l10_stddev = s10;
  }
  const seasonTotal = mlbStatValue(res.season, statType);
  const games = parseFloat(res.season && (res.season.gamesPlayed ?? res.season.gamesStarted ?? res.season.gamesPitched));
  if (seasonTotal != null && Number.isFinite(games) && games > 0) {
    out.l20_avg = seasonTotal / games;
  } else if (out.l10_avg != null) {
    out.l20_avg = out.l10_avg; // baseline so form has a reference
  }

  // Session 47 — complete VYNDR INTELLIGENCE for MLB:
  //  - rest_days: days between the two most recent games (0 = back-to-back).
  //  - ab_per_game: at-bats per game, the MLB "usage" equivalent.
  const dated = logs.filter((g) => g && g.date);
  if (dated.length >= 2) {
    const last = new Date(dated[dated.length - 1].date).getTime();
    const prev = new Date(dated[dated.length - 2].date).getTime();
    const gap = Math.round((last - prev) / 86_400_000);
    // rest_days = days OFF (0 = played the day before = B2B), matching the
    // NBA convention buildIntelFields uses. Consecutive calendar days → 0.
    if (Number.isFinite(gap) && gap >= 1 && gap <= 14) out.rest_days = gap - 1;
  }
  const ab = parseFloat(res.season && res.season.atBats);
  if (Number.isFinite(ab) && Number.isFinite(games) && games > 0) {
    out.ab_per_game = ab / games;
  }
  return out;
}

/**
 * Wave 0 — derive recent/season averages from an ESPN NBA/WNBA gamelog
 * (espnStatsAdapter.getPlayerGameLog result). PURE + unit-testable. The result's
 * last10 is MOST-RECENT-FIRST, so l5 = first 5, l20 = all available (the season
 * per-game reference projectionFor needs). Emits the SAME fields the Python
 * path did, plus rest_days + minutes_per_game (usage), so the grade card lights
 * up. Returns {} when the log is missing or the stat_type isn't mapped.
 */
function nbaGameLogFeatures(res, statType) {
  if (!res || !res.found) return {};
  const field = NBA_LOG_FIELD[statType];
  if (!field) return {};
  const logs = Array.isArray(res.last10) ? res.last10 : [];
  const vals = logs.map((g) => statFromGameLog(g && g.stat, field)).filter((v) => v != null);
  const out = {};
  if (vals.length) {
    const m5 = avg(vals.slice(0, 5));   // most-recent first
    const m10 = avg(vals.slice(0, 10));
    const m20 = avg(vals.slice(0, 20));
    const s10 = stddev(vals.slice(0, 10));
    if (m5 != null) out.l5_avg = m5;
    if (m10 != null) out.l10_avg = m10;
    if (m20 != null) out.l20_avg = m20;
    if (s10 != null) out.l10_stddev = s10;
  }

  // rest_days from the two most-recent dated games (0 = back-to-back), mirroring
  // the MLB branch + the NBA convention buildIntelFields reads.
  const dated = logs.filter((g) => g && g.date);
  if (dated.length >= 2) {
    const last = new Date(dated[0].date).getTime();      // most recent
    const prev = new Date(dated[1].date).getTime();
    const gap = Math.round((last - prev) / 86_400_000);
    if (Number.isFinite(gap) && gap >= 1 && gap <= 14) out.rest_days = gap - 1;
  }

  // minutes_per_game — the NBA "usage" equivalent buildIntelFields surfaces.
  const mins = logs.map((g) => g && g.stat && Number(g.stat.minutes)).filter((v) => Number.isFinite(v));
  const mpg = avg(mins);
  if (mpg != null) out.minutes_per_game = mpg;
  return out;
}

/**
 * Session 63 — NORMALIZED PER-GAME STAT ROWS.
 *
 * The probability estimator (`probabilityEstimator.estimateProbability`) and the
 * consistency scorer both read a game-log row as `row[statType]`. The ONLY
 * producer wired to them was `gameLogService.getGameLogs`, which returns null for
 * MLB by construction and depends on the offline Python service for NBA/WNBA —
 * so `meta.gameLogs` was `[]` for every sport in production and every
 * probability-derived output (p_win, ev_pct, kelly, model_odds, value) was
 * silently skipped, along with the ±1.0 consistency factor.
 *
 * This is the S46 fix applied to the SECOND location: same adapters, same maps
 * (no new stat map — the three-map-split rule stands), emitting rows in the shape
 * those two consumers already expect:
 *
 *     [{ date, [statType]: value }, ...]   MOST-RECENT-FIRST
 *
 * Most-recent-first matters: the estimator treats `values.slice(0, 5)` as the
 * recency window. Returns [] (never null) when no real log exists — absent beats
 * a fabricated distribution.
 */
async function getStatRows(playerName, sport, statType) {
  const sp = String(sport || '').toLowerCase();
  const rows = [];
  const push = (date, value) => {
    if (value == null || !Number.isFinite(Number(value))) return;
    rows.push({ date: date || null, [statType]: Number(value) });
  };

  try {
    if (sp === 'mlb') {
      const mlbStats = require('../adapters/mlbStatsAdapter');
      const res = await mlbStats.getPlayerStats(playerName);
      const logs = (res && res.found && Array.isArray(res.last10)) ? res.last10 : [];
      // MLB logs are chronological (most recent LAST) — reverse to match.
      for (const g of [...logs].reverse()) push(g && g.date, mlbStatValue(g && g.stat, statType));
      return rows;
    }

    // NBA/WNBA — Python service first (it's the richer source when it's up),
    // then the FREE ESPN per-athlete gamelog. Same order as gameLogFeatures.
    const pyLogs = await gameLogs.getGameLogs(playerName, sp, 20);
    if (Array.isArray(pyLogs) && pyLogs.length) {
      // Python rows are already flat + most-recent-first.
      for (const r of pyLogs) push(r && r.date, statFromGameLog(r, statType));
      return rows;
    }

    if (sp === 'nba' || sp === 'wnba') {
      const espnStats = require('../adapters/espnStatsAdapter');
      const res = await espnStats.getPlayerGameLog(playerName, sp);
      const logs = (res && res.found && Array.isArray(res.last10)) ? res.last10 : [];
      const field = NBA_LOG_FIELD[statType];
      if (!field) return rows;   // unmapped stat → no rows, never a guess
      // ESPN last10 is most-recent-first already.
      for (const g of logs) push(g && g.date, statFromGameLog(g && g.stat, field));
    }
    return rows;
  } catch (e) {
    console.warn('[featureCache] getStatRows failed:', e.message);
    return [];
  }
}

/** Games played in the trailing `days` window, from normalized rows. Powers the
 *  `heavy_workload_7d` factor, whose feature nothing populated. */
function gameCountInWindow(statRows, days = 7, now = Date.now()) {
  if (!Array.isArray(statRows)) return null;
  const cutoff = now - days * 86_400_000;
  const dated = statRows.filter((r) => r && r.date && !Number.isNaN(new Date(r.date).getTime()));
  if (dated.length === 0) return null;
  return dated.filter((r) => new Date(r.date).getTime() >= cutoff).length;
}

async function gameLogFeatures(playerName, sport, statType) {
  // MLB game logs come from the FREE statsapi.mlb.com (Session 46) — the Python
  // gameLogService only covers NBA/WNBA, so MLB props had no recent/season
  // averages and the grade card's intel sections stayed empty.
  if (sport === 'mlb') {
    try {
      const mlbStats = require('../adapters/mlbStatsAdapter');
      const res = await mlbStats.getPlayerStats(playerName);
      return mlbGameLogFeatures(res, statType);
    } catch (e) {
      console.warn('[featureCache] MLB game-log features failed:', e.message);
      return {};
    }
  }

  const logs = await gameLogs.getGameLogs(playerName, sport, 20);

  // Wave 0 — NBA/WNBA grade unlock. The Python nba_api service (gameLogService)
  // is offline in prod, so `logs` is null and this branch used to return {} →
  // no l5/l20 → projectionFor null → the ENTIRE slate refused. Fall back to the
  // FREE ESPN per-athlete gamelog so NBA (off-season) + WNBA (in-season) grade.
  if ((!logs || logs.length === 0) && (sport === 'nba' || sport === 'wnba')) {
    try {
      const espnStats = require('../adapters/espnStatsAdapter');
      const res = await espnStats.getPlayerGameLog(playerName, sport);
      return nbaGameLogFeatures(res, statType);
    } catch (e) {
      console.warn('[featureCache] ESPN game-log fallback failed:', e.message);
      return {};
    }
  }

  if (!logs || logs.length === 0) return {};

  const valuesAll = logs.map((row) => statFromGameLog(row, statType)).filter((v) => v != null);
  const l5 = valuesAll.slice(0, 5);
  const l20 = valuesAll;
  const l10 = valuesAll.slice(0, 10);

  const out = {};
  const m5 = avg(l5);
  const m20 = avg(l20);
  const s10 = stddev(l10);
  if (m5 != null) out.l5_avg = m5;
  if (m20 != null) out.l20_avg = m20;
  if (s10 != null) out.l10_stddev = s10;

  // Career playoff games is a separate endpoint.
  const cp = await gameLogs.getCareerPlayoffGames(playerName, sport);
  if (Number.isFinite(cp)) out.career_playoff_games = cp;
  return out;
}

async function teamFeatures(sport, opponentAbbr, statType) {
  const out = {};
  if (!opponentAbbr) return out;
  const oppStats = await getTeamStats(sport, opponentAbbr);
  if (oppStats) {
    if (Number.isFinite(oppStats.pace)) out.pace_factor = oppStats.pace;
    if (Number.isFinite(oppStats.pace)) out.team_pace = oppStats.pace;
  }
  const rank = await getOpponentRank(sport, opponentAbbr, statType);
  if (rank != null) out.opp_rank_stat = rank;

  // Session 64 — MLB opp_rank_stat. ESPN's MLB team endpoint carries NO
  // defensive metric, so this field was permanently null for MLB and engine1's
  // ±1.0 opponent factor never fired for the sport carrying most of our volume.
  // Derived instead from statsapi team pitching splits (one free call, all 30
  // teams), normalized to the SHARED CONTRACT: 0–1, HIGH = weak opponent —
  // identical to WNBA, so engine1 reads one field with one meaning.
  // Isolated + best-effort: a failure leaves the field ABSENT (honest null),
  // never a guessed rank.
  if (out.opp_rank_stat == null && String(sport).toLowerCase() === 'mlb') {
    try {
      const mlb = require('../adapters/mlbStatsAdapter');
      const strength = require('../opponentStrength');
      const [teams, oppTeam] = await Promise.all([
        mlb.getTeamPitchingStats(),
        mlb.resolveTeam(opponentAbbr),
      ]);
      if (oppTeam && oppTeam.id) {
        const d = strength.deriveMlbOppRank(teams, oppTeam.id, statType);
        if (d.value != null) {
          out.opp_rank_stat = d.value;
        } else if (process.env.OPP_RANK_DEBUG === '1') {
          console.log(`[featureCache] mlb opp_rank null for ${opponentAbbr}/${statType}: ${d.reason}`);
        }
      }
    } catch (e) {
      console.warn('[featureCache] mlb opponent-strength derivation failed:', e.message);
    }
  }
  return out;
}

function contextFeatures(gameContext = {}) {
  const out = {};
  if (gameContext.home_away === 'home') out.home_away = 1.0;
  else if (gameContext.home_away === 'away') out.home_away = 0.0;
  if (Number.isFinite(gameContext.rest_days)) out.rest_days = gameContext.rest_days;
  if (Number.isFinite(gameContext.game_count_in_7d)) out.game_count_in_7d = gameContext.game_count_in_7d;
  if (gameContext.season_type != null) out.season_type = gameContext.season_type;
  if (Number.isFinite(gameContext.game_in_series)) out.game_in_series = gameContext.game_in_series;
  if (Number.isFinite(gameContext.season_phase)) out.season_phase = gameContext.season_phase;
  return out;
}

async function injuryFeatures(sport, teamId, knownStarterIds = []) {
  const out = {};
  if (!teamId) return out;
  const list = await getTeamInjuries(sport, teamId);
  if (!list || list.length === 0) {
    out.injury_severity_score = 0;
    return out;
  }
  const starterSet = new Set(knownStarterIds.map(String));
  const missingStarters = list.filter(
    (i) => starterSet.has(i.playerId) && (i.status === 'OUT' || i.status === 'DOUBTFUL')
  );
  out.injury_severity_score = Math.min(5, missingStarters.length);

  // Teammate-absence bump: a league-average constant when we don't have
  // with/without splits for this player. Engine 2 can replace this with
  // a learned value over time.
  if (missingStarters.length > 0) out.teammate_absence_bump = 0.05 * missingStarters.length;
  return out;
}

async function lineFeatures(gameId, playerName, statType) {
  const lm = await getLineMovement(gameId, playerName, statType);
  if (!lm) return {};
  return { line_delta: lm.movement };
}

async function refFeatures(gameId) {
  const impact = await getRefImpact(gameId);
  if (!impact) return {};
  const out = {};
  if (Number.isFinite(impact.pace_impact)) out.ref_pace_adjustment = impact.pace_impact;
  if (Number.isFinite(impact.foul_adjustment)) out.ref_foul_adjustment = impact.foul_adjustment;
  return out;
}

async function coachFeatures(sport, teamAbbr, gameContext = {}) {
  const impact = await getCoachImpact(sport, teamAbbr, gameContext);
  if (!impact) return {};
  const out = {};
  if (Number.isFinite(impact.adjusted_pace_delta)) out.coach_pace_delta = impact.adjusted_pace_delta;
  if (Number.isFinite(impact.without_primary_pace_shift)) {
    out.coach_player_interaction = impact.without_primary_pace_shift;
  }
  return out;
}

function lineupFeatures(role) {
  if (!role) return {};
  return { lineup_ball_handler_role: roleValue(role) };
}

// Top-level: build the full vector. Each sub-call is independent so a
// failure in one (e.g. ref assignments not yet published) just omits its
// feature and the rest of the vector is still useful.
async function getFeatures(input = {}) {
  const {
    playerId,
    playerName,
    statType,
    sport,
    teamAbbr,
    opponentAbbr,
    teamId,
    opponentTeamId,
    gameId,
    gameContext,
    role,
    knownStarterIds = [],
  } = input;

  const cacheKey = `features:${sport}:${playerId}:${statType}:${gameId}`;
  const cached = await cacheGet(cacheKey);
  if (cached) return cached;

  const [gl, team, ctx, injury, line, ref, coach, lineup] = await Promise.all([
    gameLogFeatures(playerName, sport, statType),
    teamFeatures(sport, opponentAbbr, statType),
    Promise.resolve(contextFeatures(gameContext)),
    injuryFeatures(sport, teamId, knownStarterIds),
    lineFeatures(gameId, playerName, statType),
    refFeatures(gameId),
    coachFeatures(sport, teamAbbr, gameContext),
    Promise.resolve(lineupFeatures(role)),
  ]);

  const features = { ...gl, ...team, ...ctx, ...injury, ...line, ...ref, ...coach, ...lineup };
  const FEATURE_NAMES = [
    'l5_avg', 'l20_avg', 'l10_stddev', 'career_playoff_games',
    'opp_rank_stat', 'pace_factor', 'team_pace',
    'home_away', 'rest_days', 'game_count_in_7d', 'season_type', 'game_in_series', 'season_phase',
    'teammate_absence_bump', 'primary_stat_suppression', 'injury_severity_score',
    'line_delta',
    'ref_pace_adjustment', 'ref_foul_adjustment',
    'coach_pace_delta', 'coach_player_interaction',
    'lineup_ball_handler_role',
  ];
  const available = FEATURE_NAMES.filter((n) => features[n] != null);
  const missing = FEATURE_NAMES.filter((n) => features[n] == null);

  const payload = {
    features,
    meta: {
      computed_at: new Date().toISOString(),
      features_available: available,
      features_missing: missing,
    },
  };
  await cacheSet(cacheKey, payload, VECTOR_TTL_SECONDS);
  return payload;
}

async function clearCache(cacheKey) {
  // Hook for tests + manual invalidation.
  const { cacheDel } = require('../../utils/redis');
  return cacheDel(cacheKey);
}

function getCacheStats() {
  return { ttlSeconds: VECTOR_TTL_SECONDS };
}

module.exports = {
  getFeatures,
  getStatRows,
  gameCountInWindow,
  clearCache,
  getCacheStats,
  // Internal helpers exported for unit tests + Engine 2 reuse.
  __internals: {
    gameLogFeatures,
    teamFeatures,
    contextFeatures,
    injuryFeatures,
    lineFeatures,
    refFeatures,
    coachFeatures,
    lineupFeatures,
    statFromGameLog,
    mlbGameLogFeatures,
    mlbStatValue,
    nbaGameLogFeatures,
    NBA_LOG_FIELD,
    avg,
    stddev,
    daysBetween,
  },
};
