'use strict';

/**
 * outcomeService — the self-learning loop (Session 55).
 *
 * VYNDR grades props but never checked whether it was right. This closes the
 * loop: after games complete, settle each locked snapshot grade against the
 * REAL result (did the player clear the line?), record hit/miss/push, and
 * aggregate a rolling accuracy record by grade tier. That powers the accuracy
 * display ("A-rated props: 68% hit rate"), the #1 trust builder — a system that
 * shows its misses, not just its hits.
 *
 * Data source: the FREE MLB Stats API game log (mlbStatsAdapter.getPlayerStats)
 * — the same source the grade pipeline already uses. Presence of a game-log row
 * for the graded date ⇒ the game is FINAL. NBA/WNBA degrade to `pending` when
 * their (usually offline) Python stats service returns nothing — never throw.
 * Zero new dependency, zero paid API credits.
 *
 * Redis keys written:
 *   outcomes:{sport}:log  — settled outcomes, newest first, cap 1000, deduped
 *   accuracy:{sport}      — { overall, byGrade } over a trailing 30-day window
 *   accuracy:overall      — same, aggregated across sports (dashboard header)
 *
 * Everything is injectable → the whole cycle is unit-tested with zero network.
 */

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

const LOG_CAP = 1000;
const LOG_TTL = 30 * 24 * 3600;       // 30d — matches the accuracy window
const ACC_TTL = 7 * 24 * 3600;
const WINDOW_DAYS = 30;
const MIN_SAMPLE = 8;                  // below this, callers should hide the pct
const SPORTS = ['mlb', 'nba', 'wnba', 'soccer'];

// VYNDR stat_type → the per-game field in a statsapi.mlb.com game-log row.
// Mirrors featureCache.MLB_LOG_FIELD (settlement is a distinct concern, kept
// self-contained so this service doesn't depend on test-only internals).
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 — confirmed present in the real boxscore/game log.
  doubles: 'doubles', triples: 'triples', outs: 'outs',
};

// Wave 1 — NBA/WNBA settlement box-field map. A SEPARATE local map from
// MLB_LOG_FIELD (the S11 three-map-split rule — settlement, features, and live
// tracking each own their own map; never merge). The ESPN gamelog row's `stat`
// object (espnStatsAdapter.getPlayerGameLog) is already keyed by VYNDR stat
// names, so simple stats map 1:1; combo stat_types (pts_reb_ast, …) sum their
// components at read time — mirroring featureCache.statFromGameLog. A stat_type
// absent here does NOT settle (absent beats a fabricated outcome); add a new
// NBA/WNBA stat here (and to featureCache + liveTrackingService) to unlock it.
const NBA_BOX_KEY = {
  points: 'points', rebounds: 'rebounds', assists: 'assists', threes: 'threes',
  steals: 'steals', blocks: 'blocks', turnovers: 'turnovers', pra: 'pra',
};
const NBA_COMBO = {
  pts_reb_ast: ['points', 'rebounds', 'assists'],
  pts_reb: ['points', 'rebounds'],
  pts_ast: ['points', 'assists'],
  reb_ast: ['rebounds', 'assists'],
  stl_blk: ['steals', 'blocks'],
};

// Resolve one NBA/WNBA per-game stat value. Combos require EVERY component to
// be present (a played game reports 0, not absent) — a partial box never
// fabricates a combo total.
function nbaStatValue(statObj, statType) {
  if (!statObj) return null;
  const st = String(statType || '').toLowerCase();
  const combo = NBA_COMBO[st];
  if (combo) {
    let sum = 0;
    for (const c of combo) {
      const n = parseFloat(statObj[c]);
      if (!Number.isFinite(n)) return null;
      sum += n;
    }
    return sum;
  }
  const f = NBA_BOX_KEY[st];
  if (!f) return null;
  const n = parseFloat(statObj[f]);
  return Number.isFinite(n) ? n : null;
}

// Sport-aware actual-value resolver. Default (unspecified/mlb) reads the
// statsapi.mlb.com game-log field; nba/wnba read the ESPN gamelog box object.
function statValue(statObj, statType, sport) {
  const sp = String(sport || 'mlb').toLowerCase();
  if (sp === 'nba' || sp === 'wnba') return nbaStatValue(statObj, statType);
  const f = MLB_LOG_FIELD[String(statType || '').toLowerCase()];
  if (!f || !statObj) return null;
  const n = parseFloat(statObj[f]);
  return Number.isFinite(n) ? n : null;
}

