'use strict';

/**
 * PLATOON SPLITS (Layer 3, Step 6) — the highest-value and highest-noise signal.
 *
 * The market often prices a hitter's BLENDED line while tonight he faces only
 * one handedness. That gap is the value. The danger is that platoon samples are
 * the thinnest in baseball — a hitter may see ~130 PA vs LHP in a season — so a
 * raw split is mostly noise.
 *
 * ── THE SPINE: REGRESS, OR THIS IS ACTIVELY HARMFUL ──────────────────────
 * Applying a raw split adjusts projections on noise, which is worse than not
 * building the adjuster at all. Every estimate here is shrunk toward a prior:
 *
 *     regressed = (PA · observed + K · prior) / (PA + K)
 *
 * The PRIOR is the hitter's OWN overall rate, not the league's. The question a
 * platoon adjustment answers is "is he DIFFERENT against this hand than he
 * normally is" — so the null hypothesis is his own blended line, and a hitter
 * with no evidence of a split correctly gets no adjustment.
 *
 * K = 600 PA (env-tunable). Platoon skill is famously slow to stabilise; the
 * sabermetric literature puts the half-signal point for right-handed batters
 * near 1,000 PA vs a hand. 600 is deliberately conservative in the direction of
 * doing nothing:
 *      30 PA →  4.8% weight on the observed split   (essentially the prior)
 *     130 PA →   18%
 *     400 PA →   40%
 *   1,000 PA →   63%
 * A 30-PA .310 hitter vs LHP therefore moves the projection almost not at all,
 * which is the correct answer, not a limitation.
 *
 * ── IT IS A MATCHUP SIGNAL, NOT AN ENVIRONMENT ───────────────────────────
 * Park and weather compose into one environment coefficient. Platoon does NOT
 * join them: it is a property of this hitter against this pitcher's hand, and
 * entangling it with the stadium would make both harder to attribute when the
 * instrument scores them.
 */

/** Shrinkage constant, in PA vs the hand. Higher = more conservative. */
const K_PA = Number(process.env.PLATOON_K_PA) || 600;
/** Below this the adjustment is reported as immaterial (it will already be
 *  tiny from the regression; this is the honesty label, not the mechanism). */
const MATERIAL_THRESHOLD = Number(process.env.PLATOON_MATERIAL) || 0.02;
/** Hard cap. Platoon leans; it never re-forecasts. */
const MAX_MULT = Number(process.env.PLATOON_MAX_MULT) || 0.15;

/** Stats a platoon split speaks to. Absent elsewhere — we are not inventing a
 *  platoon effect on a strikeout-allowed prop. */
const PLATOON_STATS = Object.freeze(new Set([
  'hits', 'home_runs', 'total_bases', 'rbi', 'runs', 'doubles', 'strikeouts', 'walks',
]));

/** Which observed rate speaks to which stat. */
const STAT_RATE = Object.freeze({
  hits: 'avg', doubles: 'avg', runs: 'ops', rbi: 'ops',
  home_runs: 'slg', total_bases: 'slg',
  strikeouts: 'k_rate', walks: 'bb_rate',
});
/** Stats where a HIGHER rate means a LOWER prop outcome. */
const INVERTED = Object.freeze(new Set(['strikeouts']));

const num = (v) => {
  if (v == null || v === '') return null;
  const n = typeof v === 'number' ? v : Number(v);
  return Number.isFinite(n) ? n : null;
};
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);

/**
 * regressSplit({ observedRate, observedPa, priorRate, k }) — PURE.
 * The whole safety mechanism in six lines. Returns the shrunk rate plus the
 * weight actually given to the observation, so callers can SEE how much of the
 * estimate is evidence and how much is prior.
 */
function regressSplit({ observedRate, observedPa, priorRate, k = K_PA } = {}) {
  const obs = num(observedRate);
  const pa = num(observedPa);
  const prior = num(priorRate);
  if (prior == null) return { rate: null, weight: 0, reason: 'no prior' };
  if (obs == null || pa == null || pa <= 0) {
    return { rate: prior, weight: 0, reason: 'no observed split — prior stands' };
  }
  const weight = pa / (pa + k);
  return {
    rate: obs * weight + prior * (1 - weight),
    weight: Math.round(weight * 1000) / 1000,
    observed_pa: pa,
    reason: null,
  };
}

/**
 * platoonEstimate({ splits, batterHand, pitcherHand, statType }) — the signal.
 *
 * splits: { vsL: {pa, avg, ops, slg, k_rate, bb_rate}, vsR: {…}, overall: {…} }
 * Returns { multiplier, state, material, ... }. 1.0 is a true no-op and is
 * reached honestly and OFTEN — most hitters do not have enough evidence of a
 * split to move a projection, and saying so is the correct output.
 */
