/* ============================================================
   Session 69 — LAYER 2: the multi-axis archetype classifier.

   A player is a BLEND across independent axes, not one label. These lock the
   three things that make that honest: axes that are genuinely independent,
   cut-lines at real percentiles, and NO fallback bucket anywhere.
   ============================================================ */

const axes = require('../../src/services/archetypeAxes');
const { classifyPlayer, BATTER_AXES, PITCHER_AXES } = axes;

// Real rows from statcast_aggregates (2026-07-21).
const SKUBAL = {
  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 }, { type: 'CH', velo: 87.3 }, { type: 'SL', velo: 89.4 }],
};
const JUDGE = {
  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 KWAN = {
  role: 'batter', bats: 'L', sample_pa: 365, k_pct: 9.9, bb_pct: 12.9, chase_pct: 22.1,
  barrel_pct: 0.4, hard_hit_pct: 9.7, avg_launch_angle: 14, sweet_spot_pct: 39.2,
};
const BELL = {
  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 RODEN_THIN = { role: 'batter', bats: 'L', sample_pa: 21, k_pct: 19, chase_pct: 30.4, barrel_pct: 13.3 };

describe('multi-axis blend — several true things at once', () => {
  it('Skubal is a STARTER and a strikeout arm and a ground-ball arm and a control arm', () => {
    const r = classifyPlayer(SKUBAL);
    expect(r.roleLabel).toBe('STARTER');
    const labels = r.blend.map((b) => b.label);
    expect(labels).toContain('WHIFF');    // k% 30.5 ≥ starter p90 28.6
    expect(labels).toContain('SEAM');     // gb% 49 ≥ starter p90 48.9
    expect(labels).toContain('PINPOINT'); // bb% 3.4 ≤ starter elite 5.5
    expect(r.blend.length).toBeGreaterThanOrEqual(3);
  });

  it('surfaces at most 3 traits but stores the FULL vector for Layer 3', () => {
    const r = classifyPlayer(SKUBAL);
    expect(r.blend.length).toBeLessThanOrEqual(3);
    expect(Object.keys(r.vector)).toEqual(Object.keys(PITCHER_AXES));
    expect(Object.values(r.vector).filter(Boolean).length).toBeGreaterThan(r.blend.length);
  });

  it('Judge: elite power AND patient AND strikeout-prone — all three real', () => {
    const labels = classifyPlayer(JUDGE).blend.map((b) => b.label);
    expect(labels).toContain('BOMBER');
    expect(labels).toContain('GRINDER');
    expect(labels).toContain('WHIFF RISK');
  });

  it('Kwan: elite contact AND elite discipline, with NO power claimed', () => {
    const r = classifyPlayer(KWAN);
    const labels = r.blend.map((b) => b.label);
    expect(labels).toContain('SURGEON');
    expect(labels).toContain('SNIPER');
    expect(labels).not.toContain('SLUGGER');
    expect(labels).not.toContain('BOMBER');
    expect(r.vector.power).toBeNull(); // 0.4 barrel% — absent, not "low power"
  });
});

describe('NO FALLBACK — the FLEX disease is structurally impossible', () => {
  it('an unremarkable player surfaces NOTHING and says so', () => {
    const r = classifyPlayer(BELL);
    expect(r.blend).toEqual([]);
    expect(r.sufficient).toBe(true);
    expect(r.note).toMatch(/No standout profile/);
    // He used to classify as DRIVER. Being average is not an archetype.
  });

  it('a thin sample claims NOTHING — every axis absent, not a guess', () => {
    const r = classifyPlayer(RODEN_THIN);
    expect(r.sufficient).toBe(false);
    expect(r.blend).toEqual([]);
    expect(r.absent.length).toBe(Object.keys(BATTER_AXES).length);
    expect(r.note).toMatch(/Not enough plate appearances/);
    // Alan Roden was FLEX at weight 1.0 — a confident label on 21 PA.
  });

  it('classify() no longer emits a fallback archetype for ANY sport', () => {
    const { classify } = require('../../src/services/archetypeService');
    for (const sport of ['mlb', 'nba', 'wnba', 'mma']) {
      const r = classify(sport, {});
      expect(r.primary).toBeNull();
      expect(r.blend).toEqual([]);
    }
  });

  it('never pads the blend to a fixed length', () => {
    expect(classifyPlayer(KWAN).blend.length).toBeLessThanOrEqual(3);
    expect(classifyPlayer(BELL).blend.length).toBe(0);
  });
});

describe('honest-absent PER AXIS', () => {
  it('a velo-less pitcher keeps every other axis', () => {
    const r = classifyPlayer({ ...SKUBAL, pitch_mix: [{ type: 'FF', velo: null }] });
    expect(r.vector.velocity).toBeNull();
    expect(r.absent).toContain('velocity');
    expect(r.blend.length).toBeGreaterThan(0); // other axes unaffected
  });

  it('distinguishes NO DATA from LEAGUE-AVERAGE', () => {
    const noData = classifyPlayer({ role: 'batter', sample_pa: 300 });
    expect(noData.absent).toContain('power');       // metric missing
    const average = classifyPlayer(BELL);
    expect(average.absent).not.toContain('power');  // metric present, just unremarkable
    expect(average.vector.power).toBeNull();
  });
});

describe('axis independence — collapsed pairs cannot both fire', () => {
  it.each([
    ['contact', 'swing_miss'],
    ['patience', 'aggression'],
  ])('%s and %s are one underlying axis', (a, b) => {
    for (const row of [JUDGE, KWAN, BELL]) {
      const v = classifyPlayer(row).vector;
      expect(v[a] && v[b]).toBeFalsy();
    }
  });

  it('pitcher ground-ball and fly-ball never both fire (r = -0.73)', () => {
    const v = classifyPlayer(SKUBAL).vector;
    expect(v.ground_ball && v.fly_ball).toBeFalsy();
  });

  it('velocity is its OWN axis — measured independent of strikeout (r = 0.14)', () => {
    // A hard thrower who misses no bats still earns CANNON; a soft-tossing
    // strikeout artist still earns WHIFF. Neither implies the other.
    const hardNoK = classifyPlayer({
      role: 'pitcher', role_detail: 'starter', sample_ip: 80, k_pct: 18,
      pitch_mix: [{ type: 'FF', velo: 98.5 }],
    });
    expect(hardNoK.blend.map((b) => b.label)).toContain('HOWITZER');
    expect(hardNoK.vector.strikeout).toBeNull();
  });
});

describe('cut-lines are the REAL measured percentiles, per role where tails differ', () => {
  it('reliever GB% needs a higher bar than a starter (p90 54.1 vs 48.9)', () => {
    const base = { role: 'pitcher', sample_ip: 40, gb_pct: 50 };
    const sp = classifyPlayer({ ...base, role_detail: 'starter' });
    const rp = classifyPlayer({ ...base, role_detail: 'reliever' });
    expect(sp.vector.ground_ball.tier).toBe('elite'); // 50 ≥ SP p90 48.9
    expect(rp.vector.ground_ball.tier).toBe('hi');    // 50 < RP p90 54.1
  });

  it('p75 is distinctive, p90 is elite', () => {
    const at75 = classifyPlayer({ role: 'batter', sample_pa: 300, barrel_pct: 10.6 });
    const at90 = classifyPlayer({ role: 'batter', sample_pa: 300, barrel_pct: 13.3 });
    expect(at75.vector.power.tier).toBe('hi');
    expect(at90.vector.power.tier).toBe('elite');
    const below = classifyPlayer({ role: 'batter', sample_pa: 300, barrel_pct: 10.5 });
    expect(below.vector.power).toBeNull();
  });

  it('sample floors gate every axis (PA>=50, IP>=10)', () => {
    expect(classifyPlayer({ role: 'batter', sample_pa: 49, barrel_pct: 25 }).blend).toEqual([]);
    expect(classifyPlayer({ role: 'pitcher', sample_ip: 9, k_pct: 40 }).blend).toEqual([]);
    expect(classifyPlayer({ role: 'batter', sample_pa: 50, barrel_pct: 25 }).blend.length).toBe(1);
  });
});