// The UTC date AND the America/New_York date of an ISO timestamp — covers a
// late game whose ET calendar date differs from UTC, without heavy TZ math.
function dateStrings(ts) {
  if (!ts) return [];
  const d = new Date(ts);
  if (isNaN(d.getTime())) return [];
  const utc = d.toISOString().slice(0, 10);
  let et = utc;
  try {
    et = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York' }).format(d);
  } catch { /* Intl missing → UTC only */ }
  return [...new Set([utc, et])];
}

// The America/New_York calendar date (YYYY-MM-DD) of an ISO timestamp.
function etDate(ts) {
  if (!ts) return null;
  const d = new Date(ts);
  if (isNaN(d.getTime())) return null;
  try {
    return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York' }).format(d);
  } catch {
    return d.toISOString().slice(0, 10);
  }
}

/**
 * Does a game-log row fall on `targetDate` (YYYY-MM-DD)?
 * MLB rows are already plain YYYY-MM-DD → exact compare (unchanged behavior).
 * NBA/WNBA rows carry a FULL ISO timestamp from ESPN (e.g. a late tip is
 * 00:30Z the next calendar day) → match on either its UTC or ET date so a
 * game whose UTC date rolled past midnight still matches its ET slate date.
 */
function logRowOnDate(logRow, targetDate, sport) {
  if (!logRow || !logRow.date || !targetDate) return false;
  const sp = String(sport || 'mlb').toLowerCase();
  if (sp === 'nba' || sp === 'wnba') return dateStrings(logRow.date).includes(targetDate);
  return logRow.date === targetDate;
}

const sideOver = (side) => {
  const s = String(side || 'over').toLowerCase();
  return s === 'over' || s === 'o';
};

// hit / miss / push for a graded side given the actual result and the line.
function settleResult(side, actual, line) {
  if (actual == null || line == null) return null;
  const a = Number(actual), l = Number(line);
  if (!Number.isFinite(a) || !Number.isFinite(l)) return null;
  if (a === l) return 'push';
  return sideOver(side) ? (a > l ? 'hit' : 'miss') : (a < l ? 'hit' : 'miss');
}

// Bucket a letter grade into a tier: A+ stands alone; A-/A → A; B±/B → B; …
function gradeBucket(grade) {
  const g = String(grade || '').trim().toUpperCase();
  if (!g) return null;
  if (g === 'A+') return 'A+';
  return g[0];
}

const TIERS = ['A+', 'A', 'B', 'C', 'D', 'F'];

function outcomeKey(o) {
  return `${nameKey(o.player)}|${String(o.stat).toLowerCase()}|${o.line}|${sideOver(o.side) ? 'O' : 'U'}|${o.date}`;
}

/**
 * Settle one sport's latest snapshot against real results. Returns
 * { sport, settled, pending, log } and persists the merged log + accuracy.
 * Never throws; a player/stat that can't be resolved is simply left pending.
 *
 * opts (all injectable): cacheGet, cacheSet, getPlayerStats(name, sport),
 * now (ISO string).
 */
