'use strict';

/**
 * Baseball Savant / Statcast adapter (Wave 5B — Pitcher Arsenal).
 *
 * Pitch-level identity (mix / velo / usage% / whiff%) is NOT in statsapi.mlb.com —
 * it lives on Baseball Savant (baseballsavant.mlb.com), which is FREE + public.
 * `mlbStatsAdapter` already gives probable pitchers + ERA + game logs; the arsenal
 * is the missing "one identity → many props" lens (the mockup's PITCHER IDENTITY).
 *
 * ZERO-OUT-OF-POCKET + HONESTY doctrine (same as espnStatsAdapter):
 *   - Free source → NO gateway / NO quota tracking.
 *   - Prefer a CSV/JSON leaderboard endpoint so NO new parsing dependency is
 *     needed (a tiny quote-aware CSV parser lives here; no cheerio/HTML scrape).
 *   - Defensive parsing: `null` on any unrecognized shape. A missing velo/whiff
 *     is ABSENT (null), NEVER 0 — `Number(null) === 0` is the fabrication trap.
 *   - Never fabricate: no pitcher → `{ found:false }`; the card self-hides.
 *   - Injectable (`opts.fetchImpl` / `opts.statsCsv` / `opts.veloCsv` /
 *     `opts.resolveId` / cacheGet/cacheSet) → tests never hit the network.
 *
 * SOURCE ENDPOINTS (public, `csv=true` → no dependency):
 *   1. Pitch-arsenal-stats (usage% + whiff% + K%) — LONG format, one row per
 *      (pitcher, pitch_type):
 *      https://baseballsavant.mlb.com/leaderboard/pitch-arsenal-stats?type=pitcher&pitchType=&year={year}&min=1&csv=true
 *   2. Pitch-arsenals avg velo (best-effort enrichment) — WIDE format, one row
 *      per pitcher with a `{abbr}_avg_speed` column per pitch:
 *      https://baseballsavant.mlb.com/leaderboard/pitch-arsenals?year={year}&min=1&type=avg_speed&hand=&csv=true
 *   Both are LEAGUE-WIDE → fetched once, cached 24h, indexed by MLBAM pitcher id.
 *   Velo is OPTIONAL: if endpoint #2's shape drifts, velo stays absent (null) —
 *   never a fabricated 0. NEEDS PROD VERIFICATION of the live column names.
 */

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

const HTTP_TIMEOUT_MS = 12_000;
const ARSENAL_TTL = 24 * 3600; // 24h — pitch mix is a slow-moving season identity
const LEAGUE_TTL = 24 * 3600;
const DEFAULT_SEASON = 2026;
const TOP_PITCHES = 6; // cap the strip — nobody throws more than ~6 real pitches

// In-memory mirror so a single process serves the merged per-pitcher arsenal
// without a Redis round-trip (and so it degrades when Redis is down).
const _mem = new Map(); // key -> { value, exp }
function memGet(key) {
  const hit = _mem.get(key);
  if (!hit) return null;
  if (hit.exp && hit.exp < Date.now()) { _mem.delete(key); return null; }
  return hit.value;
}
function memSet(key, value, ttl) {
  _mem.set(key, { value, exp: Date.now() + ttl * 1000 });
}

function statsUrl(year) {
  return `https://baseballsavant.mlb.com/leaderboard/pitch-arsenal-stats?type=pitcher&pitchType=&year=${year}&min=1&csv=true`;
}
function veloUrl(year) {
  return `https://baseballsavant.mlb.com/leaderboard/pitch-arsenals?year=${year}&min=1&type=avg_speed&hand=&csv=true`;
}

// ── Strict numeric parsing — absent beats zero ──────────────────────────────
// A blank / non-numeric field is null, NOT 0. Percentages arrive either as a
// whole number ("32.4") or a fraction ("0.324"); we normalize to a whole-number
// percent only when the source is clearly a fraction (0..1).
function numOrNull(raw) {
  if (raw === null || raw === undefined) return null;
  const s = String(raw).trim().replace('%', '');
  if (s === '' || s === 'NA' || s === 'null') return null;
  const n = Number(s);
  return Number.isFinite(n) ? n : null;
}
function pctOrNull(raw) {
  const n = numOrNull(raw);
  if (n === null) return null;
  // Savant returns whole-number percents ("32.4"); a value in (0,1] is a
  // fraction we scale up. Never invent a value where none exists.
  return n > 0 && n <= 1 ? Math.round(n * 1000) / 10 : n;
}

