'use strict';

/**
 * MULTI-AXIS ARCHETYPE CLASSIFIER (Layer 2, Session 69).
 *
 * A player is not one label. He is a BLEND across INDEPENDENT axes — Skubal is
 * a STARTER *and* a PUNCHOUT arm *and* a CHASE-BAIT arm, three true things at
 * once. Single-label classification is lossy, and its failure mode is the FLEX
 * disease: when nothing matches, invent a bucket.
 *
 * THERE IS NO FALLBACK HERE. Unclassified on an axis = absent on that axis. A
 * genuinely unremarkable player surfaces one axis, or none, and "no standout
 * profile" is the honest output. `classify()`'s FLEX-at-weight-1.0 is replaced.
 *
 * ── AXIS INDEPENDENCE, MEASURED (not assumed) ────────────────────────────
 * Correlations over the real store (467 batters PA>=50, 531 pitchers IP>=10).
 * Anything |r| >= 0.70 is ONE underlying trait and was COLLAPSED, so we never
 * show one trait as two archetypes (fake granularity):
 *
 *   COLLAPSED — batter
 *     k% ~ whiff%              r = +0.89  -> one SWING-AND-MISS axis
 *     hard-hit% ~ avg exit velo r = +0.88  -> folded into POWER as intensity
 *     chase% ~ swing%          r = +0.87  -> one AGGRESSION axis
 *     chase% ~ bb%             r = -0.72  -> same axis, inverted (discipline)
 *     barrel% ~ hard-hit%      r = +0.70  -> at the line; barrel leads, hard-hit
 *                                            is an intensity input, not an axis
 *   COLLAPSED — pitcher
 *     k% ~ whiff%              r = +0.76  -> one STRIKEOUT axis
 *     gb% ~ fb%                r = -0.73  -> one signed BATTED-BALL TILT axis
 *
 *   INDEPENDENT — kept as real, separate axes
 *     pitcher k% ~ gb%         r = -0.10  PUNCHOUT and SINKER are orthogonal
 *     pitcher velo ~ k%        r = +0.14  velocity is its OWN trait, not a
 *     pitcher velo ~ whiff%    r = +0.07  proxy for missing bats
 *     pitcher velo ~ gb%       r = +0.07
 *     pitcher arm angle ~ gb%  r = -0.17
 *     batter barrel ~ launch   r = +0.23  power and launch are separate
 *     batter k% ~ chase%       r = +0.05  whiffing and chasing are separate
 *
 * ── CUT-LINES ARE REAL PERCENTILES ───────────────────────────────────────
 * p75 = distinctive, p90 = elite, measured on this season's store — PER ROLE
 * where the tails differ (reliever GB% p90 is 54.1 vs a starter's 48.9, even
 * though the medians are identical at 42.5). No guessed numbers.
 */

/** Sample floors. Below these an axis is ABSENT — the structural prevention of
 *  classifying a 12-PA cameo as confidently as a 400-PA season. */
const MIN_PA = 50;
const MIN_IP = 10;

/** Tier thresholds, from the measured distribution. `hi` = p75 (distinctive),
 *  `elite` = p90. `dir: -1` means LOW values are the distinctive end. */
const B = (hi, elite, dir = 1) => ({ hi, elite, dir });

const BATTER_AXES = Object.freeze({
  power: {
    name: 'POWER', metric: 'barrel_pct', band: B(10.6, 13.3),
    // hard-hit% and exit velo ride along as intensity (r=0.70/0.69) rather than
    // as their own axes — one trait, one archetype.
    tiers: { hi: 'SLUGGER', elite: 'BOMBER' },
  },
  swing_miss: {
    name: 'SWING & MISS', metric: 'k_pct', band: B(27.4, 31.9),
    tiers: { hi: 'WHIFF RISK', elite: 'WINDMILL' },
  },
  contact: {
    name: 'CONTACT', metric: 'k_pct', band: B(17.7, 14.0, -1),
    tiers: { hi: 'TECHNICIAN', elite: 'SURGEON' },
  },
  patience: {
    name: 'PLATE DISCIPLINE', metric: 'chase_pct', band: B(26.2, 22.7, -1),
    tiers: { hi: 'GRINDER', elite: 'SNIPER' },
  },
  aggression: {
    name: 'AGGRESSION', metric: 'chase_pct', band: B(34.8, 39.7),
    tiers: { hi: 'FREE SWINGER', elite: 'HACKER' },
  },
  launch: {
    name: 'LAUNCH', metric: 'avg_launch_angle', band: B(17.2, 20.1),
    tiers: { hi: 'TOPSPIN', elite: 'LOFT' },
  },
  line_drive: {
    name: 'LINE DRIVE', metric: 'sweet_spot_pct', band: B(36.6, 40.0),
    tiers: { hi: 'SLASH', elite: 'BARREL FINDER' },
  },
});

