'use strict';

/**
 * CHALLENGER PROJECTION (Layer 3, Step 2) — archetype-aware, measured not claimed.
 *
 * The CHAMPION (`probabilityEstimator` → `p_win`) keeps serving and grading
 * users, completely unchanged. This computes a SECOND probability from the same
 * inputs at the same instant, retained beside the champion and joined to the
 * same outcome and the same close, so the ledger can decide which is better.
 *
 * NOTHING HERE IS CLAIMED. Running a challenger is honest beta; asserting it is
 * better before the settled ledger says so is not. Promotion is a separate,
 * later decision gated on Brier + calibration over sufficient segmented volume.
 *
 * ── WHY INTERPRETABLE, NOT A RE-ESTIMATION ───────────────────────────────
 * The challenger is the champion's probability ADJUSTED by the Layer-2 axes,
 * never a black-box re-derivation. That buys three things:
 *   1. Every difference is attributable to a named axis and a signed nudge —
 *      we can see exactly what the archetype changed and where.
 *   2. Where the archetype is absent or unremarkable the challenger is
 *      BYTE-IDENTICAL to the champion, so the A/B differs only where
 *      archetype-awareness could possibly help or hurt. That is the clean
 *      experiment: no dilution from rows the treatment never touched.
 *   3. A bad adjustment is removable without touching the base projection.
 *
 * ── ISOLATION ────────────────────────────────────────────────────────────
 * The champion is read, never written. `adjust()` is pure: same inputs → same
 * output, no shared state, no feedback. A contaminated A/B measures nothing.
 *
 * ── THE ADJUSTMENT ───────────────────────────────────────────────────────
 * Applied in LOG-ODDS space, so a nudge cannot push a probability past 0 or 1
 * and the same nudge means the same thing at p=0.5 and p=0.9 (an additive
 * probability bump does neither). Magnitudes are deliberately SMALL: this is a
 * lean on a real signal, not a re-forecast.
 */

const CHALLENGER_VERSION = 'arch-v1';

/** Log-odds nudges. `elite` = the Layer-2 p90 tier, `hi` = p75. Capped, and the
 *  total is clamped, so no stack of axes can run away with the projection. */
const NUDGE = Object.freeze({ elite: 0.22, hi: 0.11 });
const MAX_TOTAL_NUDGE = 0.45; // ≈ 10 pts at p=0.5 — a lean, never a re-forecast

/**
 * Which axis speaks to which stat, and in which direction.
 * `+1` = the trait makes the stat MORE likely, `-1` = less likely.
 * Only relationships that are mechanically obvious are encoded — a speculative
 * mapping would be the same guessing this whole layer exists to replace.
 */
const BATTER_MAP = Object.freeze({
  home_runs:    { power: +1, launch: +1, swing_miss: -1, contact: 0 },
  total_bases:  { power: +1, launch: +1, swing_miss: -1 },
  hits:         { contact: +1, line_drive: +1, swing_miss: -1, power: 0 },
  rbi:          { power: +1 },
  runs:         { patience: +1 },
  doubles:      { line_drive: +1, power: +1 },
  strikeouts:   { swing_miss: +1, contact: -1, aggression: +1, patience: -1 },
  walks:        { patience: +1, aggression: -1 },
  stolen_bases: {},
});

/**
 * TIER 1 — TAUTOLOGICAL. The archetype IS the stat tendency, so these are safe
 * in DIRECTION without an empirical test and run live from day one. They are
 * still MEASURED like everything else; only a quantified accuracy claim waits.
 * Keys below are the CANONICAL axis keys the classifier emits — a key that does
 * not exist would be a silent no-op, so `challengerMappings.test.js` asserts
 * every one against BATTER_AXES / PITCHER_AXES.
 */
const PITCHER_MAP = Object.freeze({
  strikeouts:       { strikeout: +1, chase: +1, velocity: +1, contact_allowed: -1 },
  pitcher_strikeouts: { strikeout: +1, chase: +1, velocity: +1, contact_allowed: -1 },
  hits_allowed:     { strikeout: -1, contact_allowed: +1, ground_ball: -1 },
  // Tier 1: a ground-ball arm allows fewer home runs; a fly-ball arm more.
  // The most tautological pair in the set — a ball on the ground cannot leave
  // the park.
  home_runs_allowed: { ground_ball: -1, fly_ball: +1, contact_allowed: +1 },
  home_runs:         { ground_ball: -1, fly_ball: +1, contact_allowed: +1 },
  earned_runs:      { contact_allowed: +1, wild: +1, strikeout: -1 },
  outs_recorded:    { control: +1, ground_ball: +1, wild: -1 },
  innings_pitched:  { control: +1, ground_ball: +1, wild: -1 },
  walks_allowed:    { wild: +1, control: -1 },
});