async function settleSnapshot(sport, opts = {}) {
  const sp = String(sport || '').toLowerCase();
  const deps = {
    cacheGet: opts.cacheGet || require('../utils/redis').cacheGet,
    cacheSet: opts.cacheSet || require('../utils/redis').cacheSet,
    getPlayerStats: opts.getPlayerStats || defaultGetPlayerStats,
    now: opts.now || (() => new Date().toISOString()),
  };
  const nowIso = deps.now();

  const snap = await deps.cacheGet(`snapshot:${sp}:latest`);
  const grades = snap && Array.isArray(snap.grades) ? snap.grades : [];
  if (grades.length === 0) return { sport: sp, settled: 0, pending: 0, log: [] };

  // Existing settled log (idempotency source).
  const prevLog = normalizeLog(await deps.cacheGet(`outcomes:${sp}:log`));
  const seen = new Set(prevLog.map(outcomeKey));

  // Resolve each unique player's game log ONCE per run.
  const players = [...new Set(grades.map((g) => g.player || g.player_name).filter(Boolean))];
  const logByPlayer = {};
  for (const player of players) {
    try {
      const stats = await deps.getPlayerStats(player, sp);
      logByPlayer[player] = stats && stats.found && Array.isArray(stats.last10) ? stats.last10 : [];
    } catch { logByPlayer[player] = []; }
  }

  const todayEt = etDate(nowIso);
  const isBasketball = sp === 'nba' || sp === 'wnba';
  const fresh = [];
  let pending = 0;
  for (const g of grades) {
    const player = g.player || g.player_name;
    const stat = g.stat_type || g.stat;
    const side = g.direction || 'over';
    const line = g.line;
    const gradedTs = (g.gradedAt && g.gradedAt.timestamp) || snap.updated_at || nowIso;
    const dates = dateStrings(gradedTs);
    const log = logByPlayer[player] || [];
    // Find the game played on the graded date (sport-aware date normalization).
    const row = log.find((r) => dates.some((d) => logRowOnDate(r, d, sp)));
    if (!row) { pending += 1; continue; }
    // Detect FINAL honestly: a game-log row is a COMPLETED game, but never
    // settle a game whose ET date is today (could be an in-progress partial
    // row for NBA/WNBA). MLB game logs are final-only + settle same-day, so
    // this guard is scoped to basketball — it must not stall MLB afternoons.
    if (isBasketball) {
      const rowEt = etDate(row.date);
      if (!rowEt || rowEt >= todayEt) { pending += 1; continue; }
    }
    const actual = statValue(row.stat, stat, sp);
    if (actual == null) { pending += 1; continue; }
    const result = settleResult(side, actual, line);
    if (!result) { pending += 1; continue; }
    // Store the game's ET calendar date (YYYY-MM-DD). MLB rows are already in
    // that form; basketball rows carry a full ISO timestamp → normalize so the
    // accuracy 30-day window filter + the idempotency key (both key off `date`)
    // behave identically across sports.
    const outcomeDate = isBasketball ? (etDate(row.date) || row.date) : row.date;
    const outcome = {
      player, stat, line, side: sideOver(side) ? 'O' : 'U',
      grade: g.grade, actual, result, date: outcomeDate,
      gradedAt: gradedTs, settledAt: nowIso,
    };
    const key = outcomeKey(outcome);
    if (seen.has(key)) continue;           // idempotent — already settled
    seen.add(key);
    fresh.push({ ...outcome, key });
  }

  // Merge (newest first), cap.
  const merged = [...fresh, ...prevLog].slice(0, LOG_CAP);
  await deps.cacheSet(`outcomes:${sp}:log`, merged, LOG_TTL);

  const accuracy = computeAccuracy(sp, merged, nowIso);
  await deps.cacheSet(`accuracy:${sp}`, accuracy, ACC_TTL);

  return { sport: sp, settled: fresh.length, pending, log: merged, accuracy };
}

// Aggregate a settled log into an accuracy record over the trailing window.
function computeAccuracy(sport, log, nowIso, windowDays = WINDOW_DAYS) {
  const cutoff = new Date(nowIso).getTime() - windowDays * 24 * 3600 * 1000;
  const inWindow = (log || []).filter((o) => {
    const t = new Date(`${o.date}T12:00:00Z`).getTime();
    return Number.isFinite(t) && t >= cutoff;
  });
  const bucketFor = (grade) => {
    const b = gradeBucket(grade);
    return TIERS.includes(b) ? b : null;
  };
  const blank = () => ({ hits: 0, misses: 0, pushes: 0, total: 0, pct: null });
  const byGrade = {};
  for (const t of TIERS) byGrade[t] = blank();
  const overall = blank();
  for (const o of inWindow) {
    const tier = bucketFor(o.grade);
    const targets = [overall];
    if (tier) targets.push(byGrade[tier]);
    for (const bucket of targets) {
      if (o.result === 'hit') bucket.hits += 1;
      else if (o.result === 'miss') bucket.misses += 1;
      else if (o.result === 'push') bucket.pushes += 1;
    }
  }
  const finalize = (b) => {
    b.total = b.hits + b.misses + b.pushes;
    const decided = b.hits + b.misses;
    b.pct = decided > 0 ? Math.round((b.hits / decided) * 100) : null;
    return b;
  };
  finalize(overall);
  for (const t of TIERS) finalize(byGrade[t]);
  return {
    sport, updated_at: nowIso, window_days: windowDays,
    sample: overall.total, min_sample: MIN_SAMPLE,
    overall, byGrade,
  };
}