// ── Minimal quote-aware CSV parser (NO dependency) ──────────────────────────
// Savant's first column header is literally `"last_name, first_name"` (a quoted
// field containing a comma), so a naive split is wrong — we must honor quotes.
function parseCsvLine(line) {
  const out = [];
  let cur = '';
  let inQ = false;
  for (let i = 0; i < line.length; i++) {
    const ch = line[i];
    if (inQ) {
      if (ch === '"') {
        if (line[i + 1] === '"') { cur += '"'; i++; } else inQ = false;
      } else cur += ch;
    } else if (ch === '"') {
      inQ = true;
    } else if (ch === ',') {
      out.push(cur); cur = '';
    } else cur += ch;
  }
  out.push(cur);
  return out;
}
function parseCsv(text) {
  if (typeof text !== 'string' || text.trim() === '') return [];
  const rows = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter((l) => l.length > 0);
  if (rows.length < 2) return [];
  const header = parseCsvLine(rows[0]).map((h) => h.trim());
  const objs = [];
  for (let r = 1; r < rows.length; r++) {
    const cols = parseCsvLine(rows[r]);
    if (cols.length === 0) continue;
    const obj = {};
    for (let c = 0; c < header.length; c++) obj[header[c]] = cols[c] !== undefined ? cols[c].trim() : '';
    objs.push(obj);
  }
  return objs;
}

// A row must carry a pitcher id + a pitch_type to be usable. Unknown shape → [].
function idOf(row) {
  return row.player_id ?? row.pitcher ?? row.playerId ?? row.mlbam_id ?? null;
}

// ── League-wide fetch + index (cached 24h, indexed by pitcher id) ───────────
async function fetchText(url, opts) {
  if (typeof opts.fetchImpl === 'function') return opts.fetchImpl(url);
  const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS, responseType: 'text' });
  return typeof res.data === 'string' ? res.data : null;
}

// stats leaderboard → { [pitcherId]: [ { pitch_type, pitch_name, pitch_usage, whiff_percent, k_percent } ] }
async function loadStatsIndex(year, opts) {
  const cacheGet = opts.cacheGet || redisGet;
  const cacheSet = opts.cacheSet || redisSet;
  const key = `savant:arsenal-stats:${year}`;
  let csv = opts.statsCsv;
  if (csv === undefined) {
    const cached = await cacheGet(key).catch(() => null);
    if (cached && typeof cached === 'object') return cached; // already indexed
    csv = await fetchText(statsUrl(year), opts).catch(() => null);
  }
  const rows = parseCsv(csv);
  if (rows.length === 0) return null; // unrecognized / empty → caller decides
  const index = {};
  let usable = 0;
  for (const row of rows) {
    const id = idOf(row);
    const type = row.pitch_type != null ? String(row.pitch_type).trim() : '';
    if (id == null || String(id).trim() === '' || type === '') continue;
    const key2 = String(id).trim();
    (index[key2] = index[key2] || []).push({
      type,
      name: row.pitch_name ? String(row.pitch_name).trim() : type,
      usagePct: pctOrNull(row.pitch_usage),
      whiffPct: pctOrNull(row.whiff_percent),
      kPct: pctOrNull(row.k_percent),
    });
    usable++;
  }
  if (usable === 0) return null; // header present but no id/pitch columns → unknown shape
  if (opts.statsCsv === undefined) await (opts.cacheSet || redisSet)(key, index, LEAGUE_TTL).catch(() => {});
  return index;
}

// velo leaderboard (WIDE) → { [pitcherId]: { ff: 99.1, sl: 87.0, ... } }  (lowercased abbr → velo)
async function loadVeloIndex(year, opts) {
  const cacheGet = opts.cacheGet || redisGet;
  const key = `savant:arsenal-velo:${year}`;
  let csv = opts.veloCsv;
  if (csv === undefined) {
    const cached = await cacheGet(key).catch(() => null);
    if (cached && typeof cached === 'object') return cached;
    csv = await fetchText(veloUrl(year), opts).catch(() => null);
  }
  const rows = parseCsv(csv);
  if (rows.length === 0) return {}; // velo is OPTIONAL — absent index is fine
  const index = {};
  for (const row of rows) {
    const id = idOf(row);
    if (id == null || String(id).trim() === '') continue;
    const speeds = {};
    for (const col of Object.keys(row)) {
      const m = /^([a-z]{1,3})_avg_speed$/i.exec(col);
      if (!m) continue;
      const v = numOrNull(row[col]);
      if (v !== null) speeds[m[1].toLowerCase()] = v;
    }
    if (Object.keys(speeds).length) index[String(id).trim()] = speeds;
  }
  if (opts.veloCsv === undefined) await (opts.cacheSet || redisSet)(key, index, LEAGUE_TTL).catch(() => {});
  return index;
}

