'use strict';

/**
 * TIER-2 MAPPING NOMINATION HARNESS (Layer 3, Step 3).
 *
 * Decides whether a candidate archetype→stat mapping earns the right to run
 * live. Three hurdles, all of which a plausible-but-worthless mapping fails:
 *
 *   1. MARGINAL CONTRIBUTION, not raw correlation. The question is never "does
 *      this archetype correlate with this stat" — a ground-ball arm obviously
 *      correlates with fewer home runs. The question is whether it adds signal
 *      BEYOND what the projection already captures. We score the projection's
 *      RESIDUAL (outcome − p_win) against the archetype: if the projection
 *      already knows it, the residual carries no signal and the mapping is
 *      REDUNDANT → rejected. This is the hurdle that catches double-counting.
 *
 *   2. OUT-OF-SAMPLE. Fit the effect on one period, measure it on a held-out
 *      period the fit never touched. A mapping that only works in-sample is
 *      overfit → rejected. The split is by DATE, never random: rows from one
 *      game leak into each other, so a random split would leak.
 *
 *   3. DIRECTION VALIDATED FROM DATA. A correct pairing with a flipped sign is
 *      worse than no mapping at all. If the held-out effect contradicts the
 *      candidate's assumed sign, it is rejected — never silently flipped to
 *      whatever the data says, because that is fitting noise.
 *
 * Under all three: MIN_ROWS. Below it the verdict is INSUFFICIENT, never a
 * nomination — the same honest-absent rule the rest of the system runs on.
 */

const MIN_ROWS = Number(process.env.MAPPING_MIN_ROWS) || 200;
const MIN_HELD_OUT = Number(process.env.MAPPING_MIN_HELD_OUT) || 60;
/** The held-out residual effect must clear this to be a real lean, not drift. */
const MIN_EFFECT = 0.02;

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

/**
 * Split by DATE, never randomly. Rows from the same game share a pitcher, a
 * park and a lineup; a random split puts correlated rows on both sides and
 * leaks. `fitFrac` of the distinct dates (earliest first) fit; the rest test.
 */
function splitByDate(rows, fitFrac = 0.6) {
  const dates = [...new Set(rows.map((r) => String(r.game_date)))].sort();
  if (dates.length < 2) return { fit: [], test: [], dates, split: null };
  const cut = Math.max(1, Math.floor(dates.length * fitFrac));
  const fitDates = new Set(dates.slice(0, cut));
  return {
    fit: rows.filter((r) => fitDates.has(String(r.game_date))),
    test: rows.filter((r) => !fitDates.has(String(r.game_date))),
    dates,
    split: dates[cut - 1],
  };
}

/**
 * meanResidual(rows) — average of (outcome − p_win).
 * Positive = the projection UNDER-estimated these props; negative = over. This
 * is the projection's own error, which is exactly what a new signal has to
 * explain to be worth anything.
 */
function meanResidual(rows) {
  const vals = rows
    .map((r) => {
      const p = num(r.p_win);
      const y = r.outcome === 'hit' ? 1 : r.outcome === 'miss' ? 0 : null;
      return p == null || y == null ? null : y - p;
    })
    .filter((v) => v != null);
  if (!vals.length) return null;
  return vals.reduce((a, b) => a + b, 0) / vals.length;
}

/**
 * evaluateMapping(rows, candidate) — the verdict.
 *
 * rows: settled records carrying { p_win, outcome, game_date, stat, archetype_vector }
 * candidate: { axis, stat, sign, role }  sign = +1 (raises the stat) / -1 (lowers)
 *
 * Returns { verdict, reason, ... } where verdict is one of:
 *   NOMINATE      — marginal, out-of-sample, direction confirmed → runs live
 *   REJECT        — redundant, or the held-out effect contradicts the sign
 *   INSUFFICIENT  — not enough rows to say anything (honest-absent)
 */
