/**
 * playerIntelService — aggregates a player's intelligence payload for the
 * /player/:name profile page and the stats API (Session 42).
 *
 * Sources, all best-effort + graceful (the page must render even when every
 * upstream is cold):
 *   - archetypeService.classify  → PRIMARY/SECONDARY archetype + DNA blend
 *   - grades:{sport} cache        → this player's graded props for tonight
 *   - caller-supplied season/last10/splits/gradeHistory (wired by the route
 *     from the stats adapters; empty when unavailable)
 *
 * Pure-ish: I/O is only the grades-cache read, and that's injectable for tests.
 */

const { classify } = require('./archetypeService');
const { normalizeName, nameKey } = require('../utils/playerName');

const toNum = (v) => {
  const n = parseFloat(v);
  return Number.isNaN(n) ? 0 : n;
};
const fmt3 = (v) => {
  const s = String(v == null ? '' : v);
  return s.startsWith('0.') ? s.slice(1) : s; // ".282" baseball style
};

/**
 * Sanitize a player-name URL param (Montgomery's note). Decode, strip anything
 * that isn't a letter/number/space or name punctuation (. - '), collapse
 * whitespace, cap length. Defends the cache key + any downstream lookups.
 */
function sanitizePlayerName(raw) {
  let decoded = String(raw == null ? '' : raw);
  try { decoded = decodeURIComponent(decoded); } catch { /* malformed % — use raw */ }
  const cleaned = decoded
    .replace(/[^\p{L}\p{N}\s.'-]/gu, '')
    .replace(/\s+/g, ' ')
    .trim()
    .slice(0, 60);
  // Session 46 — normalize periods/suffix for display ("A.J. Ewing" → "AJ Ewing").
  return normalizeName(cleaned).display;
}

// Session 46 — match by the normalized name key so name variants resolve to the
// same player (snapshot grades, profile lookups).
const normName = (n) => nameKey(n);

async function loadPlayerGrades(sport, name, cacheGetFn) {
  const env = await cacheGetFn(`grades:${sport}`);
  const grades = env && Array.isArray(env.grades) ? env.grades : [];
  const target = normName(name);
  if (!target) return [];
  return grades.filter((g) => normName(g.player_name || g.player) === target);
}

// ── MLB raw-stat normalization (Session 43) ─────────────────────────
// Maps the raw statsapi.mlb.com season object into (a) classifier input and
// (b) the profile's display rows + last-10 log.

function mapMlbHitter(s) {
  const pa = toNum(s.plateAppearances) || toNum(s.atBats);
  return {
    avg: toNum(s.avg), hr: toNum(s.homeRuns), rbi: toNum(s.rbi), sb: toNum(s.stolenBases),
    ops: toNum(s.ops), runs: toNum(s.runs), doubles: toNum(s.doubles),
    k_rate: pa > 0 ? (toNum(s.strikeOuts) / pa) * 100 : 0,
  };
}
function mapMlbPitcher(s) {
  const ip = toNum(s.inningsPitched);
  const gs = toNum(s.gamesStarted);
  return {
    era: toNum(s.era), whip: toNum(s.whip),
    k9: toNum(s.strikeoutsPer9Inn) || (ip > 0 ? (toNum(s.strikeOuts) / ip) * 9 : 0),
    ip_per_start: gs > 0 ? ip / gs : 0,
    saves: toNum(s.saves),
    role: gs > 0 ? 'SP' : toNum(s.saves) > 0 ? 'CL' : 'RP',
  };
}

function mlbSeasonRows(s, group) {
  if (group === 'pitching') {
    return [
      { k: 'ERA', v: String(s.era ?? '—') },
      { k: 'K', v: String(s.strikeOuts ?? '—') },
      { k: 'IP', v: String(s.inningsPitched ?? '—') },
      { k: 'WHIP', v: String(s.whip ?? '—') },
      { k: 'GS', v: String(s.gamesStarted ?? '—') },
    ];
  }
  return [
    { k: 'AVG', v: fmt3(s.avg) || '—' },
    { k: 'HR', v: String(s.homeRuns ?? '—') },
    { k: 'RBI', v: String(s.rbi ?? '—') },
    { k: 'OPS', v: fmt3(s.ops) || '—' },
    { k: 'GP', v: String(s.gamesPlayed ?? '—') },
  ];
}

function mlbLast10Rows(log, group) {
  return (log || []).slice(-10).reverse().map((g) => {
    const st = g.stat || {};
    const summary = group === 'pitching'
      ? `${st.inningsPitched ?? '0'} IP · ${st.strikeOuts ?? 0} K`
      : `${st.hits ?? 0}-${st.atBats ?? 0} · ${st.homeRuns ?? 0} HR`;
    return { d: String(g.date || '').slice(5), opp: g.opponent ? String(g.opponent).slice(0, 3).toUpperCase() : '', stat: summary };
  });
}

/**
 * Resolve a player's REAL stats by sport (Session 43). Returns a normalized
 * bundle or { found:false }. Best-effort + never throws — the profile renders
 * regardless. Adapters are injectable for tests (opts.mlbAdapter/nbaClient).
 */
async function resolvePlayerStats(name, sport, opts = {}) {
  const sp = String(sport || 'nba').toLowerCase();
  try {
    if (sp === 'mlb') {
      const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter');
      // Wave 1 — thread the prop's game team hint so a namesake collision
      // (two "James Wood") resolves to the RIGHT player, and a team that isn't
      // a participant of the prop's game is dropped (never a foreign tag).
      const res = await mlb.getPlayerStats(name, undefined, { teamHint: opts.teamHint });
      if (!res || !res.found) return { found: false };
      const classifierInput = res.group === 'pitching' ? mapMlbPitcher(res.season) : mapMlbHitter(res.season);
      // Session 48 — real VYNDR INTELLIGENCE for the player profile: usage (AB/G)
      // + rest (days off between the two most recent games; 0 = B2B). The
      // classify() scorer ignores these extra keys; buildIntel reads them.
      Object.assign(classifierInput, mlbProfileIntel(res));
      return {
        found: true,
        team: res.team || '',
        classifierInput,
        season: mlbSeasonRows(res.season, res.group),
        last10: mlbLast10Rows(res.last10, res.group),
        splits: [],
        // Session 60 (night2/B) — the RAW flattened game log, most-recent
        // first, for the streaks/hot-list roster blob. Free: the adapter
        // already fetched it for this resolve; nothing extra is called.
        rawLog: (res.last10 || [])
          .map((r) => ({ date: r.date || null, opponent: r.opponent || null, isHome: r.isHome ?? null, ...(r.stat || {}) }))
          .reverse(),
        seasonRaw: res.season || null,
        group: res.group || null,
        playerId: res.id ?? null,
      };
    }
    if (sp === 'nba' || sp === 'wnba') {
      // PRIMARY: the Python nba_api service (nbaStatsClient) — often offline in
      // prod. FALLBACK: ESPN's free public stats (espnStatsAdapter). Either way,
      // a miss degrades quietly to found:false (no badge, never a crash).
      const nba = opts.nbaClient || require('./nbaStatsClient');
      let data = await nba.getSeasonAvg(name).catch(() => null);
      if (!data || typeof data !== 'object' || !toNum(data.ppg ?? data.points)) {
        const espn = opts.espnStats || require('./adapters/espnStatsAdapter');
        const e = await espn.getSeasonAverages(name, sp).catch(() => ({ found: false }));
        if (e && e.found && e.classifierInput) {
          const ci = e.classifierInput;
          const season = [
            { k: 'PPG', v: String(ci.ppg ?? '—') }, { k: 'RPG', v: String(ci.rpg ?? '—') },
            { k: 'APG', v: String(ci.apg ?? '—') }, { k: 'BLK', v: String(ci.bpg ?? '—') },
          ];
          // Session 60 (5.3) — WNBA/NBA parity: minutes-based usage on the
          // profile (the basketball equivalent of AB/G). Only when the feed
          // carries minutes — absent beats invented.
          const mpg = Number(ci.mpg ?? ci.min ?? ci.minutes);
          const extra = Number.isFinite(mpg) && mpg > 0 ? { usage: `${Math.round(mpg)} min` } : {};
          if (extra.usage) season.push({ k: 'MIN', v: String(Math.round(mpg)) });
          // Wave 2A — the REAL ESPN athlete id (headshot CDN) surfaces from the
          // adapter. Absent → no id → monogram. Never guessed.
          return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position, ...extra }, season, last10: [], splits: [], espnId: e.espnId ?? null };
        }
        return { found: false };
      }
      const classifierInput = {
        ppg: toNum(data.ppg ?? data.points), rpg: toNum(data.rpg ?? data.rebounds), apg: toNum(data.apg ?? data.assists),
        bpg: toNum(data.bpg ?? data.blocks), spg: toNum(data.spg ?? data.steals),
        threes: toNum(data.threes ?? data.fg3m), usg: toNum(data.usg ?? data.usage), pos: data.pos || data.position,
      };
      const season = [
        { k: 'PPG', v: String(classifierInput.ppg) }, { k: 'RPG', v: String(classifierInput.rpg) },
        { k: 'APG', v: String(classifierInput.apg) }, { k: 'BLK', v: String(classifierInput.bpg) },
      ];
      return { found: true, team: data.team || '', classifierInput, season, last10: [], splits: [] };
    }
  } catch (err) {
    console.warn('[playerIntel] resolvePlayerStats failed:', name, sp, err.message);
  }
  return { found: false };
}

