/**
 * VYNDR Ship Grading Engine — Unit Tests
 *
 * Pure logic tests for grading engine components.
 * All constants and formulas are inlined — no external imports.
 * Tests the DATA CONTRACTS that the grading engine must honor.
 */

// ---------------------------------------------------------------------------
// 1. Grade Thresholds (4 tests)
// ---------------------------------------------------------------------------
describe('Grade Thresholds', () => {
  const GRADE_MAP = [
    { min: 0.85, max: 1.00, grade: 'A+' },
    { min: 0.78, max: 0.84, grade: 'A' },
    { min: 0.72, max: 0.77, grade: 'B+' },
    { min: 0.66, max: 0.71, grade: 'B' },
    { min: 0.55, max: 0.65, grade: 'C+' },
    { min: 0.40, max: 0.54, grade: 'C' },
    { min: 0.29, max: 0.39, grade: 'D' },
    { min: 0.00, max: 0.28, grade: 'F' },
  ];

  function toGrade(confidence) {
    for (const tier of GRADE_MAP) {
      if (confidence >= tier.min && confidence <= tier.max) return tier.grade;
    }
    return 'F';
  }

  test('A+ range covers 0.85 to 1.00', () => {
    expect(toGrade(0.85)).toBe('A+');
    expect(toGrade(0.92)).toBe('A+');
    expect(toGrade(1.00)).toBe('A+');
  });

  test('B+ range covers 0.72 to 0.77 (not 0.66-0.71 which is B)', () => {
    expect(toGrade(0.66)).toBe('B');
    expect(toGrade(0.71)).toBe('B');
    expect(toGrade(0.72)).toBe('B+');
  });

  test('C+ caps at 0.54 on the low end when data is limited', () => {
    const DATA_LIMITED_CAP = 0.54;
    const rawConfidence = 0.78;
    const capped = Math.min(rawConfidence, DATA_LIMITED_CAP);
    expect(capped).toBe(0.54);
    expect(toGrade(capped)).toBe('C');
  });

  test('F grade for confidence below 0.29', () => {
    expect(toGrade(0.10)).toBe('F');
    expect(toGrade(0.28)).toBe('F');
    expect(toGrade(0.00)).toBe('F');
  });
});

// ---------------------------------------------------------------------------
// 2. Bayesian Weights Per-Stat-Type (5 tests)
// ---------------------------------------------------------------------------
describe('Bayesian Weights Per-Stat-Type', () => {
  const BAYESIAN_WEIGHTS = {
    strikeouts: { prior: 0.40, recent: 0.35, context: 0.25 },
    points:     { prior: 0.30, recent: 0.45, context: 0.25 },
    assists:    { prior: 0.25, recent: 0.50, context: 0.25 },
    rebounds:   { prior: 0.30, recent: 0.40, context: 0.30 },
    threes:     { prior: 0.35, recent: 0.40, context: 0.25 },
  };

  const DEFAULT_WEIGHTS = { prior: 0.33, recent: 0.34, context: 0.33 };

  test('strikeouts has prior weight of 0.40', () => {
    expect(BAYESIAN_WEIGHTS.strikeouts.prior).toBe(0.40);
  });

  test('points has recent weight of 0.45', () => {
    expect(BAYESIAN_WEIGHTS.points.recent).toBe(0.45);
  });

  test('assists has recent weight of 0.50', () => {
    expect(BAYESIAN_WEIGHTS.assists.recent).toBe(0.50);
  });

  test('default weights sum to approximately 1.0', () => {
    const sum = DEFAULT_WEIGHTS.prior + DEFAULT_WEIGHTS.recent + DEFAULT_WEIGHTS.context;
    expect(sum).toBeCloseTo(1.0, 5);
  });

  test('every stat type has prior + recent + context summing to 1.0', () => {
    for (const [stat, weights] of Object.entries(BAYESIAN_WEIGHTS)) {
      const sum = weights.prior + weights.recent + weights.context;
      expect(sum).toBeCloseTo(1.0, 5);
    }
  });
});

