/* ============================================================
   Session 72 — TIER-1 MAPPINGS LIVE + the TIER-2 nomination harness.
   ============================================================ */

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

// ── 1. NO SILENT NO-OPS ──────────────────────────────────────────────────
describe('every mapping key is a CANONICAL emitted axis', () => {
  it('batter mappings reference only real batter axes', () => {
    const bad = [];
    for (const [stat, m] of Object.entries(ch.BATTER_MAP)) {
      for (const k of Object.keys(m)) if (!(k in axes.BATTER_AXES)) bad.push(`${stat}.${k}`);
    }
    // A key that does not exist would look active and never fire.
    expect(bad).toEqual([]);
  });

  it('pitcher mappings reference only real pitcher axes', () => {
    const bad = [];
    for (const [stat, m] of Object.entries(ch.PITCHER_MAP)) {
      for (const k of Object.keys(m)) if (!(k in axes.PITCHER_AXES)) bad.push(`${stat}.${k}`);
    }
    expect(bad).toEqual([]);
  });
});

// ── 2. TIER 1 FIRES, WITH THE RIGHT SIGN ─────────────────────────────────
const PITCHER = (o) => axes.classifyPlayer({
  role: 'pitcher', role_detail: 'starter', sample_ip: 80,
  k_pct: 22, bb_pct: 8, chase_pct: 30, gb_pct: 42, fb_pct: 26, barrel_pct: 8, ...o,
});
const BATTER = (o) => axes.classifyPlayer({
  role: 'batter', sample_pa: 300,
  k_pct: 22, bb_pct: 8, chase_pct: 30, barrel_pct: 7, avg_launch_angle: 14, sweet_spot_pct: 33, ...o,
});
const move = (cls, stat, dir = 'over', p = 0.5) =>
  ch.adjust({ pWin: p, direction: dir, statType: stat, classification: cls }).delta;

describe('TIER 1 — tautological mappings, live and directional', () => {
  it('WHIFF / PUNCHOUT arm → strikeouts UP', () => {
    expect(move(PITCHER({ k_pct: 31 }), 'strikeouts')).toBeGreaterThan(0);
  });

  it('SINKER / SEAM arm → home runs allowed DOWN', () => {
    expect(move(PITCHER({ gb_pct: 55 }), 'home_runs_allowed')).toBeLessThan(0);
  });

  it('FLY BALL / ELEVATOR arm → home runs allowed UP', () => {
    expect(move(PITCHER({ fb_pct: 36 }), 'home_runs_allowed')).toBeGreaterThan(0);
  });

  it('SURGEON ARM / PINPOINT → walks allowed DOWN', () => {
    expect(move(PITCHER({ bb_pct: 5 }), 'walks_allowed')).toBeLessThan(0);
  });

  it('SLUGGER / BOMBER → total bases UP and home runs UP', () => {
    const slugger = BATTER({ barrel_pct: 14 });
    expect(move(slugger, 'total_bases')).toBeGreaterThan(0);
    expect(move(slugger, 'home_runs')).toBeGreaterThan(0);
  });

  it('TECHNICIAN / SURGEON → hits UP and strikeouts DOWN', () => {
    const contact = BATTER({ k_pct: 12 });
    expect(move(contact, 'hits')).toBeGreaterThan(0);
    expect(move(contact, 'strikeouts')).toBeLessThan(0);
  });

  it('GRINDER / SNIPER → walks UP', () => {
    expect(move(BATTER({ chase_pct: 21 }), 'walks')).toBeGreaterThan(0);
  });

  it('an average player is untouched on every Tier-1 stat', () => {
    for (const s of ['strikeouts', 'home_runs_allowed', 'walks_allowed']) {
      expect(move(PITCHER({}), s)).toBe(0);
    }
    for (const s of ['total_bases', 'home_runs', 'hits', 'walks']) {
      expect(move(BATTER({}), s)).toBe(0);
    }
  });

  it('every Tier-1 mapping mirrors exactly on the UNDER side', () => {
    const p = PITCHER({ k_pct: 31 });
    expect(move(p, 'strikeouts', 'under', 0.5)).toBeCloseTo(-move(p, 'strikeouts', 'over', 0.5), 3);
  });
});