/**
 * Resolve a pitcher's arsenal by MLBAM id (preferred) or name.
 * @returns {Promise<{found:boolean, playerId?:(number|string), pitches?:Array, source?:string}>}
 *   pitches: [{ type, name, usagePct, velo|null, whiffPct|null, kPct|null }], ranked by usage desc.
 *   Always graceful — any miss / unrecognized shape → { found:false }.
 */
async function getPitcherArsenal(idOrName, opts = {}) {
  try {
    const year = opts.year || DEFAULT_SEASON;

    // Resolve name → MLBAM id when we weren't given a numeric id.
    let id = idOrName;
    const isNumericId = id != null && /^\d+$/.test(String(id).trim());
    if (!isNumericId) {
      const name = String(idOrName || '').trim();
      if (!name) return { found: false };
      const resolveId = opts.resolveId || (async (n) => {
        const mlb = opts.mlbAdapter || require('./mlbStatsAdapter');
        const person = await mlb.searchPlayer(n).catch(() => null);
        return person && person.id != null ? person.id : null;
      });
      id = await resolveId(name);
      if (id == null) return { found: false };
    }
    const key = String(id).trim();

    // Merged per-pitcher cache (Redis + in-memory mirror), only for the live path
    // (injected CSV fixtures skip the cache so tests are deterministic).
    const usingFixtures = opts.statsCsv !== undefined || opts.veloCsv !== undefined;
    const memKey = `savant:arsenal:${key}:${year}`;
    if (!usingFixtures) {
      const m = memGet(memKey);
      if (m) return m;
      const cached = await (opts.cacheGet || redisGet)(memKey).catch(() => null);
      if (cached && typeof cached === 'object') { memSet(memKey, cached, ARSENAL_TTL); return cached; }
    }

    const statsIndex = await loadStatsIndex(year, opts);
    if (!statsIndex) return { found: false }; // unrecognized / empty stats feed
    const rows = statsIndex[key];
    if (!Array.isArray(rows) || rows.length === 0) return { found: false };

    // Velo is best-effort enrichment; a failure leaves velo absent (null).
    // In fixture mode (statsCsv injected) with NO veloCsv, DON'T hit the network —
    // tests stay hermetic and velo is honestly absent.
    let veloIndex = {};
    if (opts.veloCsv !== undefined || !usingFixtures) {
      try { veloIndex = await loadVeloIndex(year, opts); } catch { veloIndex = {}; }
    }
    const speeds = (veloIndex && veloIndex[key]) || {};

    const pitches = rows
      .map((p) => ({
        type: p.type,
        name: p.name,
        usagePct: p.usagePct,
        velo: (speeds[p.type.toLowerCase()] !== undefined ? speeds[p.type.toLowerCase()] : null),
        whiffPct: p.whiffPct,
        kPct: p.kPct,
      }))
      // rank by usage desc; a null usage sorts last (never fabricated to 0)
      .sort((a, b) => (b.usagePct ?? -1) - (a.usagePct ?? -1))
      .slice(0, TOP_PITCHES);

    const result = { found: true, playerId: /^\d+$/.test(key) ? Number(key) : key, pitches, source: 'baseball_savant' };
    if (!usingFixtures) {
      memSet(memKey, result, ARSENAL_TTL);
      await (opts.cacheSet || redisSet)(memKey, result, ARSENAL_TTL).catch(() => {});
    }
    return result;
  } catch (err) {
    console.warn('[savant] getPitcherArsenal failed:', idOrName, err && err.message);
    return { found: false };
  }
}

module.exports = {
  getPitcherArsenal,
  __internals: {
    parseCsv, parseCsvLine, numOrNull, pctOrNull, idOf,
    loadStatsIndex, loadVeloIndex, statsUrl, veloUrl,
    DEFAULT_SEASON, ARSENAL_TTL, TOP_PITCHES, _mem,
  },
};