// ---------------------------------------------------------------------------
// 3. Abstention Logic (3 tests)
// ---------------------------------------------------------------------------
describe('Abstention Logic', () => {
  function shouldAbstain({ confidence, similar_games, data_quality }) {
    if (confidence >= 0.40 && confidence <= 0.55 && similar_games < 3) return true;
    if (data_quality === 'limited' && confidence < 0.55) return true;
    return false;
  }

  test('abstain when confidence 0.40-0.55 AND similar_games < 3', () => {
    expect(shouldAbstain({ confidence: 0.45, similar_games: 2, data_quality: 'normal' })).toBe(true);
    expect(shouldAbstain({ confidence: 0.50, similar_games: 0, data_quality: 'normal' })).toBe(true);
  });

  test('abstain when data_quality is limited AND confidence < 0.55', () => {
    expect(shouldAbstain({ confidence: 0.50, similar_games: 10, data_quality: 'limited' })).toBe(true);
    expect(shouldAbstain({ confidence: 0.40, similar_games: 5, data_quality: 'limited' })).toBe(true);
  });

  test('do NOT abstain when confidence > 0.55', () => {
    expect(shouldAbstain({ confidence: 0.60, similar_games: 1, data_quality: 'normal' })).toBe(false);
    expect(shouldAbstain({ confidence: 0.80, similar_games: 0, data_quality: 'normal' })).toBe(false);
  });
});

// ---------------------------------------------------------------------------
// 4. Global Offset Clamped +/-0.15 (3 tests)
// ---------------------------------------------------------------------------
describe('Global Offset Clamped +/-0.15', () => {
  const OFFSET_CLAMP = 0.15;
  const MIN_SAMPLE_SIZE = 20;

  function clampOffset(rawOffset, sampleSize) {
    if (sampleSize < MIN_SAMPLE_SIZE) return 0.0;
    return Math.max(-OFFSET_CLAMP, Math.min(OFFSET_CLAMP, rawOffset));
  }

  test('clamps positive offset to +0.15', () => {
    expect(clampOffset(0.30, 50)).toBe(0.15);
    expect(clampOffset(0.15, 50)).toBe(0.15);
  });

  test('clamps negative offset to -0.15', () => {
    expect(clampOffset(-0.25, 50)).toBe(-0.15);
    expect(clampOffset(-0.15, 50)).toBe(-0.15);
  });

  test('returns zero when sample size is insufficient', () => {
    expect(clampOffset(0.10, 5)).toBe(0.0);
    expect(clampOffset(-0.20, 19)).toBe(0.0);
  });
});

// ---------------------------------------------------------------------------
// 5. Data Sufficiency Smooth Curve (4 tests)
// ---------------------------------------------------------------------------
describe('Data Sufficiency Smooth Curve', () => {
  const C_PLUS_CAP = 0.54;
  const MIN_GAMES = 5;
  const FULL_GAMES = MIN_GAMES * 2; // 10

  function dataSufficiencyMultiplier(gamesPlayed) {
    if (gamesPlayed < MIN_GAMES) return C_PLUS_CAP;
    if (gamesPlayed >= FULL_GAMES) return 1.0;
    // smooth ramp from 0.70 to 1.0 between MIN_GAMES and FULL_GAMES
    const t = (gamesPlayed - MIN_GAMES) / (FULL_GAMES - MIN_GAMES);
    return 0.70 + 0.30 * t;
  }

  test('below minimum returns C+ cap (0.54)', () => {
    expect(dataSufficiencyMultiplier(0)).toBe(C_PLUS_CAP);
    expect(dataSufficiencyMultiplier(4)).toBe(C_PLUS_CAP);
  });

  test('at minimum returns 70% confidence multiplier', () => {
    expect(dataSufficiencyMultiplier(MIN_GAMES)).toBeCloseTo(0.70, 5);
  });

  test('at 2x minimum returns 100% confidence multiplier', () => {
    expect(dataSufficiencyMultiplier(FULL_GAMES)).toBe(1.0);
    expect(dataSufficiencyMultiplier(15)).toBe(1.0);
  });

  test('ramp is smooth between min and 2x min', () => {
    const at6 = dataSufficiencyMultiplier(6);
    const at7 = dataSufficiencyMultiplier(7);
    const at8 = dataSufficiencyMultiplier(8);
    // monotonically increasing
    expect(at7).toBeGreaterThan(at6);
    expect(at8).toBeGreaterThan(at7);
    // all within the 0.70–1.0 band
    expect(at6).toBeGreaterThanOrEqual(0.70);
    expect(at8).toBeLessThanOrEqual(1.0);
  });
});

