/* ============================================================
   Session 76 — PLATOON SPLITS. Highest value, highest noise.
   The regression IS the feature: raw splits applied unregressed are harmful.
   ============================================================ */

const pl = require('../../src/services/platoonSplits');
const ch = require('../../src/services/challengerProjection');

/** A hitter with `pa` PA vs LHP at `avg`, 300 PA vs RHP at his overall rate. */
const mk = (pa, avg, overall = 0.250) => ({
  vsL: { pa, avg, ops: avg * 2.6, slg: avg * 1.7 },
  vsR: { pa: 300, avg: overall, ops: overall * 2.6, slg: overall * 1.7 },
  overall: { pa: pa + 300, avg: overall, ops: overall * 2.6, slg: overall * 1.7 },
});
const est = (splits, ph = 'L', stat = 'hits', bh = 'R') =>
  pl.platoonEstimate({ splits, batterHand: bh, pitcherHand: ph, statType: stat });

describe('THE MAKE-OR-BREAK TEST — thin splits must not move anything', () => {
  it('a .310 split on 30 PA is regressed almost entirely away', () => {
    const e = est(mk(30, 0.310));
    expect(e.observed_weight).toBeLessThan(0.06);   // ~4.8% evidence
    expect(Math.abs(e.multiplier - 1)).toBeLessThan(0.02);
    expect(e.material).toBe(false);
  });

  it('the SAME split on 400 PA produces a real adjustment', () => {
    const e = est(mk(400, 0.310));
    expect(e.observed_weight).toBeCloseTo(0.4, 1);
    expect(e.multiplier).toBeGreaterThan(1.05);
    expect(e.material).toBe(true);
  });

  it('deep sample moves the projection MANY times more than thin', () => {
    const thin = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'hits', matchup: est(mk(30, 0.310)) });
    const deep = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'hits', matchup: est(mk(400, 0.310)) });
    // If this ratio ever collapses toward 1, the regression has broken and
    // platoon is applying noise.
    expect(Math.abs(deep.delta)).toBeGreaterThan(Math.abs(thin.delta) * 5);
  });

  it('is symmetric — a deep NEGATIVE split suppresses by the same magnitude', () => {
    const up = est(mk(400, 0.310));
    const down = est(mk(400, 0.190));
    expect(Math.abs(up.multiplier - 1)).toBeCloseTo(Math.abs(down.multiplier - 1), 2);
    expect(down.multiplier).toBeLessThan(1);
  });

  it('regresses toward the HITTER\'S OWN rate, not the league', () => {
    // The question is "is he different vs this hand than he normally is", so a
    // hitter whose split matches his overall gets NO adjustment however deep.
    const e = est(mk(2000, 0.250, 0.250));
    expect(e.multiplier).toBe(1);
  });
});

describe('regressSplit — the mechanism', () => {
  it('weight rises with PA and never reaches 1', () => {
    const w = (pa) => pl.regressSplit({ observedRate: 0.3, observedPa: pa, priorRate: 0.25 }).weight;
    expect(w(30)).toBeLessThan(w(130));
    expect(w(130)).toBeLessThan(w(400));
    expect(w(400)).toBeLessThan(w(1000));
    expect(w(100000)).toBeLessThan(1);
  });

  it('no observation → the prior stands, untouched', () => {
    const r = pl.regressSplit({ observedRate: null, observedPa: null, priorRate: 0.25 });
    expect(r.rate).toBe(0.25);
    expect(r.weight).toBe(0);
  });

  it('no prior → no estimate at all (never invents one)', () => {
    expect(pl.regressSplit({ observedRate: 0.3, observedPa: 400, priorRate: null }).rate).toBeNull();
  });

  it('K is conservative — 600 PA of prior', () => {
    expect(pl.K_PA).toBe(600);
    expect(pl.regressSplit({ observedRate: 1, observedPa: 600, priorRate: 0 }).weight).toBe(0.5);
  });
});

describe('honest-absent — frequent and CORRECT here', () => {
  it.each([
    ['no batter handedness', { bh: null, ph: 'L' }, 'batter_hand_absent'],
    ['no pitcher handedness', { bh: 'R', ph: null }, 'pitcher_hand_absent'],
  ])('%s → multiplier 1', (_n, { bh, ph }, state) => {
    const e = pl.platoonEstimate({ splits: mk(400, 0.310), batterHand: bh, pitcherHand: ph, statType: 'hits' });
    expect(e.multiplier).toBe(1);
    expect(e.state).toBe(state);
  });

  it('a stat platoon says nothing about → not_applicable', () => {
    expect(est(mk(400, 0.310), 'L', 'stolen_bases').state).toBe('not_applicable');
  });

  it('no splits at all → absent, never a guess', () => {
    expect(pl.platoonEstimate({ splits: null, batterHand: 'R', pitcherHand: 'L', statType: 'hits' }).state)
      .toBe('splits_absent');
  });

  it('a missing SIDE falls back to the prior rather than zero', () => {
    const s = { vsL: null, vsR: { pa: 300, avg: 0.25 }, overall: { pa: 300, avg: 0.25 } };
    const e = pl.platoonEstimate({ splits: s, batterHand: 'R', pitcherHand: 'L', statType: 'hits' });
    expect(e.multiplier).toBe(1);
    expect(e.observed_weight).toBe(0);
  });
});

