'use strict';

/**
 * STATCAST ADAPTER (Layer 1) — the SOURCE ADAPTER of the mechanism-data pattern.
 *
 * Pulls per-player-per-season Statcast aggregates from Baseball Savant. Free,
 * public, no key, no quota — the same host `savantAdapter` already uses in prod.
 *
 * WHY NOT pybaseball: it is an MIT-licensed Python wrapper around these exact
 * CSV URLs. Adding it would reintroduce a Python runtime dependency in a stack
 * where the existing Python service is already offline in production (nba_api
 * degrades to found:false for exactly that reason). We call the endpoints
 * directly with axios and reuse savantAdapter's quote-aware CSV parser — no new
 * dependency, one less thing to keep alive.
 *
 * FIVE FEEDS (all measured live 2026-07-20, all `csv=true`, all min=1 so the
 * long tail is included rather than silently dropped by Savant's `min=q`):
 *   1. batter discipline   604 rows — k/bb/whiff/swing/CHASE/z-swing/sweetspot/
 *                                     barrel/hard-hit/best-speed
 *   2. batter batted-ball  600 rows — exit velo (avg/max/ev50), launch angle,
 *                                     GB/FB-LD, barrels, ev95+
 *   3. pitcher discipline  713 rows — k/bb/whiff/swing/chase, GB/FB/LD%,
 *                                     barrel/hard-hit allowed, ARM ANGLE
 *   4. pitcher batted-ball 749 rows — contact quality allowed
 *   5. pitch movement      677 rows — per pitch type: velo, break_x/z, induced
 *                                     break, usage — and PITCH_HAND (handedness)
 *
 * HONESTY DOCTRINE (inherited from savantAdapter / espnStatsAdapter):
 *   - A missing metric is NULL, never 0. `Number(null) === 0` is the fabrication
 *     trap this whole layer exists to avoid.
 *   - Any unrecognised shape → that field absent; never a guessed default.
 *   - Injectable (`opts.fetchImpl`) so tests never touch the network.
 *
 * COMMODITY, NOT MOAT: this data is public and every competitor can pull it in
 * about a second. The edge is Layer 2 (archetype definitions) and Layer 3
 * (projections) built on top — plus the settled ledger that proves them.
 */

const axios = require('axios');
const { __internals: savant } = require('./savantAdapter');

const { parseCsv, numOrNull } = savant;

const HTTP_TIMEOUT_MS = 60_000;
const DEFAULT_SEASON = Number(process.env.STATCAST_SEASON) || 2026;
const BASE = 'https://baseballsavant.mlb.com/leaderboard';

/** `min=1` deliberately: Savant's default `min=q` drops every non-qualified
 *  player, which would silently make a thin-sample player look absent rather
 *  than thin. We take the long tail and apply our OWN minimum-sample gate at
 *  the aggregate layer, where it is explicit and testable. */
const FEEDS = Object.freeze({
  batter_discipline: (y) => `${BASE}/custom?year=${y}&type=batter&filter=&min=1&selections=pa,k_percent,bb_percent,whiff_percent,swing_percent,oz_swing_percent,z_swing_percent,sweet_spot_percent,barrel_batted_rate,hard_hit_percent,avg_best_speed,avg_hyper_speed&chart=false&x=pa&y=pa&r=no&chartType=beeswarm&sort=pa&sortDir=desc&csv=true`,
  batter_batted_ball: (y) => `${BASE}/statcast?type=batter&year=${y}&position=&team=&min=1&csv=true`,
  pitcher_discipline: (y) => `${BASE}/custom?year=${y}&type=pitcher&filter=&min=1&selections=p_formatted_ip,k_percent,bb_percent,whiff_percent,swing_percent,oz_swing_percent,groundballs_percent,flyballs_percent,linedrives_percent,barrel_batted_rate,hard_hit_percent,arm_angle&chart=false&x=p_formatted_ip&y=p_formatted_ip&r=no&chartType=beeswarm&sort=p_formatted_ip&sortDir=desc&csv=true`,
  pitcher_batted_ball: (y) => `${BASE}/statcast?type=pitcher&year=${y}&position=&team=&min=1&csv=true`,
  pitch_movement: (y) => `${BASE}/pitch-movement?year=${y}&team=&min=1&pitch_type=&hand=&csv=true`,
  // The FULL arsenal: one row per (pitcher, pitch type) — 3,205 rows vs the
  // movement feed's 677. The movement endpoint with an empty pitch_type returns
  // each pitcher's PRIMARY pitch only, so using it alone would silently record
  // a five-pitch arsenal as a one-pitch one. Movement still supplies velo,
  // break and handedness; this supplies the mix.
  pitch_arsenal: (y) => `${BASE}/pitch-arsenal-stats?type=pitcher&pitchType=&year=${y}&min=1&csv=true`,
  // WIDE velo: one row per pitcher, one column per pitch type
  // (`{abbr}_avg_speed`). Session 69 — this is the velo FIX. The movement feed
  // carries only each pitcher's primary pitch, which left velo at 53% coverage
  // and unusable as an archetype axis.
  pitch_velo: (y) => `${BASE}/pitch-arsenals?year=${y}&min=1&type=avg_speed&hand=&csv=true`,
});