/** Pitcher bands differ by role where the TAILS differ, even where the medians
 *  agree. Measured: SP n=123, RP n=408. */
const PITCHER_AXES = Object.freeze({
  strikeout: {
    name: 'STRIKEOUT', metric: 'k_pct',
    byRole: { starter: B(25.4, 28.6), default: B(26.3, 29.5) },
    tiers: { hi: 'PUNCHOUT', elite: 'WHIFF' },
  },
  ground_ball: {
    name: 'GROUND BALL', metric: 'gb_pct',
    byRole: { starter: B(45.7, 48.9), default: B(48.0, 54.1) },
    tiers: { hi: 'SINKER', elite: 'SEAM' },
  },
  fly_ball: {
    name: 'FLY BALL', metric: 'fb_pct',
    byRole: { starter: B(28.8, 31.1), default: B(31.4, 35.6) },
    tiers: { hi: 'FLY BALL', elite: 'ELEVATOR' },
  },
  control: {
    name: 'CONTROL', metric: 'bb_pct',
    byRole: { starter: B(6.5, 5.5, -1), default: B(7.5, 6.0, -1) },
    tiers: { hi: 'SURGEON ARM', elite: 'PINPOINT' },
  },
  wild: {
    name: 'WILDNESS', metric: 'bb_pct',
    byRole: { starter: B(9.0, 10.7), default: B(12.1, 14.9) },
    tiers: { hi: 'NIBBLER', elite: 'SCATTERGUN' },
  },
  chase: {
    name: 'CHASE BAIT', metric: 'chase_pct',
    byRole: { starter: B(33.1, 34.6), default: B(32.5, 35.2) },
    tiers: { hi: 'BAIT', elite: 'TRAPDOOR' },
  },
  contact_allowed: {
    name: 'HARD CONTACT ALLOWED', metric: 'barrel_pct',
    byRole: { starter: B(9.7, 10.8), default: B(9.5, 12.0) },
    tiers: { hi: 'HITTABLE', elite: 'BATTING PRACTICE' },
  },
  velocity: {
    // Measured independent of k% (0.14), whiff% (0.07) and gb% (0.07) — this is
    // genuinely its own trait, which is why it earns an axis rather than being
    // folded into STRIKEOUT. Coverage recovered 53% -> 99% by the Session-69
    // velo fix, so it is buildable rather than shelved.
    name: 'VELOCITY', metric: '_velo', byRole: { default: B(96.2, 97.7) },
    tiers: { hi: 'CANNON', elite: 'HOWITZER' },
  },
  slot: {
    name: 'ARM SLOT', metric: 'arm_angle', byRole: { default: B(29.5, 20.1, -1) },
    tiers: { hi: 'SIDEARM', elite: 'SUBMARINE' },
  },
});

/** Role axis — from TRUE usage (statsapi gamesStarted/saves/holds), never the
 *  IP proxy. Always surfaced when known: it is context, not a distinction. */
const ROLE_LABEL = Object.freeze({
  starter: 'STARTER', closer: 'CLOSER', setup: 'SETUP', reliever: 'RELIEVER',
});

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

function bandFor(axis, roleDetail) {
  if (axis.band) return axis.band;
  const by = axis.byRole || {};
  return by[roleDetail] || by.default || null;
}

/**
 * scoreAxis — returns { tier, label, value, strength } or NULL.
 * NULL means one of three honest things, never "average archetype":
 *   the metric is absent · the sample is too thin · the player is unremarkable
 *   on this axis (between the tails).
 */