/**
 * Profile VYNDR INTELLIGENCE bits from real MLB stats (Session 48):
 * usage = AB/G (the MLB usage equivalent), rest = days off between the two most
 * recent games ("B2B"/"Xd rest"). Returns {} when unavailable.
 */
function mlbProfileIntel(res) {
  const out = {};
  const ab = parseFloat(res.season && res.season.atBats);
  const gp = parseFloat(res.season && (res.season.gamesPlayed ?? res.season.gamesStarted));
  if (Number.isFinite(ab) && Number.isFinite(gp) && gp > 0) {
    out.usage = `${Math.round((ab / gp) * 10) / 10} AB/G`;
  }
  const dated = Array.isArray(res.last10) ? res.last10.filter((g) => g && g.date) : [];
  if (dated.length >= 2) {
    const gap = Math.round((new Date(dated[dated.length - 1].date) - new Date(dated[dated.length - 2].date)) / 86_400_000);
    if (Number.isFinite(gap) && gap >= 1 && gap <= 14) {
      const off = gap - 1;
      out.rest = off === 0 ? 'B2B' : `${off}d rest`;
    }
  }
  return out;
}

/**
 * Derive the VYNDR Intelligence metric row from whatever we have.
 *
 * Session 65 — UN-FABRICATE (Truth Law: if it renders a number, it comes from
 * real data or it does not render).
 * - FORM used to fall back to `70 + 4 × (count of tonight's graded props)`,
 *   capped at 92. Nothing on the HTTP path ever sets `stats.form`, so that
 *   fallback WAS the live value: Josh Bell's "74" was 70 + 4×1 graded prop —
 *   a number carrying zero information about the player. Absent now renders
 *   absent.
 * - MATCHUP used to be `gradeFromForm(form)` (i.e. the same prop count) with a
 *   hardcoded 'B' when no archetype resolved. There is no opponent input on
 *   this path at all, so BOTH branches were fabricated letters. Absent now
 *   renders absent.
 * A real form metric is a separate, held design call (per-sport headline stat);
 * it lands as `stats.form` / `stats.matchup` and lights these tiles back up.
 * Tiles emit kind 'plain' when absent so the page prints "—" and draws no
 * progress bar — no math, colour or bar width is computed off a null.
 */
