/* ============================================================
   Session 71 — CHAMPION / CHALLENGER.

   The champion serves and grades users, unchanged. The challenger runs live and
   is measured. These lock the three properties that make the A/B worth running:
   isolation, interpretability, and identity-where-there-is-no-signal.
   ============================================================ */

const ch = require('../../src/services/challengerProjection');
const axes = require('../../src/services/archetypeAxes');

const JUDGE = axes.classifyPlayer({
  role: 'batter', bats: 'R', sample_pa: 261, k_pct: 27.6, bb_pct: 16.1, chase_pct: 25.8,
  barrel_pct: 21.7, hard_hit_pct: 57.3, avg_launch_angle: 14.6, sweet_spot_pct: 33.6,
});
const BELL = axes.classifyPlayer({
  role: 'batter', bats: 'S', sample_pa: 387, k_pct: 21.7, bb_pct: 7.5, chase_pct: 30.6,
  barrel_pct: 10.3, hard_hit_pct: 43.4, avg_launch_angle: 13.9, sweet_spot_pct: 33.8,
});
const SKUBAL = axes.classifyPlayer({
  role: 'pitcher', role_detail: 'starter', throws: 'L', sample_ip: 82.2, k_pct: 30.5,
  bb_pct: 3.4, chase_pct: 36.7, barrel_pct: 6.7, gb_pct: 49, fb_pct: 22.6, arm_angle: 46.9,
  pitch_mix: [{ type: 'FF', velo: 96.7 }],
});
const THIN = axes.classifyPlayer({ role: 'batter', sample_pa: 12, barrel_pct: 30 });

describe('identical where there is no signal — the clean-experiment property', () => {
  it.each([
    ['unremarkable player', BELL, 'no_distinctive_axis_or_environment'],
    ['thin sample', THIN, 'archetype_absent_or_thin'],
    ['no classification', null, 'archetype_absent_or_thin'],
  ])('%s → challenger === champion, exactly', (_n, cls, reason) => {
    const r = ch.adjust({ pWin: 0.42, direction: 'over', statType: 'home_runs', classification: cls });
    expect(r.p_win_challenger).toBe(0.42);
    expect(r.delta).toBe(0);
    expect(r.adjustments).toEqual([]);
    expect(r.reason).toBe(reason);
  });

  it('an unmapped stat leaves the champion untouched — no speculative mapping', () => {
    const r = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'nonsense_stat', classification: JUDGE });
    expect(r.p_win_challenger).toBe(0.5);
    expect(r.reason).toBe('stat_not_mapped');
  });

  it('no champion probability → nothing to adjust', () => {
    for (const p of [null, undefined, 0, 1]) {
      expect(ch.adjust({ pWin: p, direction: 'over', statType: 'hits', classification: JUDGE }).reason)
        .toBe('no_champion_probability');
    }
  });
});

describe('interpretable — every difference is attributable', () => {
  it('Judge HR: BOMBER lifts, WHIFF RISK offsets, and both are named', () => {
    const r = ch.adjust({ pWin: 0.42, direction: 'over', statType: 'home_runs', classification: JUDGE });
    const labels = r.adjustments.map((a) => a.label);
    expect(labels).toContain('BOMBER');
    expect(labels).toContain('WHIFF RISK');
    expect(r.adjustments.find((a) => a.label === 'BOMBER').nudge).toBeGreaterThan(0);
    expect(r.adjustments.find((a) => a.label === 'WHIFF RISK').nudge).toBeLessThan(0);
    expect(r.p_win_challenger).toBeGreaterThan(0.42);
  });

  it('Skubal strikeouts: WHIFF + TRAPDOOR + CANNON all push the same way', () => {
    const r = ch.adjust({ pWin: 0.6, direction: 'over', statType: 'strikeouts', classification: SKUBAL });
    expect(r.p_win_challenger).toBeGreaterThan(0.6);
    expect(r.adjustments.every((a) => a.nudge > 0)).toBe(true);
  });

  it('a strikeout arm makes hits-allowed LESS likely — sign is per stat', () => {
    const r = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'hits_allowed', classification: SKUBAL });
    expect(r.p_win_challenger).toBeLessThan(0.5);
  });

  it('opposing signals can cancel to exactly zero — an honest "no lean"', () => {
    const r = ch.adjust({ pWin: 0.55, direction: 'over', statType: 'strikeouts', classification: JUDGE });
    expect(r.delta).toBe(0);
    expect(r.adjustments.length).toBe(2); // both still recorded
  });
});