describe('SPEED mappings are honestly absent, not faked', () => {
  it('stolen_bases has no axis to key on — Layer 2 shelved speed', () => {
    // SB is a statsapi field that never reached the aggregate store, so a
    // BURNER axis does not exist. The mapping is empty rather than invented.
    expect(ch.BATTER_MAP.stolen_bases).toEqual({});
    expect(move(BATTER({}), 'stolen_bases')).toBe(0);
  });
});

// ── 3. THE TIER-2 HARNESS ────────────────────────────────────────────────
/** Rows where the archetype carries REAL residual signal the projection missed. */
function rows({ n = 300, effect = 0.10, withRate = 0.4, stat = 'hits_allowed', flipInTest = false }) {
  const out = [];
  for (let i = 0; i < n; i++) {
    const day = 1 + Math.floor(i / 10); // ~30 dates
    const isTest = day > 18;
    const has = i % Math.round(1 / withRate) === 0;
    const eff = has ? (flipInTest && isTest ? -effect : effect) : 0;
    // p_win fixed at .5; hit rate shifted by the effect for archetype-carriers.
    // The draw uses a modulus COPRIME with the carrier selector — otherwise the
    // two alias and the fixture manufactures an effect the generator never put
    // there (which is exactly what happened first time, and the harness caught).
    const y = ((i * 37) % 101) / 101 < 0.5 + eff ? 'hit' : 'miss';
    out.push({
      stat, p_win: 0.5, outcome: y,
      game_date: `2026-06-${String(day).padStart(2, '0')}`,
      archetype_vector: has ? { vector: { ground_ball: { tier: 'hi', label: 'SINKER' } } } : { vector: {} },
    });
  }
  return out;
}
const CAND = { axis: 'ground_ball', stat: 'hits_allowed', sign: -1, role: 'pitcher' };

describe('Tier-2 harness — marginal, out-of-sample, direction-validated', () => {
  it('INSUFFICIENT below the row floor — never a nomination on thin data', () => {
    const r = harness.evaluateMapping(rows({ n: 50 }), CAND);
    expect(r.verdict).toBe('INSUFFICIENT');
    expect(r.reason).toMatch(/need 200 settled rows/);
  });

  it('REJECTS a redundant mapping — the projection already knows it', () => {
    // Zero residual difference between carriers and non-carriers.
    const r = harness.evaluateMapping(rows({ effect: 0 }), CAND);
    expect(r.verdict).toBe('REJECT');
    expect(r.reason).toMatch(/redundant/);
  });

  it('REJECTS an overfit mapping — effect in fit, gone/flipped out-of-sample', () => {
    const r = harness.evaluateMapping(rows({ effect: 0.12, flipInTest: true }), { ...CAND, sign: +1 });
    expect(r.verdict).toBe('REJECT');
  });

  it('NEVER silently flips a contradicted sign', () => {
    const r = harness.evaluateMapping(rows({ effect: 0.10 }), { ...CAND, sign: -1 });
    if (r.verdict === 'REJECT') expect(r.reason).toMatch(/contradicts the candidate sign/);
    expect(r.verdict).not.toBe('NOMINATE'); // the data says +, the candidate says −
  });

  it('NOMINATES a mapping with real out-of-sample residual signal', () => {
    const r = harness.evaluateMapping(rows({ effect: 0.10 }), { ...CAND, sign: +1 });
    expect(r.verdict).toBe('NOMINATE');
    expect(Math.abs(r.test_effect)).toBeGreaterThanOrEqual(harness.MIN_EFFECT);
    expect(r.direction_holds).toBe(true);
  });

  it('splits by DATE, never randomly — same-game rows would leak', () => {
    const s = harness.splitByDate(rows({ n: 300 }));
    const fitDates = new Set(s.fit.map((r) => r.game_date));
    const testDates = new Set(s.test.map((r) => r.game_date));
    for (const d of testDates) expect(fitDates.has(d)).toBe(false);
  });

  it('reads the archetype RETAINED at grade time, never a re-classification', () => {
    const src = require('fs').readFileSync(require.resolve('../../src/services/mappingHarness'), 'utf8');
    expect(src).toMatch(/archetype_vector/);
    expect(src).toMatch(/look-ahead leakage/);
  });
});

describe('lifecycle — nothing sits dark', () => {
  it('nominated → live → claimed, and only the CLAIM is withheld', () => {
    expect(harness.LIFECYCLE).toEqual(['nominated', 'live', 'claimed']);
  });
});