const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const toLogOdds = (p) => Math.log(p / (1 - p));
const fromLogOdds = (l) => 1 / (1 + Math.exp(-l));

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

/**
 * adjust({ pWin, direction, statType, classification }) — PURE.
 *
 * Returns { p_win_challenger, delta, adjustments[], reason }.
 * When there is nothing to say, `p_win_challenger === pWin` EXACTLY and
 * `adjustments` is empty — the challenger is the champion on those rows, by
 * construction, and the comparison stays clean.
 */
/**
 * Compose an ENVIRONMENT multiplier into the same log-odds space the archetype
 * nudges use. A multiplier of 1.0 is a no-op; >1 leans toward the over.
 *
 * Park is the BASE environment; weather will multiply onto it next order
 * (`env = park_base × weather_mod × …`) with no change here — that is why the
 * park layer emits a coefficient rather than a nudge.
 *
 * `Math.log(env)` converts a multiplicative environment into an additive
 * log-odds term, which is the correct composition: two independent 5% effects
 * become 1.05 × 1.05, not +5% +5%.
 */
const ENV_SCALE = Number(process.env.ENV_NUDGE_SCALE) || 1.0;
const MAX_ENV_NUDGE = 0.30;
function envNudge(env, dirSign) {
  const e = num(env);
  if (e == null || e <= 0 || e === 1) return 0;
  const raw = Math.log(e) * ENV_SCALE * dirSign;
  return clamp(raw, -MAX_ENV_NUDGE, MAX_ENV_NUDGE);
}

function adjust({ pWin, direction, statType, classification, environment, matchup } = {}) {
  const p = num(pWin);
  const identical = (reason) => ({
    p_win_challenger: p, delta: 0, adjustments: [], reason, version: CHALLENGER_VERSION,
  });

  if (p == null || p <= 0 || p >= 1) return identical('no_champion_probability');
  const envPresent = num(environment && environment.multiplier) != null
    && num(environment.multiplier) !== 1;
  // MATCHUP (platoon) is deliberately its OWN signal, not folded into the
  // environment coefficient: park and weather describe the stadium, platoon
  // describes this hitter against this pitcher's hand. Keeping them separate is
  // what lets the instrument attribute them independently.
  const matchupPresent = num(matchup && matchup.multiplier) != null
    && num(matchup.multiplier) !== 1;
  if ((!classification || !classification.sufficient) && !envPresent && !matchupPresent) {
    return identical('archetype_absent_or_thin');
  }

  const vector = (classification && classification.vector) || {};
  const stat = String(statType || '').toLowerCase();
  const role = classification && classification.role === 'pitcher' ? 'pitcher' : 'batter';
  const map = ((classification && classification.sufficient)
    ? (role === 'pitcher' ? PITCHER_MAP : BATTER_MAP)[stat]
    : null) || {};
  if (!Object.keys(map).length && !envPresent && !matchupPresent) return identical('stat_not_mapped');

  // Direction: a trait that raises the stat raises P(over) and lowers P(under).
  const dirSign = String(direction || 'over').toLowerCase() === 'under' ? -1 : 1;

  const adjustments = [];
  let total = 0;

  // ── MATCHUP (platoon) — independent of the environment ─────────────────
  const mMult = num(matchup && matchup.multiplier);
  if (mMult != null && mMult !== 1) {
    const n = envNudge(mMult, dirSign);
    if (n) {
      total += n;
      adjustments.push({
        axis: 'matchup', label: matchup.label || 'PLATOON',
        tier: 'matchup', nudge: Math.round(n * 1000) / 1000,
        multiplier: Math.round(mMult * 1000) / 1000,
        batter_hand: matchup.batter_hand ?? null,
        pitcher_hand: matchup.pitcher_hand ?? null,
        observed_pa: matchup.observed_pa ?? null,
        observed_weight: matchup.observed_weight ?? null,
      });
    }
  }

  // ── ENVIRONMENT (park now; weather composes onto it next order) ────────
  // Applied even when no archetype axis fires: a park effect is real whether or
  // not the player is distinctive. `environment` is the COMPOSED multiplier.
  const envMult = num(environment && environment.multiplier);
  if (envMult != null && envMult !== 1) {
    const n = envNudge(envMult, dirSign);
    if (n) {
      total += n;
      adjustments.push({
        axis: 'environment', label: (environment.label || 'PARK'),
        tier: 'env', nudge: Math.round(n * 1000) / 1000,
        multiplier: Math.round(envMult * 1000) / 1000,
        venue: environment.venue || null,
        weather_na: environment.weather_na ?? null,
      });
    }
  }
  for (const [axisKey, sign] of Object.entries(map)) {
    if (!sign) continue;
    const hit = vector[axisKey];
    // null = measured and unremarkable, or no data. Either way: no signal, no
    // nudge. Only a DISTINCTIVE trait (>= p75) moves anything.
    if (!hit) continue;
    const mag = NUDGE[hit.tier] || 0;
    if (!mag) continue;
    const signed = mag * sign * dirSign;
    total += signed;
    adjustments.push({ axis: axisKey, label: hit.label, tier: hit.tier, nudge: Math.round(signed * 1000) / 1000 });
  }

  if (!adjustments.length) return identical('no_distinctive_axis_or_environment');

  const capped = clamp(total, -MAX_TOTAL_NUDGE, MAX_TOTAL_NUDGE);
  const challenger = clamp(fromLogOdds(toLogOdds(p) + capped), 0.01, 0.99);
  const rounded = Math.round(challenger * 1000) / 1000;

  return {
    p_win_challenger: rounded,
    delta: Math.round((rounded - p) * 1000) / 1000,
    adjustments,
    capped: capped !== total,
    reason: null,
    version: CHALLENGER_VERSION,
  };
}