function evaluateMapping(rows, candidate = {}) {
  const { axis, stat, sign = 1 } = candidate;
  const base = {
    axis, stat, sign, min_rows: MIN_ROWS, evaluated_at: null,
  };

  const relevant = (rows || []).filter((r) => {
    if (String(r.stat || '').toLowerCase() !== String(stat || '').toLowerCase()) return false;
    if (num(r.p_win) == null) return false;
    return r.outcome === 'hit' || r.outcome === 'miss';
  });

  if (relevant.length < MIN_ROWS) {
    return { ...base, verdict: 'INSUFFICIENT', rows: relevant.length,
      reason: `need ${MIN_ROWS} settled rows for ${stat}, have ${relevant.length}` };
  }

  // Split into HAS-the-archetype vs does not, using the vector retained at
  // grade time — never a re-classification from today's stats, which would be
  // look-ahead leakage.
  const hasAxis = (r) => {
    const v = r.archetype_vector;
    if (!v || typeof v !== 'object') return false;
    const vec = v.vector || v;
    return Boolean(vec && vec[axis]);
  };

  const { fit, test, split, dates } = splitByDate(relevant);
  if (test.length < MIN_HELD_OUT) {
    return { ...base, verdict: 'INSUFFICIENT', rows: relevant.length, held_out: test.length,
      reason: `held-out period too small (${test.length} < ${MIN_HELD_OUT})` };
  }

  const fitWith = fit.filter(hasAxis);
  const fitWithout = fit.filter((r) => !hasAxis(r));
  const testWith = test.filter(hasAxis);
  const testWithout = test.filter((r) => !hasAxis(r));

  if (fitWith.length < 20 || testWith.length < 20) {
    return { ...base, verdict: 'INSUFFICIENT', rows: relevant.length,
      with_axis_fit: fitWith.length, with_axis_test: testWith.length,
      reason: 'too few rows carrying the archetype to separate its effect' };
  }

  // The EFFECT is the difference in the projection's residual between props
  // whose player carries the archetype and props whose player does not. If the
  // projection already accounts for the trait, both residuals sit at the same
  // place and the effect is ~0 → redundant.
  const fitEffect = meanResidual(fitWith) - meanResidual(fitWithout);
  const testEffect = meanResidual(testWith) - meanResidual(testWithout);

  const expectedSign = Math.sign(sign) || 1;
  const directionHolds = Math.sign(testEffect) === expectedSign;
  const bigEnough = Math.abs(testEffect) >= MIN_EFFECT;
  // In-sample effect present but held-out effect gone = overfit.
  const survivesOOS = bigEnough && directionHolds;

  const out = {
    ...base,
    rows: relevant.length,
    split_date: split, dates: dates.length,
    fit_rows: fit.length, test_rows: test.length,
    with_axis_fit: fitWith.length, with_axis_test: testWith.length,
    fit_effect: Math.round(fitEffect * 1000) / 1000,
    test_effect: Math.round(testEffect * 1000) / 1000,
    direction_holds: directionHolds,
  };

  if (!bigEnough) {
    return { ...out, verdict: 'REJECT',
      reason: `held-out effect ${out.test_effect} is below ${MIN_EFFECT} — the projection already captures it (redundant)` };
  }
  if (!directionHolds) {
    // Never silently flip: a sign that only reverses out-of-sample is noise.
    return { ...out, verdict: 'REJECT',
      reason: `held-out effect ${out.test_effect} contradicts the candidate sign ${expectedSign}` };
  }
  return { ...out, verdict: 'NOMINATE', survivesOOS,
    reason: `adds ${out.test_effect} residual signal out-of-sample with the expected sign` };
}

/**
 * LIFECYCLE — nothing sits dark.
 *   nominated : survived the harness → RUNS LIVE as a challenger mapping,
 *               adjusting real projections and measured by the instrument.
 *   live      : running and accruing settled volume.
 *   claimed   : ONLY after the live ledger shows it beats the market. This is
 *               the only stage that is withheld, and only the CLAIM is.
 */
const LIFECYCLE = Object.freeze(['nominated', 'live', 'claimed']);

module.exports = {
  evaluateMapping,
  splitByDate,
  meanResidual,
  LIFECYCLE,
  MIN_ROWS,
  MIN_HELD_OUT,
  MIN_EFFECT,
};