function buildIntel(stats, arch) {
  const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
  const form = stats.form != null && Number.isFinite(Number(stats.form))
    ? clamp(Math.round(Number(stats.form)), 0, 100)
    : null;
  // Session 48 — prefer a preformatted usage string (e.g. "3.6 AB/G"); rest
  // shows the real value or "—" (no more bogus "+0%").
  const usage = stats.usage || (stats.usg != null ? `${stats.usg}%` : stats.k9 != null ? `${stats.k9} K/9` : '—');
  const matchup = typeof stats.matchup === 'string' && stats.matchup ? stats.matchup : null;
  return [
    form != null
      ? { label: 'FORM', kind: 'form', value: String(form), score: `${form}%`, color: form >= 85 ? '#00ffb8' : form >= 70 ? '#00D4A0' : '#FFB347' }
      : { label: 'FORM', kind: 'plain', value: '—', color: '#7A7A8E' },
    { label: 'USAGE', kind: 'plain', value: usage, color: '#e8e8f0' },
    matchup
      ? { label: 'MATCHUP', kind: 'grade', value: matchup, color: '#00D4A0' }
      : { label: 'MATCHUP', kind: 'plain', value: '—', color: '#7A7A8E' },
    { label: 'REST', kind: 'plain', value: stats.rest || '—', color: stats.rest ? '#00D4A0' : '#7A7A8E' },
  ];
}