describe('conditioned on TONIGHT\'S pitcher hand', () => {
  it('uses the vs-LHP split against an LHP and the vs-RHP split against an RHP', () => {
    const s = {
      vsL: { pa: 400, avg: 0.310 }, vsR: { pa: 400, avg: 0.210 },
      overall: { pa: 800, avg: 0.260 },
    };
    const vsL = pl.platoonEstimate({ splits: s, batterHand: 'R', pitcherHand: 'L', statType: 'hits' });
    const vsR = pl.platoonEstimate({ splits: s, batterHand: 'R', pitcherHand: 'R', statType: 'hits' });
    expect(vsL.multiplier).toBeGreaterThan(1);
    expect(vsR.multiplier).toBeLessThan(1);
  });

  it('inverts for strikeouts — a higher K rate means a LOWER hit prop, not higher', () => {
    const s = {
      vsL: { pa: 400, k_rate: 0.35 }, vsR: { pa: 400, k_rate: 0.20 },
      overall: { pa: 800, k_rate: 0.25 },
    };
    const e = pl.platoonEstimate({ splits: s, batterHand: 'R', pitcherHand: 'L', statType: 'strikeouts' });
    // He strikes out MORE vs LHP → the strikeout prop over is MORE likely.
    expect(e.rate_key).toBe('k_rate');
    expect(e.multiplier).not.toBe(1);
  });
});

describe('INDEPENDENT of the environment coefficient', () => {
  it('platoon rides its own slot, labelled separately', () => {
    const r = ch.adjust({
      pWin: 0.5, direction: 'over', statType: 'hits',
      matchup: { ...est(mk(400, 0.310)), label: 'PLATOON' },
      environment: { multiplier: 1.10, label: 'PARK × WEATHER', venue: 'Coors Field' },
    });
    const axes = r.adjustments.map((a) => a.axis);
    expect(axes).toContain('matchup');
    expect(axes).toContain('environment');
    // Two separate entries → the instrument can attribute them independently.
    expect(r.adjustments.find((a) => a.axis === 'matchup').label).toBe('PLATOON');
  });

  it('mirrors on the under', () => {
    const m = { ...est(mk(400, 0.310)), label: 'PLATOON' };
    const over = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'hits', matchup: m });
    const under = ch.adjust({ pWin: 0.5, direction: 'under', statType: 'hits', matchup: m });
    expect(under.delta).toBeCloseTo(-over.delta, 3);
  });

  it('is capped — platoon leans, never re-forecasts', () => {
    const absurd = est(mk(5000, 0.600));
    expect(Math.abs(absurd.multiplier - 1)).toBeLessThanOrEqual(pl.MAX_MULT + 1e-9);
  });
});

describe('parseSplits — statsapi shape', () => {
  const payload = { stats: [{ splits: [
    { split: { code: 'vl' }, stat: { plateAppearances: 126, avg: '.259', ops: '.698', slg: '.400', strikeOuts: 30, baseOnBalls: 10 } },
    { split: { code: 'vr' }, stat: { plateAppearances: 265, avg: '.248', ops: '.755', slg: '.430', strikeOuts: 55, baseOnBalls: 20 } },
  ] }] };

  it('parses both sides and derives rates', () => {
    const s = pl.parseSplits(payload);
    expect(s.vsL.pa).toBe(126);
    expect(s.vsL.avg).toBeCloseTo(0.259, 3);
    expect(s.vsR.k_rate).toBeCloseTo(55 / 265, 3);
  });

  it('builds the overall prior as a PA-weighted blend when none is supplied', () => {
    const s = pl.parseSplits(payload);
    expect(s.overall.pa).toBe(391);
    expect(s.overall.avg).toBeGreaterThan(0.248);
    expect(s.overall.avg).toBeLessThan(0.259);
  });

  it('an absent side stays null — never zero-filled', () => {
    const s = pl.parseSplits({ stats: [{ splits: [payload.stats[0].splits[1]] }] });
    expect(s.vsL).toBeNull();
  });
});