/** statsapi — free, no key. Two calls that complete the Tier-A joins.
 *  HANDEDNESS: batSide + pitchHand + primaryPosition for every player.
 *  ROLE: gamesStarted/gamesPitched/saves/holds — real usage, not an IP proxy. */
const STATSAPI = Object.freeze({
  handedness: (y) => `https://statsapi.mlb.com/api/v1/sports/1/players?season=${y}`,
  pitcher_roles: (y) => `https://statsapi.mlb.com/api/v1/stats?stats=season&group=pitching&season=${y}&limit=2000&sportId=1&playerPool=ALL`,
});

async function fetchText(url, opts = {}) {
  if (opts.fetchImpl) return opts.fetchImpl(url);
  const res = await axios.get(url, {
    timeout: HTTP_TIMEOUT_MS,
    responseType: 'text',
    headers: { Accept: 'text/csv,*/*' },
    // Savant occasionally returns a large body; don't let axios truncate.
    maxContentLength: 64 * 1024 * 1024,
    transformResponse: [(d) => d],
  });
  return typeof res.data === 'string' ? res.data : null;
}

/** Savant's first column is the literal header `last_name, first_name` — a
 *  comma INSIDE a quoted header. The value is likewise `"Last, First"`. */
function flipName(v) {
  const s = String(v || '').trim();
  if (!s) return null;
  const i = s.indexOf(',');
  if (i < 0) return s;
  const last = s.slice(0, i).trim();
  const first = s.slice(i + 1).trim();
  return first ? `${first} ${last}` : last;
}

function idOf(row) {
  const raw = row.player_id ?? row.pitcher_id ?? row.batter_id;
  const n = Number(raw);
  return Number.isFinite(n) && n > 0 ? n : null;
}

/** Row → { id, name, metrics } with EVERY metric strictly parsed (null, not 0). */
function normalizeRow(row, fields) {
  const id = idOf(row);
  if (id == null) return null;
  const metrics = {};
  for (const [out, src] of Object.entries(fields)) {
    const v = numOrNull(row[src]);
    if (v != null) metrics[out] = v;
  }
  return { id, name: flipName(row['last_name, first_name'] || row.last_name), metrics };
}

const BATTER_DISCIPLINE = {
  pa: 'pa', k_pct: 'k_percent', bb_pct: 'bb_percent', whiff_pct: 'whiff_percent',
  swing_pct: 'swing_percent', chase_pct: 'oz_swing_percent', z_swing_pct: 'z_swing_percent',
  sweet_spot_pct: 'sweet_spot_percent', barrel_pct: 'barrel_batted_rate',
  hard_hit_pct: 'hard_hit_percent', avg_best_speed: 'avg_best_speed',
};
const BATTED_BALL = {
  bip: 'attempts', avg_launch_angle: 'avg_hit_angle', sweet_spot_pct_bb: 'anglesweetspotpercent',
  max_exit_velo: 'max_hit_speed', avg_exit_velo: 'avg_hit_speed', ev50: 'ev50',
  fb_ld_pct: 'fbld', gb_pct_bb: 'gb', max_distance: 'max_distance',
  avg_distance: 'avg_distance', avg_hr_distance: 'avg_hr_distance',
  ev95_pct: 'ev95percent', barrels: 'barrels', barrel_pct_bb: 'brl_percent', barrel_per_pa: 'brl_pa',
};
const PITCHER_DISCIPLINE = {
  ip: 'p_formatted_ip', k_pct: 'k_percent', bb_pct: 'bb_percent', whiff_pct: 'whiff_percent',
  swing_pct: 'swing_percent', chase_pct: 'oz_swing_percent', gb_pct: 'groundballs_percent',
  fb_pct: 'flyballs_percent', ld_pct: 'linedrives_percent',
  barrel_pct_allowed: 'barrel_batted_rate', hard_hit_pct_allowed: 'hard_hit_percent',
  arm_angle: 'arm_angle',
};