describe('direction and bounds', () => {
  it('UNDER mirrors OVER exactly', () => {
    const over = ch.adjust({ pWin: 0.42, direction: 'over', statType: 'home_runs', classification: JUDGE });
    const under = ch.adjust({ pWin: 0.58, direction: 'under', statType: 'home_runs', classification: JUDGE });
    expect(under.delta).toBeCloseTo(-over.delta, 3);
  });

  it('log-odds space keeps every output a valid probability', () => {
    for (const p of [0.02, 0.5, 0.98]) {
      const r = ch.adjust({ pWin: p, direction: 'over', statType: 'strikeouts', classification: SKUBAL });
      expect(r.p_win_challenger).toBeGreaterThan(0);
      expect(r.p_win_challenger).toBeLessThan(1);
    }
  });

  it('the total nudge is capped — a stack of axes cannot re-forecast', () => {
    const r = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'strikeouts', classification: SKUBAL });
    expect(Math.abs(r.delta)).toBeLessThan(0.12); // a lean, not a re-forecast
  });
});

describe('isolation — the champion is never touched', () => {
  it('adjust() is pure: same input, same output, no shared state', () => {
    const a = ch.adjust({ pWin: 0.42, direction: 'over', statType: 'home_runs', classification: JUDGE });
    const b = ch.adjust({ pWin: 0.42, direction: 'over', statType: 'home_runs', classification: JUDGE });
    expect(a).toEqual(b);
  });

  it('attachChallenger preserves p_win byte-for-byte on every grade', async () => {
    const grades = [
      { player: 'Aaron Judge', stat_type: 'home_runs', direction: 'over', p_win: 0.42, grade: 'B' },
      { player: 'Josh Bell', stat_type: 'home_runs', direction: 'over', p_win: 0.33, grade: 'C' },
    ];
    const out = await ch.attachChallenger(grades, (n) => (n === 'Aaron Judge' ? JUDGE : BELL));
    expect(out[0].p_win).toBe(0.42);
    expect(out[1].p_win).toBe(0.33);
    expect(out[0].p_win_challenger).toBeGreaterThan(0.42); // moved
    expect(out[1].p_win_challenger).toBe(0.33);            // identical
    expect(out[1].challenger_delta).toBe(0);
  });

  it('stamps a version so a future adjustment is distinguishable', async () => {
    const out = await ch.attachChallenger([{ player: 'x', stat_type: 'hits', p_win: 0.5 }], () => null);
    expect(out[0].challenger_version).toBe(ch.CHALLENGER_VERSION);
  });
});

describe('retention + serving contract', () => {
  const fs = require('fs');
  it('the ledger retains the challenger beside the champion on one row', () => {
    const src = fs.readFileSync(require.resolve('../../src/services/ledgerService'), 'utf8');
    expect(src).toMatch(/p_win_challenger: numOrNull\(g\.p_win_challenger\)/);
    expect(src).toMatch(/challenger_adjustments/);
  });

  it('the snapshot writes the CHALLENGER-carrying grades but serves `enriched`', () => {
    const src = fs.readFileSync(require.resolve('../../src/services/snapshotService'), 'utf8');
    expect(src).toMatch(/recordPipelineGrades\(sp, withChallenger, props/);
    // the served snapshot payload is still the untouched champion object
    expect(src).toMatch(/grades: enriched/);
  });

  it('a challenger failure can never break the pipeline it is measured inside', () => {
    const src = fs.readFileSync(require.resolve('../../src/services/snapshotService'), 'utf8');
    expect(src).toMatch(/\[challenger\] \$\{sp\} skipped/);
  });
});