/**
 * Build the full player intelligence payload.
 * opts: { cacheGet, stats, season, last10, splits, gradeHistory, injury, team }
 */
async function getPlayerIntel(name, sport, opts = {}) {
  const cacheGetFn = opts.cacheGet || require('../utils/redis').cacheGet;
  const clean = sanitizePlayerName(name);
  const sp = String(sport || 'nba').toLowerCase();

  // 1. Real season stats from the sport adapter (Session 43). Injectable for
  //    tests via opts.resolveStats; opts.stats short-circuits to caller-supplied.
  let resolved;
  if (opts.stats) {
    resolved = { found: Object.keys(opts.stats).length > 0, classifierInput: opts.stats, season: opts.season || [], last10: opts.last10 || [], splits: opts.splits || [], team: opts.team || '' };
  } else {
    const resolver = opts.resolveStats || resolvePlayerStats;
    resolved = await resolver(clean, sp, opts);
  }
  const realStats = resolved.classifierInput || {};

  // 2. Archetype, now classified from REAL stats when we have them.
  const archetype = classify(sp, realStats);

  // 3. Tonight's graded props from the slate cache.
  let props = [];
  try {
    props = await loadPlayerGrades(sp, clean, cacheGetFn);
  } catch {
    props = []; // cold/broken cache must not 500 the page
  }

  const activeProps = props.map((p) => ({
    stat: p.stat_type || p.stat,
    line: p.line,
    side: String(p.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O',
    grade: p.grade,
    confidence: p.confidence != null ? `${p.confidence}%` : null,
  }));

  const team = resolved.team || (props[0] && (props[0].team || props[0].team_abbr)) || opts.team || '';

  return {
    player: clean,
    sport: sp,
    team,
    found: !!resolved.found || props.length > 0,
    archetype,
    propDNA: archetype.primary ? archetype.primary.propDNA : { reliable: [], volatile: [] },
    education: archetype.primary ? archetype.primary.education : '',
    season: resolved.season || opts.season || [],
    last10: resolved.last10 || opts.last10 || [],
    splits: resolved.splits || opts.splits || [],
    gradeHistory: opts.gradeHistory || [],
    activeProps,
    // Session 65 — prop count is deliberately NOT passed any more: it used to
    // manufacture the FORM number. Intel is a function of real stats only.
    intel: buildIntel(realStats, archetype),
    injury: opts.injury || null,
  };
}

/**
 * Tonight's leaders for a sport — top graded props by confidence, optionally
 * filtered to a single stat. Reads the same grades:{sport} cache.
 */
async function getLeaders(sport, opts = {}) {
  const cacheGetFn = opts.cacheGet || require('../utils/redis').cacheGet;
  const sp = String(sport || 'nba').toLowerCase();
  const stat = opts.stat ? String(opts.stat).toLowerCase() : null;
  const limit = Math.max(1, Math.min(50, Number(opts.limit) || 10));

  let grades = [];
  try {
    const env = await cacheGetFn(`grades:${sp}`);
    grades = env && Array.isArray(env.grades) ? env.grades : [];
  } catch {
    grades = [];
  }

  return grades
    .filter((g) => !stat || String(g.stat_type || g.stat || '').toLowerCase() === stat)
    .sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0))
    .slice(0, limit)
    .map((g) => ({
      player: g.player_name || g.player,
      team: g.team || g.team_abbr || '',
      stat: g.stat_type || g.stat,
      line: g.line,
      side: String(g.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O',
      grade: g.grade,
      confidence: g.confidence,
    }));
}

module.exports = {
  sanitizePlayerName,
  getPlayerIntel,
  getLeaders,
  resolvePlayerStats,
  _internals: { normName, loadPlayerGrades, buildIntel, mapMlbHitter, mapMlbPitcher, mlbSeasonRows, mlbLast10Rows },
};