function indexBy(rows, fields) {
  const out = new Map();
  for (const r of rows) {
    const n = normalizeRow(r, fields);
    if (!n) continue;
    const prev = out.get(n.id);
    out.set(n.id, prev ? { ...prev, ...n, metrics: { ...prev.metrics, ...n.metrics } } : n);
  }
  return out;
}

/**
 * Pitch MOVEMENT — one row per pitcher (their PRIMARY pitch). Supplies velo,
 * break and, uniquely, HANDEDNESS (`pitch_hand`). Not the arsenal: see
 * indexArsenal.
 */
function indexPitchMix(rows) {
  const out = new Map();
  for (const r of rows) {
    const id = idOf(r);
    if (id == null) continue;
    const entry = out.get(id) || { id, name: flipName(r['last_name, first_name']), throws: null, pitches: [] };
    if (!entry.throws && r.pitch_hand) entry.throws = String(r.pitch_hand).trim().toUpperCase() || null;
    // `pitch_per` arrives as a FRACTION (0.371), unlike every other _pct field
    // on these feeds. Normalise to a percentage so downstream never has to know
    // which endpoint a number came from.
    const rawUsage = numOrNull(r.pitch_per);
    const usage = rawUsage == null ? null : (rawUsage <= 1 ? Math.round(rawUsage * 1000) / 10 : rawUsage);
    const type = (r.pitch_type || '').trim();
    if (type) {
      entry.pitches.push({
        type,
        name: (r.pitch_type_name || '').trim() || null,
        usage_pct: usage,
        velo: numOrNull(r.avg_speed),
        break_z_induced: numOrNull(r.pitcher_break_z_induced),
        break_x: numOrNull(r.pitcher_break_x),
        thrown: numOrNull(r.pitches_thrown),
      });
    }
    out.set(id, entry);
  }
  for (const e of out.values()) {
    e.pitches.sort((a, b) => (b.usage_pct ?? 0) - (a.usage_pct ?? 0));
  }
  return out;
}

/** Wide velo CSV → Map(pitcherId → { FF: 96.7, SL: 89.4, ... }). */
function indexVelo(rows) {
  const out = new Map();
  for (const r of rows) {
    const id = Number(r.pitcher ?? r.player_id);
    if (!Number.isFinite(id) || id <= 0) continue;
    const byType = {};
    for (const [k, v] of Object.entries(r)) {
      const m = /^([a-z]{2})_avg_speed$/.exec(k);
      if (!m) continue;
      const n = numOrNull(v);
      if (n != null) byType[m[1].toUpperCase()] = n;
    }
    if (Object.keys(byType).length) out.set(id, byType);
  }
  return out;
}

/**
 * Pitch ARSENAL — one row per (pitcher, pitch type). The real mix: usage%,
 * whiff%, K% and contact quality per pitch. Merged with the movement feed's
 * velo/break for the pitcher's primary pitch.
 */
function indexArsenal(rows, movementIdx, veloIdx = new Map()) {
  const out = new Map();
  for (const r of rows) {
    const id = idOf(r);
    if (id == null) continue;
    const type = (r.pitch_type || '').trim();
    if (!type) continue;
    const entry = out.get(id) || { id, name: flipName(r['last_name, first_name']), throws: null, pitches: [] };
    entry.pitches.push({
      type,
      name: (r.pitch_name || '').trim() || null,
      usage_pct: numOrNull(r.pitch_usage),
      thrown: numOrNull(r.pitches),
      whiff_pct: numOrNull(r.whiff_percent),
      k_pct: numOrNull(r.k_percent),
      put_away_pct: numOrNull(r.put_away),
      hard_hit_pct: numOrNull(r.hard_hit_percent),
      run_value_per_100: numOrNull(r.run_value_per_100),
      velo: null,
      break_z_induced: null,
      break_x: null,
    });
    out.set(id, entry);
  }
  // Velo comes from the WIDE feed, matched BY PITCH TYPE — every pitch, not
  // just the primary. Break + handedness still come from the movement feed
  // (primary pitch only, which is all it carries).
  for (const [id, entry] of out) {
    const velos = veloIdx.get(id);
    if (velos) {
      for (const p of entry.pitches) {
        if (velos[p.type] != null) p.velo = velos[p.type];
      }
    }
    const mv = movementIdx.get(id);
    if (!mv) continue;
    entry.throws = mv.throws || entry.throws;
    const primary = mv.pitches && mv.pitches[0];
    if (primary) {
      const match = entry.pitches.find((p) => p.type === primary.type);
      if (match) {
        match.break_z_induced = primary.break_z_induced;
        match.break_x = primary.break_x;
        if (match.velo == null) match.velo = primary.velo;
      }
    }
  }
  // A pitcher who appears ONLY in the movement feed still gets his handedness
  // and his one measured pitch — absent beats dropping him.
  for (const [id, mv] of movementIdx) {
    if (!out.has(id)) out.set(id, mv);
  }
  for (const e of out.values()) e.pitches.sort((a, b) => (b.usage_pct ?? 0) - (a.usage_pct ?? 0));
  return out;
}

