/**
 * Engine 1 — rule-based grading on the v6b feature vector.
 *
 * Engine 1 is deterministic. Same inputs always produce the same grade.
 * That predictability is intentional: when Engine 2 (LLM, non-deterministic)
 * disagrees with Engine 1, the disagreement itself is a signal we surface
 * to users — and a stable reference point makes that signal meaningful.
 *
 * Grade scale (11 steps): F, D, C-, C, C+, B-, B, B+, A-, A, A+
 * Start at C (neutral); positive signals push UP, negative push DOWN.
 *
 * Factors carry the top 3 contributors out so Engine 2 sees them in its
 * prompt and the UI can render a "why this grade" tooltip.
 */

const GRADE_SCALE = ['F', 'D', 'C-', 'C', 'C+', 'B-', 'B', 'B+', 'A-', 'A', 'A+'];
const NEUTRAL_INDEX = 3;  // 'C'

// The confidence a grade carries is the MIDPOINT of that grade's band in
// grade_thresholds.json — the ONE canonical scale, shared with the Python
// engine. This enforces the law: applying the threshold table to a grade's
// displayed confidence resolves back to the SAME letter.
//
// The pre-2026-07 hand-rolled table drifted a full sub-tier LOW (B → 0.55,
// which the threshold table calls B-). Every emitted grade's confidence read
// one sub-tier under its letter — the "B at 45%" the phone audit caught. Never
// re-hardcode this; derive it from the bands so letter and confidence can't
// disagree.
const GRADE_BANDS = require('../python/data/grade_thresholds.json').grade_scale;
const GRADE_TO_CONFIDENCE = Object.fromEntries(
  GRADE_SCALE.map((g) => {
    const band = GRADE_BANDS[g];
    const mid = band ? (band.low + band.high) / 2 : 0.25;
    return [g, Math.round(mid * 1000) / 1000];
  }),
);

function clampIndex(idx) {
  return Math.max(0, Math.min(GRADE_SCALE.length - 1, idx));
}

function indexToGrade(idx) {
  return GRADE_SCALE[clampIndex(Math.round(idx))];
}