// ---------------------------------------------------------------------------
// 6. Real Edge with Vig (4 tests)
// ---------------------------------------------------------------------------
describe('Real Edge with Vig', () => {
  function americanToImpliedProb(odds) {
    if (odds < 0) return Math.abs(odds) / (Math.abs(odds) + 100);
    return 100 / (odds + 100);
  }

  function edgeVerdict(modelProb, impliedProb) {
    const edge = modelProb - impliedProb;
    return edge > 0 ? 'BET' : 'NO BET';
  }

  test('-110 implied probability is approximately 0.524', () => {
    const prob = americanToImpliedProb(-110);
    expect(prob).toBeCloseTo(0.524, 2);
  });

  test('+150 implied probability is approximately 0.40', () => {
    const prob = americanToImpliedProb(150);
    expect(prob).toBeCloseTo(0.40, 2);
  });

  test('positive EV when model probability > implied probability', () => {
    const implied = americanToImpliedProb(-110); // ~0.524
    const modelProb = 0.60;
    expect(edgeVerdict(modelProb, implied)).toBe('BET');
  });

  test('negative EV returns NO BET', () => {
    const implied = americanToImpliedProb(-110); // ~0.524
    const modelProb = 0.48;
    expect(edgeVerdict(modelProb, implied)).toBe('NO BET');
  });
});

// ---------------------------------------------------------------------------
// 7. Kelly Criterion (3 tests)
// ---------------------------------------------------------------------------
describe('Kelly Criterion', () => {
  function americanToDecimalOdds(odds) {
    if (odds < 0) return 1 + (100 / Math.abs(odds));
    return 1 + (odds / 100);
  }

  function quarterKelly(modelProb, americanOdds) {
    const decimal = americanToDecimalOdds(americanOdds);
    const b = decimal - 1;
    const q = 1 - modelProb;
    const fullKelly = (modelProb * b - q) / b;
    if (fullKelly <= 0) return 0;
    return fullKelly / 4;
  }

  test('quarter Kelly divides full Kelly by 4', () => {
    const modelProb = 0.60;
    const odds = -110;
    const decimal = americanToDecimalOdds(odds); // ~1.909
    const b = decimal - 1;
    const fullKelly = (modelProb * b - (1 - modelProb)) / b;
    const qk = quarterKelly(modelProb, odds);
    expect(qk).toBeCloseTo(fullKelly / 4, 5);
  });

  test('returns 0 for negative EV bets', () => {
    expect(quarterKelly(0.40, -150)).toBe(0);
    expect(quarterKelly(0.30, -110)).toBe(0);
  });

  test('reasonable sizing for +EV bet', () => {
    const size = quarterKelly(0.60, -110);
    // quarter Kelly should be modest — well under 10% of bankroll
    expect(size).toBeGreaterThan(0);
    expect(size).toBeLessThan(0.10);
  });
});