/**
 * fetchSeason(season, opts) — pull all five feeds and return normalized indexes.
 * Any single feed failing degrades to an EMPTY index for that feed (its metrics
 * become absent) rather than failing the whole pull: partial mechanism data is
 * honest, a failed night is not a reason to have none.
 */
async function fetchSeason(season = DEFAULT_SEASON, opts = {}) {
  const get = async (name) => {
    try {
      const text = await fetchText(FEEDS[name](season), opts);
      const rows = text ? parseCsv(text) : [];
      return Array.isArray(rows) ? rows : [];
    } catch (err) {
      console.warn(`[statcast] feed ${name} failed:`, err.message);
      return [];
    }
  };

  const [bd, bbb, pd, pbb, mv, ars, velo] = await Promise.all([
    get('batter_discipline'), get('batter_batted_ball'),
    get('pitcher_discipline'), get('pitcher_batted_ball'),
    get('pitch_movement'), get('pitch_arsenal'), get('pitch_velo'),
  ]);
  const [hands, roles] = await Promise.all([fetchHandedness(season, opts), fetchRoles(season, opts)]);

  return {
    season,
    batterDiscipline: indexBy(bd, BATTER_DISCIPLINE),
    batterBattedBall: indexBy(bbb, BATTED_BALL),
    pitcherDiscipline: indexBy(pd, PITCHER_DISCIPLINE),
    pitcherBattedBall: indexBy(pbb, BATTED_BALL),
    pitchMix: indexArsenal(ars, indexPitchMix(mv), indexVelo(velo)),
    handedness: hands,
    roles,
    counts: {
      batter_discipline: bd.length, batter_batted_ball: bbb.length,
      pitcher_discipline: pd.length, pitcher_batted_ball: pbb.length,
      pitch_movement: mv.length, pitch_arsenal: ars.length, pitch_velo: velo.length,
      handedness: hands.size, pitcher_roles: roles.size,
    },
  };
}

/** statsapi handedness → Map(id → {bats, throws, position}). ONE call, and it
 *  carried 1,316/1,316 in the live probe. */
async function fetchHandedness(season, opts = {}) {
  const out = new Map();
  try {
    const raw = opts.handednessJson || await fetchJson(STATSAPI.handedness(season), opts);
    for (const p of (raw && raw.people) || []) {
      const id = Number(p.id);
      if (!Number.isFinite(id)) continue;
      out.set(id, {
        bats: (p.batSide && p.batSide.code) || null,
        throws: (p.pitchHand && p.pitchHand.code) || null,
        position: (p.primaryPosition && p.primaryPosition.abbreviation) || null,
      });
    }
  } catch (err) { console.warn('[statcast] handedness join failed:', err.message); }
  return out;
}

/** statsapi pitcher usage → Map(id → {gs, gp, gf, saves, holds}). `playerPool=ALL`
 *  is required: the default returns only the ~57 qualified pitchers. */
async function fetchRoles(season, opts = {}) {
  const out = new Map();
  try {
    const raw = opts.rolesJson || await fetchJson(STATSAPI.pitcher_roles(season), opts);
    const splits = ((raw && raw.stats) || [])[0] || {};
    for (const s of splits.splits || []) {
      const id = Number(s.player && s.player.id);
      if (!Number.isFinite(id)) continue;
      const st = s.stat || {};
      out.set(id, {
        gs: numOrNull(st.gamesStarted), gp: numOrNull(st.gamesPitched),
        gf: numOrNull(st.gamesFinished), saves: numOrNull(st.saves), holds: numOrNull(st.holds),
      });
    }
  } catch (err) { console.warn('[statcast] roles join failed:', err.message); }
  return out;
}

async function fetchJson(url, opts = {}) {
  if (opts.fetchJsonImpl) return opts.fetchJsonImpl(url);
  const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS, headers: { Accept: 'application/json' } });
  return res.data;
}

module.exports = {
  fetchSeason,
  DEFAULT_SEASON,
  FEEDS,
  __internals: {
    flipName, idOf, normalizeRow, indexBy, indexPitchMix, indexArsenal, indexVelo,
    fetchHandedness, fetchRoles, STATSAPI,
    BATTER_DISCIPLINE, BATTED_BALL, PITCHER_DISCIPLINE, fetchText,
  },
};