/**
 * attachChallenger(grades, classifyFor) — map a slate's grades to the same
 * grades plus challenger fields. `classifyFor(playerName, statType)` returns a
 * Layer-2 classification or null; injected so this never does its own I/O and
 * tests stay hermetic.
 *
 * The champion field (`p_win`) is NEVER written here. Read-only by design.
 */
async function attachChallenger(grades, classifyFor, contextFor) {
  const out = [];
  for (const g of grades || []) {
    if (!g) { out.push(g); continue; }
    const cls = typeof classifyFor === 'function'
      ? classifyFor(g.player || g.player_name, g.stat_type || g.stat)
      : null;
    // Session 77 — environment (park × weather) and matchup (platoon) come from
    // the wiring, per grade. Both optional; a resolver failure degrades that
    // grade to archetype-only rather than breaking the map.
    let ctx = { environment: null, matchup: null };
    if (typeof contextFor === 'function') {
      try { ctx = (await contextFor(g)) || ctx; } catch { /* honest-absent */ }
    }
    const res = adjust({
      pWin: g.p_win,
      direction: g.direction,
      statType: g.stat_type || g.stat,
      classification: cls,
      environment: ctx.environment,
      matchup: ctx.matchup,
    });
    out.push({
      ...g,
      p_win_challenger: res.p_win_challenger,
      challenger_delta: res.delta,
      challenger_adjustments: res.adjustments.length ? res.adjustments : null,
      challenger_version: CHALLENGER_VERSION,
      challenger_reason: res.reason,
      // Independent, attributable retention (Session 75 ledger columns). The
      // per-axis breakdown also lives in challenger_adjustments, but these
      // top-level fields keep environment measurable on its own.
      env_multiplier: ctx.environment ? ctx.environment.multiplier : null,
      env_park_base: ctx.environment ? ctx.environment.park_base : null,
      env_weather_mod: ctx.environment ? ctx.environment.weather_mod : null,
      env_weather_state: ctx.environment ? ctx.environment.weather_state : null,
    });
  }
  return out;
}

module.exports = {
  adjust,
  attachChallenger,
  CHALLENGER_VERSION,
  NUDGE,
  MAX_TOTAL_NUDGE,
  BATTER_MAP,
  PITCHER_MAP,
  __internals: { toLogOdds, fromLogOdds, clamp, num },
};