// ---------------------------------------------------------------------------
// 8. Brier Score (3 tests)
// ---------------------------------------------------------------------------
describe('Brier Score', () => {
  function brierScore(predictions) {
    if (!predictions || predictions.length === 0) return null;
    const sum = predictions.reduce((acc, { prob, outcome }) => {
      return acc + Math.pow(prob - outcome, 2);
    }, 0);
    return sum / predictions.length;
  }

  test('perfect prediction yields Brier score of 0', () => {
    const preds = [
      { prob: 1.0, outcome: 1 },
      { prob: 0.0, outcome: 0 },
    ];
    expect(brierScore(preds)).toBe(0);
  });

  test('coin flip predictions yield Brier score of 0.25', () => {
    const preds = [
      { prob: 0.5, outcome: 1 },
      { prob: 0.5, outcome: 0 },
    ];
    expect(brierScore(preds)).toBeCloseTo(0.25, 5);
  });

  test('returns null for empty data', () => {
    expect(brierScore([])).toBeNull();
    expect(brierScore(null)).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// 9. Similar Game Confidence Modifier (3 tests)
// ---------------------------------------------------------------------------
describe('Similar Game Confidence Modifier', () => {
  function similarGameModifier(similarGames) {
    if (similarGames >= 10) return 0.05;
    if (similarGames <= 1) return -0.03;
    if (similarGames >= 2 && similarGames <= 4) return 0.0;
    // 5-9 range — partial boost
    return 0.02;
  }

  test('+0.05 for 10+ similar games', () => {
    expect(similarGameModifier(10)).toBe(0.05);
    expect(similarGameModifier(25)).toBe(0.05);
  });

  test('-0.03 for 0-1 similar games', () => {
    expect(similarGameModifier(0)).toBe(-0.03);
    expect(similarGameModifier(1)).toBe(-0.03);
  });

  test('0.0 for 2-4 similar games', () => {
    expect(similarGameModifier(2)).toBe(0.0);
    expect(similarGameModifier(3)).toBe(0.0);
    expect(similarGameModifier(4)).toBe(0.0);
  });
});

// ---------------------------------------------------------------------------
// 10. Skewness (2 tests)
// ---------------------------------------------------------------------------
describe('Skewness', () => {
  function skewness(values) {
    if (!values || values.length < 10) return 0.0;
    const n = values.length;
    const mean = values.reduce((a, b) => a + b, 0) / n;
    const variance = values.reduce((a, v) => a + Math.pow(v - mean, 2), 0) / n;
    const std = Math.sqrt(variance);
    if (std === 0) return 0.0;
    const skew = values.reduce((a, v) => a + Math.pow((v - mean) / std, 3), 0) / n;
    return skew;
  }

  test('returns 0.0 for fewer than 10 values', () => {
    expect(skewness([1, 2, 3])).toBe(0.0);
    expect(skewness([1, 2, 3, 4, 5, 6, 7, 8, 9])).toBe(0.0);
    expect(skewness(null)).toBe(0.0);
  });

  test('positive skew for right-skewed data', () => {
    // Mostly low values with a few high outliers
    const data = [1, 2, 2, 3, 3, 3, 4, 4, 5, 20];
    expect(skewness(data)).toBeGreaterThan(0);
  });
});

// ---------------------------------------------------------------------------
// 11. Matchup Pace (3 tests)
// ---------------------------------------------------------------------------
describe('Matchup Pace', () => {
  const LEAGUE_AVG_PACE = 100.0;
  const HOME_WEIGHT = 0.60;
  const AWAY_WEIGHT = 0.40;

  function matchupPace(homePace, awayPace) {
    const blended = homePace * HOME_WEIGHT + awayPace * AWAY_WEIGHT;
    return blended / LEAGUE_AVG_PACE;
  }

  test('two fast teams produce pace factor > 1.0', () => {
    const factor = matchupPace(108, 106);
    expect(factor).toBeGreaterThan(1.0);
  });

  test('home team weighted 60/40 over away', () => {
    const factor = matchupPace(110, 90);
    // 110*0.6 + 90*0.4 = 66 + 36 = 102 => 102/100 = 1.02
    expect(factor).toBeCloseTo(1.02, 5);
  });

  test('league average teams return pace of 1.0', () => {
    const factor = matchupPace(LEAGUE_AVG_PACE, LEAGUE_AVG_PACE);
    expect(factor).toBe(1.0);
  });
});

// ---------------------------------------------------------------------------
// 12. Foul Trouble Risk (3 tests)
// ---------------------------------------------------------------------------
describe('Foul Trouble Risk', () => {
  function foulTroubleBoost(avgFouls) {
    if (avgFouls >= 3.5) return 3.0;
    if (avgFouls >= 2.8) return 1.5;
    return 0.0;
  }

  test('>= 3.5 average fouls returns 3.0 std deviation boost', () => {
    expect(foulTroubleBoost(3.5)).toBe(3.0);
    expect(foulTroubleBoost(4.2)).toBe(3.0);
  });

  test('>= 2.8 average fouls returns 1.5 std deviation boost', () => {
    expect(foulTroubleBoost(2.8)).toBe(1.5);
    expect(foulTroubleBoost(3.4)).toBe(1.5);
  });

  test('< 2.8 average fouls returns 0.0', () => {
    expect(foulTroubleBoost(2.0)).toBe(0.0);
    expect(foulTroubleBoost(2.7)).toBe(0.0);
  });
});

// ---------------------------------------------------------------------------
// 13. B2B Stat-Specific Adjustments (4 tests)
// ---------------------------------------------------------------------------
describe('B2B Stat-Specific Adjustments', () => {
  const B2B_ADJ = {
    points: -0.04,
    rebounds: 0.02,
    threes: -0.03,
    assists: 0.00,
  };

  function applyB2B(projection, statType, isB2B) {
    if (!isB2B) return projection;
    const adj = B2B_ADJ[statType] || 0;
    return projection * (1 + adj);
  }

  test('B2B points adjustment is -4%', () => {
    const adjusted = applyB2B(25.0, 'points', true);
    expect(adjusted).toBe(24.0);
  });

  test('B2B rebounds adjustment is +2%', () => {
    const adjusted = applyB2B(10.0, 'rebounds', true);
    expect(adjusted).toBeCloseTo(10.2, 5);
  });

  test('B2B threes adjustment is -3%', () => {
    const adjusted = applyB2B(3.0, 'threes', true);
    expect(adjusted).toBeCloseTo(2.91, 5);
  });

  test('no adjustment when not B2B', () => {
    expect(applyB2B(25.0, 'points', false)).toBe(25.0);
    expect(applyB2B(10.0, 'rebounds', false)).toBe(10.0);
    expect(applyB2B(3.0, 'threes', false)).toBe(3.0);
  });
});

// ---------------------------------------------------------------------------
// 14. Usage-Efficiency Tradeoff (2 tests)
// ---------------------------------------------------------------------------
describe('Usage-Efficiency Tradeoff', () => {
  const TS_DROP_PER_5_USG = -0.015; // -1.5% TS per +5% usage

  function usageEfficiencyAdjustment(usageDelta) {
    // usageDelta in percentage points (e.g., +5 means usage went up 5%)
    return (usageDelta / 5) * TS_DROP_PER_5_USG;
  }

  function netEffect(usageDelta, baseTS) {
    const tsAdj = usageEfficiencyAdjustment(usageDelta);
    return baseTS + tsAdj;
  }

  test('-1.5% TS per +5% usage increase', () => {
    const adj = usageEfficiencyAdjustment(5);
    expect(adj).toBeCloseTo(-0.015, 5);
    const adj10 = usageEfficiencyAdjustment(10);
    expect(adj10).toBeCloseTo(-0.030, 5);
  });

  test('net effect is sum of base TS and adjustment', () => {
    const baseTS = 0.580;
    const usageDelta = 5;
    const net = netEffect(usageDelta, baseTS);
    expect(net).toBeCloseTo(0.580 + (-0.015), 5);
    expect(net).toBeCloseTo(0.565, 5);
  });
});

// ---------------------------------------------------------------------------
// 15. Dynamic Usage Boost Headroom (2 tests)
// ---------------------------------------------------------------------------
describe('Dynamic Usage Boost Headroom', () => {
  const MAX_BOOST = 0.08; // 8% max boost
  const HIGH_USAGE_THRESHOLD = 0.30; // 30% usage rate

  function usageBoost(currentUsage, projectedIncrease) {
    const headroom = Math.max(0, HIGH_USAGE_THRESHOLD - currentUsage);
    const scaleFactor = Math.min(1.0, headroom / HIGH_USAGE_THRESHOLD);
    return projectedIncrease * scaleFactor * MAX_BOOST;
  }

  test('low usage player gets full boost', () => {
    // 15% usage — lots of headroom
    const boost = usageBoost(0.15, 1.0);
    // headroom = 0.30 - 0.15 = 0.15, scaleFactor = 0.15/0.30 = 0.5
    expect(boost).toBeCloseTo(0.04, 5);
    // But a very low usage player gets even more
    const boostLow = usageBoost(0.10, 1.0);
    expect(boostLow).toBeGreaterThan(boost);
  });

  test('high usage player gets scaled down boost', () => {
    // 28% usage — close to threshold, little headroom
    const boost = usageBoost(0.28, 1.0);
    // headroom = 0.30 - 0.28 = 0.02, scaleFactor = 0.02/0.30 ≈ 0.0667
    expect(boost).toBeCloseTo(0.02 / 0.30 * MAX_BOOST, 4);
    expect(boost).toBeLessThan(0.01);

    // At or above threshold — zero boost
    const boostAtCap = usageBoost(0.30, 1.0);
    expect(boostAtCap).toBe(0);
  });
});