function normalizeLog(raw) {
  if (Array.isArray(raw)) return raw;
  if (raw && Array.isArray(raw.log)) return raw.log;
  return [];
}

async function defaultGetPlayerStats(name, sport) {
  const sp = String(sport || '').toLowerCase();
  if (sp === 'mlb') {
    return require('./adapters/mlbStatsAdapter').getPlayerStats(name);
  }
  // Wave 1 — NBA/WNBA settle against the FREE ESPN per-game log (Wave 0's
  // getPlayerGameLog returns the same { found, last10:[{date, stat}] } shape).
  if (sp === 'nba' || sp === 'wnba') {
    return require('./adapters/espnStatsAdapter').getPlayerGameLog(name, sp);
  }
  // soccer: no free settled-result feed yet → pending.
  return { found: false };
}

/**
 * Recompute the cross-sport accuracy:overall record from every sport's log.
 * Called after settling. Deps: cacheGet, cacheSet, now.
 */
async function recomputeOverall(opts = {}) {
  const deps = {
    cacheGet: opts.cacheGet || require('../utils/redis').cacheGet,
    cacheSet: opts.cacheSet || require('../utils/redis').cacheSet,
    now: opts.now || (() => new Date().toISOString()),
  };
  const nowIso = deps.now();
  const logs = [];
  for (const sp of SPORTS) {
    const l = normalizeLog(await deps.cacheGet(`outcomes:${sp}:log`));
    logs.push(...l);
  }
  const acc = computeAccuracy('overall', logs, nowIso);
  await deps.cacheSet('accuracy:overall', acc, ACC_TTL);
  return acc;
}

/** Settle every sport, then recompute the overall record. Cron entrypoint. */
async function settleAllOutcomes(opts = {}) {
  const results = [];
  for (const sp of SPORTS) {
    try { results.push(await settleSnapshot(sp, opts)); }
    catch (e) { results.push({ sport: sp, settled: 0, pending: 0, error: e.message }); }
  }
  await recomputeOverall(opts);
  return results;
}

/**
 * Read the persisted accuracy record for the public endpoint. Cold-cache safe:
 * returns an empty-but-valid shape. Deps: cacheGet.
 */
async function getAccuracy(opts = {}) {
  const cacheGet = opts.cacheGet || require('../utils/redis').cacheGet;
  const overall = await cacheGet('accuracy:overall');
  const sports = {};
  for (const sp of SPORTS) {
    const a = await cacheGet(`accuracy:${sp}`);
    if (a) sports[sp] = a;
  }
  return {
    overall: overall || computeAccuracy('overall', [], new Date().toISOString()),
    sports,
    min_sample: MIN_SAMPLE,
    updated_at: overall && overall.updated_at ? overall.updated_at : null,
  };
}

/** Flatten an accuracy record into the ledger `buckets` shape. */
function accuracyBuckets(acc) {
  if (!acc || !acc.byGrade) return [];
  return TIERS
    .map((tier) => {
      const b = acc.byGrade[tier] || {};
      return { grade: tier, hits: b.hits || 0, total: b.total || 0, pct: b.pct };
    })
    .filter((b) => b.total > 0);
}

module.exports = {
  settleSnapshot,
  settleAllOutcomes,
  recomputeOverall,
  getAccuracy,
  computeAccuracy,
  accuracyBuckets,
  // Session 58 — settlement primitives shared with ledgerService (single
  // source of truth for hit/miss/push + log-field resolution). Wave 1 —
  // logRowOnDate normalizes the ESPN ISO game-log date for NBA/WNBA.
  settleResult,
  statValue,
  logRowOnDate,
  SPORTS,
  MIN_SAMPLE,
  __internals: {
    settleResult, gradeBucket, dateStrings, etDate, statValue, nbaStatValue,
    logRowOnDate, outcomeKey, MLB_LOG_FIELD, NBA_BOX_KEY, NBA_COMBO, TIERS,
  },
};