function scoreAxis(axis, row, roleDetail) {
  const v = axis.metric === '_velo' ? primaryVelo(row) : num(row[axis.metric]);
  if (v == null) return null;
  const band = bandFor(axis, roleDetail);
  if (!band) return null;
  const { hi, elite, dir } = band;
  const passHi = dir > 0 ? v >= hi : v <= hi;
  if (!passHi) return null; // league-average on this axis → ABSENT, not "mid"
  const passElite = dir > 0 ? v >= elite : v <= elite;
  // strength: 0.5 at the p75 line, 1.0 at p90 and beyond. Used to rank which
  // traits are the most distinctive, never rendered as a probability.
  const span = Math.abs(elite - hi) || 1;
  const over = Math.abs(v - hi) / span;
  return {
    axis: axis.name,
    tier: passElite ? 'elite' : 'hi',
    label: passElite ? axis.tiers.elite : axis.tiers.hi,
    value: v,
    strength: Math.min(1, 0.5 + over * 0.5),
  };
}

function primaryVelo(row) {
  const mix = row.pitch_mix;
  if (!Array.isArray(mix) || !mix.length) return null;
  const withVelo = mix.filter((p) => num(p.velo) != null);
  if (!withVelo.length) return null;
  // The fastest offering — a CANNON is defined by his best velo, not his mix.
  return Math.max(...withVelo.map((p) => num(p.velo)));
}

/**
 * classifyPlayer(row) — the multi-axis result.
 *
 * {
 *   role: 'batter'|'pitcher', roleDetail, sufficient,
 *   vector: { axisKey: {tier,label,value,strength} | null },  ← Layer 3 reads ALL
 *   blend:  [ {label, axis, tier, strength} ],                ← top 2-3 to SURFACE
 *   absent: [axisKey…],        ← axes with no data (distinct from unremarkable)
 *   note                       ← honest copy when the blend is empty
 * }
 */
function classifyPlayer(row = {}) {
  const isPitcher = row.role === 'pitcher';
  const axes = isPitcher ? PITCHER_AXES : BATTER_AXES;
  const roleDetail = row.role_detail || null;

  const sample = isPitcher ? num(row.sample_ip) : num(row.sample_pa);
  const floor = isPitcher ? MIN_IP : MIN_PA;
  const sufficient = sample != null && sample >= floor;

  const vector = {};
  const absent = [];
  for (const [key, axis] of Object.entries(axes)) {
    if (!sufficient) { vector[key] = null; absent.push(key); continue; }
    const metricPresent = axis.metric === '_velo'
      ? primaryVelo(row) != null
      : num(row[axis.metric]) != null;
    if (!metricPresent) { vector[key] = null; absent.push(key); continue; }
    vector[key] = scoreAxis(axis, row, roleDetail);
  }

  // Mutually-exclusive pairs live on ONE underlying axis; only one end can fire,
  // and if both somehow did we keep the stronger rather than showing a player as
  // both patient and aggressive.
  for (const [a, b] of [['contact', 'swing_miss'], ['patience', 'aggression'], ['control', 'wild'], ['ground_ball', 'fly_ball']]) {
    if (vector[a] && vector[b]) {
      if (vector[a].strength >= vector[b].strength) vector[b] = null; else vector[a] = null;
    }
  }

  const scored = Object.entries(vector)
    .filter(([, v]) => v)
    .map(([key, v]) => ({ key, ...v }))
    .sort((x, y) => (y.tier === 'elite') - (x.tier === 'elite') || y.strength - x.strength);

  // Surface the top 3 genuinely distinctive traits. NO filler: a player above
  // no p75 line surfaces nothing.
  const blend = scored.slice(0, 3).map((s) => ({
    label: s.label, axis: s.axis, key: s.key, tier: s.tier, value: s.value,
    strength: Math.round(s.strength * 100) / 100,
  }));

  let note = null;
  if (!sufficient) {
    note = `Not enough ${isPitcher ? 'innings' : 'plate appearances'} yet — no profile claimed.`;
  } else if (!blend.length) {
    note = 'No standout profile — league-average across every measured axis.';
  }

  return {
    role: isPitcher ? 'pitcher' : 'batter',
    roleDetail,
    roleLabel: roleDetail ? ROLE_LABEL[roleDetail] || null : null,
    sufficient,
    sample,
    bats: row.bats || null,
    throws: row.throws || null,
    vector,
    blend,
    absent,
    note,
  };
}

module.exports = {
  classifyPlayer,
  scoreAxis,
  primaryVelo,
  BATTER_AXES,
  PITCHER_AXES,
  ROLE_LABEL,
  MIN_PA,
  MIN_IP,
  __internals: { bandFor, num },
};