// Each factor produces a delta (positive or negative) plus a label that
// lands in the top-N list. We track magnitude for sorting so the UI can
// surface "this matters most" honestly.
function computeFactors(input) {
  const { features = {}, trap = {}, consistency = {}, prop } = input;
  const factors = [];
  const line = Number(prop?.line);
  const direction = prop?.direction;
  const overWeighted = direction === 'over';

  // Recent form vs the line.
  if (Number.isFinite(features.l5_avg) && Number.isFinite(line) && line > 0) {
    const delta = (features.l5_avg - line) / line;  // fractional gap
    if (overWeighted) {
      if (delta >= 0.15) factors.push({ label: 'l5_hot_vs_line', delta: 1.0, magnitude: Math.abs(delta) });
      else if (delta <= -0.15) factors.push({ label: 'l5_cold_vs_line', delta: -1.0, magnitude: Math.abs(delta) });
    } else {
      // For UNDER props the signs flip.
      if (delta <= -0.15) factors.push({ label: 'l5_under_friendly', delta: 1.0, magnitude: Math.abs(delta) });
      else if (delta >= 0.15) factors.push({ label: 'l5_hot_vs_under', delta: -1.0, magnitude: Math.abs(delta) });
    }
  }

  // Trend confirmation from L20 — SYMMETRIC (Session 63).
  // Both branches used to be delta +1.0, so the season baseline could only ever
  // ADD to the grade: a player whose season average CONTRADICTED the graded side
  // contributed nothing instead of subtracting. With no negative L20 path the
  // reachable index floor was -1.5, one rounding tick above a D, which is a
  // structural reason D and F were unreachable. The contradiction case now
  // carries the mirrored -1.0.
  if (Number.isFinite(features.l20_avg) && Number.isFinite(line) && line > 0) {
    const delta20 = (features.l20_avg - line) / line;
    if (overWeighted) {
      if (delta20 > 0) factors.push({ label: 'l20_over_line', delta: 1.0, magnitude: Math.abs(delta20) });
      else if (delta20 < 0) factors.push({ label: 'l20_contradicts_over', delta: -1.0, magnitude: Math.abs(delta20) });
    } else {
      if (delta20 < 0) factors.push({ label: 'l20_under_line', delta: 1.0, magnitude: Math.abs(delta20) });
      else if (delta20 > 0) factors.push({ label: 'l20_contradicts_under', delta: -1.0, magnitude: Math.abs(delta20) });
    }
  }

  // Consistency.
  const cLabel = consistency.consistency;
  if (cLabel === 'elite' || cLabel === 'reliable') {
    factors.push({ label: `consistency_${cLabel}`, delta: 1.0, magnitude: consistency.score ?? 0.7 });
  } else if (cLabel === 'boom_bust') {
    factors.push({ label: 'consistency_boom_bust', delta: -1.0, magnitude: 0.9 });
  }

  // Opponent rank (0..1 scale where 1.0 = worst defense, easiest matchup).
  if (Number.isFinite(features.opp_rank_stat)) {
    if (features.opp_rank_stat >= 0.70) {
      const adj = overWeighted ? 1.0 : -1.0;
      factors.push({ label: 'weak_opponent_defense', delta: adj, magnitude: features.opp_rank_stat });
    } else if (features.opp_rank_stat <= 0.30) {
      const adj = overWeighted ? -1.0 : 1.0;
      factors.push({ label: 'top_opponent_defense', delta: adj, magnitude: 1 - features.opp_rank_stat });
    }
  }

  // Home / away.
  if (features.home_away === 1.0) {
    factors.push({ label: 'home_game', delta: 0.5, magnitude: 0.5 });
  } else if (features.home_away === 0.0 && features.opp_rank_stat != null && features.opp_rank_stat <= 0.15) {
    factors.push({ label: 'away_vs_top5_defense', delta: -0.5, magnitude: 0.7 });
  }

  // Rest / fatigue.
  if (features.rest_days >= 2) factors.push({ label: 'rested_2plus', delta: 0.5, magnitude: 0.5 });
  if (features.rest_days === 0) factors.push({ label: 'back_to_back', delta: -0.5, magnitude: 0.7 });
  if ((features.game_count_in_7d ?? 0) >= 4) factors.push({ label: 'heavy_workload_7d', delta: -0.5, magnitude: 0.6 });

  // Coach pace.
  if (Number.isFinite(features.coach_pace_delta) && Math.abs(features.coach_pace_delta) > 0.5) {
    const sign = overWeighted ? Math.sign(features.coach_pace_delta) : -Math.sign(features.coach_pace_delta);
    factors.push({ label: 'coach_pace_delta', delta: 0.5 * sign, magnitude: Math.abs(features.coach_pace_delta) / 5 });
  }

  // Ref pace.
  if (Number.isFinite(features.ref_pace_adjustment) && Math.abs(features.ref_pace_adjustment) > 0.1) {
    const sign = overWeighted ? Math.sign(features.ref_pace_adjustment) : -Math.sign(features.ref_pace_adjustment);
    factors.push({ label: 'ref_pace_adjustment', delta: 0.5 * sign, magnitude: Math.abs(features.ref_pace_adjustment) });
  }

  // Ref foul tendency — a high-foul crew puts FT-heavy scorers at the line
  // more often. We treat the magnitude as a binary boost for scoring props.
  if (Number.isFinite(features.ref_foul_adjustment)) {
    if (features.ref_foul_adjustment > 0.5) {
      factors.push({ label: 'ref_foul_high', delta: overWeighted ? 0.5 : -0.5, magnitude: features.ref_foul_adjustment });
    } else if (features.ref_foul_adjustment < -0.5) {
      factors.push({ label: 'ref_foul_low', delta: overWeighted ? -0.5 : 0.5, magnitude: Math.abs(features.ref_foul_adjustment) });
    }
  }

  // Opponent injury severity — 2-3+ starters out means a thinner rotation
  // and easier matchup. Always lifts an OVER, never matters for UNDER.
  if (Number.isFinite(features.injury_severity_score) && overWeighted) {
    if (features.injury_severity_score >= 3) {
      factors.push({ label: 'opp_3plus_starters_out', delta: 1.0, magnitude: 1.0 });
    } else if (features.injury_severity_score >= 2) {
      factors.push({ label: 'opp_2_starters_out', delta: 0.5, magnitude: 0.7 });
    }
  }

  // Playoff experience — rookies in playoffs are volatile (downgrade);
  // veterans handle the spotlight better (upgrade). Only meaningful in
  // playoff games (season_type >= 2 in our config).
  if (Number.isFinite(features.career_playoff_games) && features.season_type >= 2) {
    if (features.career_playoff_games === 0) {
      factors.push({ label: 'rookie_in_playoffs', delta: -0.5, magnitude: 0.8 });
    } else if (features.career_playoff_games > 30) {
      factors.push({ label: 'veteran_in_playoffs', delta: 0.5, magnitude: 0.6 });
    }
  }

  // Trap composite — the big lever.
  if (Number.isFinite(trap.composite) && trap.composite > 0.5) {
    factors.push({ label: 'trap_composite_high', delta: -1.0, magnitude: trap.composite });
  }

  return factors;
}

function gradeFromFactors(factors) {
  let idx = NEUTRAL_INDEX;
  for (const f of factors) idx += f.delta;
  idx = clampIndex(Math.round(idx));
  return { grade: GRADE_SCALE[idx], confidence: GRADE_TO_CONFIDENCE[GRADE_SCALE[idx]] ?? 0.25 };
}

function topFactorLabels(factors, n = 3) {
  return [...factors]
    .sort((a, b) => Math.abs(b.delta * b.magnitude) - Math.abs(a.delta * a.magnitude))
    .slice(0, n)
    .map((f) => f.label);
}

function gradeProp(input) {
  const factors = computeFactors(input);
  const { grade, confidence } = gradeFromFactors(factors);
  return {
    grade,
    confidence,
    top_factors: topFactorLabels(factors, 3),
    all_factors: factors.map((f) => f.label),
  };
}

module.exports = {
  gradeProp,
  GRADE_SCALE,
  GRADE_TO_CONFIDENCE,
  __internals: { computeFactors, gradeFromFactors, topFactorLabels, indexToGrade, NEUTRAL_INDEX },
};
