/**
 * Session 63 — grade-range restoration.
 *
 * Locks the three structural facts the S63 audit found and fixed:
 *   1. L20 has a NEGATIVE branch (there was no downside path at all).
 *   2. With the previously-starving factors alive, A and D are REACHABLE.
 *   3. `confidence` is explicitly labelled as grade-derived, not a probability.
 *
 * These are arithmetic/structural assertions on the engine, NOT a claim about
 * how often A should occur in the wild — that is the live distribution report.
 */

const engine1 = require('../../src/services/intelligence/engine1');
const { toLegacyShape } = require('../../src/utils/gradeAdapter');
const featureCache = require('../../src/services/intelligence/featureCache');

const prop = (direction = 'over', line = 10) => ({ line, direction });

describe('L20 symmetry (the missing downside path)', () => {
  test('L20 BELOW the line now subtracts on an OVER', () => {
    const factors = engine1.__internals
      ? engine1.__internals.computeFactors({ features: { l20_avg: 5 }, prop: prop('over', 10) })
      : null;
    const res = engine1.gradeProp({ features: { l20_avg: 5 }, prop: prop('over', 10) });
    // Whether or not internals are exported, the graded result must be BELOW
    // the neutral 'C' — previously l20 could only ever add.
    expect(['F', 'D', 'C-']).toContain(res.grade);
    if (factors) {
      expect(factors.find((f) => f.label === 'l20_contradicts_over').delta).toBe(-1.0);
    }
  });

  test('L20 ABOVE the line still adds on an OVER (unchanged)', () => {
    const res = engine1.gradeProp({ features: { l20_avg: 15 }, prop: prop('over', 10) });
    expect(['C+', 'B-', 'B']).toContain(res.grade);
  });

  test('L20 ABOVE the line subtracts on an UNDER (mirrored)', () => {
    const res = engine1.gradeProp({ features: { l20_avg: 15 }, prop: prop('under', 10) });
    expect(['F', 'D', 'C-']).toContain(res.grade);
  });
});

describe('A and D are reachable once the starving factors are alive', () => {
  test('A emits when the real signals stack (the merit path)', () => {
    const res = engine1.gradeProp({
      features: {
        l5_avg: 14,           // +1.0  hot vs line
        l20_avg: 13,          // +1.0  season confirms
        opp_rank_stat: 0.85,  // +1.0  weak defense  (was permanently null)
        home_away: 1.0,       // +0.5
        rest_days: 3,         // +0.5
      },
      consistency: { consistency: 'elite', score: 0.9 },  // +1.0 (was 'unknown')
      prop: prop('over', 10),
    });
    expect(['A-', 'A', 'A+']).toContain(res.grade);
  });

  test('D/F emits when the real signals stack against (the merit path)', () => {
    const res = engine1.gradeProp({
      features: {
        l5_avg: 6,            // -1.0  cold vs line
        l20_avg: 7,           // -1.0  season contradicts (NEW branch)
        opp_rank_stat: 0.1,   // -1.0  top defense
        home_away: 0.0,
        rest_days: 0,         // -0.5  back-to-back
        game_count_in_7d: 5,  // -0.5  heavy workload (was never populated)
      },
      consistency: { consistency: 'boom_bust' },          // -1.0
      trap: { composite: 0.8 },                           // -1.0
      prop: prop('over', 10),
    });
    expect(['F', 'D']).toContain(res.grade);
  });

  test('a neutral feature set still lands at C — no inflation', () => {
    const res = engine1.gradeProp({ features: {}, prop: prop('over', 10) });
    expect(res.grade).toBe('C');
  });
});

describe('confidence is labelled as derived, not a probability', () => {
  test('toLegacyShape marks confidence_basis', () => {
    const out = toLegacyShape(
      { grade: 'B', confidence: 0.63, all_factors: [] },
      { player: 'X', stat_type: 'hits', line: 1.5, direction: 'over' },
    );
    expect(out.confidence_basis).toBe('grade_band');
  });
});

describe('gameCountInWindow (powers heavy_workload_7d)', () => {
  const now = Date.UTC(2026, 6, 19);
  const day = 86_400_000;

  test('counts only games inside the window', () => {
    const rows = [
      { date: new Date(now - 1 * day).toISOString(), hits: 1 },
      { date: new Date(now - 3 * day).toISOString(), hits: 2 },
      { date: new Date(now - 20 * day).toISOString(), hits: 0 },
    ];
    expect(featureCache.gameCountInWindow(rows, 7, now)).toBe(2);
  });

  test('returns null (absent, not 0) when there are no dated rows', () => {
    expect(featureCache.gameCountInWindow([], 7, now)).toBeNull();
    expect(featureCache.gameCountInWindow([{ hits: 1 }], 7, now)).toBeNull();
    expect(featureCache.gameCountInWindow(null, 7, now)).toBeNull();
  });
});

describe('consistency low-mean classifier (CV → index of dispersion)', () => {
  const cs = require('../../src/services/intelligence/consistencyScore');

  test('low-mean MLB stat classifies on IoD, not blanket CV boom_bust', async () => {
    // Real Pete Alonso hits log: mean 0.60. Under the raw CV (1.17) this was
    // boom_bust; the S63 stopgap made it 'unknown'; the IoD fix recovers it —
    // IoD = variance/mean = 0.82 → steadier than random → 'reliable' (+1.0).
    // The original point still holds: it is NOT wrongly stamped boom_bust.
    const logs = [0, 0, 0, 1, 2, 1, 0, 1, 1, 0].map((hits) => ({ hits }));
    const res = await cs.getConsistency({ statType: 'hits', gameLogs: logs });
    expect(res.method).toBe('iod');
    expect(['elite', 'reliable']).toContain(res.consistency);
    expect(res.consistency).not.toBe('boom_bust');
  });

  test('CV still classifies normally above the floor (NBA-scale stat)', async () => {
    const logs = [20, 22, 19, 21, 20, 23, 18, 21, 20, 22].map((points) => ({ points }));
    const res = await cs.getConsistency({ statType: 'points', gameLogs: logs });
    expect(res.method).toBe('cv');
    expect(['elite', 'reliable']).toContain(res.consistency);
  });

  test('cvIsMeaningful is the CV/IoD split point (not a blanket refusal)', () => {
    expect(cs.cvIsMeaningful(0.6)).toBe(false); // < 4 → IoD branch
    expect(cs.cvIsMeaningful(12)).toBe(true);   // ≥ 4 → CV branch
  });
});