function platoonEstimate({ splits, batterHand, pitcherHand, statType } = {}) {
  const stat = String(statType || '').toLowerCase();
  const neutral = (state, reason, extra = {}) => ({
    multiplier: 1, state, reason, material: false,
    regressed_rate: null, prior_rate: null, observed_weight: 0, ...extra,
  });

  if (!PLATOON_STATS.has(stat)) return neutral('not_applicable', 'platoon says nothing about this stat');

  // GATE: without the hitter's handedness there is no platoon question to ask.
  // (statcast_aggregates.bats is 100% populated since Session 69, but a prop
  // for a player we never resolved still lands here.)
  if (!batterHand) return neutral('batter_hand_absent', 'no batter handedness');
  const ph = String(pitcherHand || '').toUpperCase();
  if (ph !== 'L' && ph !== 'R') return neutral('pitcher_hand_absent', "tonight's pitcher handedness unknown");

  if (!splits || !splits.overall) return neutral('splits_absent', 'no split data for this hitter');

  const side = ph === 'L' ? splits.vsL : splits.vsR;
  const rateKey = STAT_RATE[stat];
  const prior = num(splits.overall[rateKey]);
  if (prior == null || prior === 0) return neutral('no_prior', 'hitter has no overall rate for this stat');

  const reg = regressSplit({
    observedRate: side ? num(side[rateKey]) : null,
    observedPa: side ? num(side.pa) : null,
    priorRate: prior,
  });
  if (reg.rate == null) return neutral('no_prior', reg.reason);

  // The adjustment is the REGRESSED split relative to his own blended line.
  let ratio = reg.rate / prior;
  if (INVERTED.has(stat)) ratio = ratio === 0 ? 1 : 1 / ratio;
  const capped = clamp(ratio - 1, -MAX_MULT, MAX_MULT);
  const multiplier = Math.round((1 + capped) * 1000) / 1000;
  const material = Math.abs(multiplier - 1) >= MATERIAL_THRESHOLD;

  return {
    multiplier,
    state: 'present',
    reason: null,
    material,
    batter_hand: batterHand,
    pitcher_hand: ph,
    rate_key: rateKey,
    prior_rate: Math.round(prior * 10000) / 10000,
    observed_rate: side ? num(side[rateKey]) : null,
    observed_pa: reg.observed_pa ?? 0,
    observed_weight: reg.weight,     // how much is evidence vs prior
    regressed_rate: Math.round(reg.rate * 10000) / 10000,
    capped: capped !== ratio - 1,
  };
}

/** statsapi platoon splits for one hitter. `vl`/`vr` = vs left/right pitching. */
const SPLITS_URL = (playerId, season) =>
  `https://statsapi.mlb.com/api/v1/people/${playerId}/stats`
  + `?stats=statSplits&sitCodes=vl,vr&group=hitting&season=${season}`;

/**
 * parseSplits(payload, overall) — PURE. statsapi → the shape platoonEstimate
 * wants. A side with no rows is ABSENT, never zero-filled.
 */
function parseSplits(payload, overall = null) {
  const out = { vsL: null, vsR: null, overall: overall || null };
  for (const st of (payload && payload.stats) || []) {
    for (const sp of st.splits || []) {
      const code = (sp.split && sp.split.code) || '';
      const s = sp.stat || {};
      const pa = num(s.plateAppearances);
      const row = {
        pa,
        avg: num(s.avg), ops: num(s.ops), slg: num(s.slg), obp: num(s.obp),
        k_rate: pa > 0 && num(s.strikeOuts) != null ? num(s.strikeOuts) / pa : null,
        bb_rate: pa > 0 && num(s.baseOnBalls) != null ? num(s.baseOnBalls) / pa : null,
      };
      if (code === 'vl') out.vsL = row;
      if (code === 'vr') out.vsR = row;
    }
  }
  // Overall = the two sides combined, when a blended line was not supplied.
  if (!out.overall && (out.vsL || out.vsR)) {
    const parts = [out.vsL, out.vsR].filter((r) => r && r.pa);
    const totalPa = parts.reduce((a, r) => a + r.pa, 0);
    if (totalPa > 0) {
      const w = (key) => {
        const vals = parts.filter((r) => num(r[key]) != null);
        if (!vals.length) return null;
        const d = vals.reduce((a, r) => a + r.pa, 0);
        return d > 0 ? vals.reduce((a, r) => a + r[key] * r.pa, 0) / d : null;
      };
      out.overall = { pa: totalPa, avg: w('avg'), ops: w('ops'), slg: w('slg'), obp: w('obp'), k_rate: w('k_rate'), bb_rate: w('bb_rate') };
    }
  }
  return out;
}

module.exports = {
  platoonEstimate,
  regressSplit,
  parseSplits,
  SPLITS_URL,
  K_PA,
  MATERIAL_THRESHOLD,
  MAX_MULT,
  PLATOON_STATS,
  STAT_RATE,
};
