/**
 * shipResolution.test.js
 * Tests the resolution pipeline, calibration, archetype/weights, parlay,
 * capper content, and migration SQL for the VYNDR ship build.
 */

const fs = require('fs');
const path = require('path');

// ---------- Migration SQL ----------
const sql005 = fs.readFileSync(path.join(__dirname, '../../supabase/migrations/005_lineup_scheme_data.sql'), 'utf8');
const sql006 = fs.readFileSync(path.join(__dirname, '../../supabase/migrations/006_data_warehouse_calibration.sql'), 'utf8');
const sql007 = fs.readFileSync(path.join(__dirname, '../../supabase/migrations/007_lineup_odds_trust_health.sql'), 'utf8');

// ==========================================================
// Helpers — inline logic that mirrors production functions
// ==========================================================

function resolveHit(direction, propLine, actualValue) {
  if (direction === 'over') return actualValue > propLine;
  if (direction === 'under') return actualValue < propLine;
  return null;
}

function calculateCLV(openingLine, closingLine, direction) {
  if (openingLine == null || closingLine == null) return null;
  const movement = closingLine - openingLine;
  const favorable = direction === 'over' ? movement > 0 : movement < 0;
  return {
    clv: favorable ? Math.abs(movement) : -Math.abs(movement),
    clv_magnitude: Math.abs(movement),
  };
}

function modelMarketAlignment(openingLine, closingLine, direction) {
  if (openingLine == null || closingLine == null) return null;
  const movement = closingLine - openingLine;
  const marketMovedWithUs =
    (direction === 'over' && movement > 0) ||
    (direction === 'under' && movement < 0);
  return marketMovedWithUs ? 'confirming' : 'contrarian';
}

function createJointOutcomes(grades) {
  const resolved = grades.filter((g) => g.hit != null);
  const pairs = [];
  for (let i = 0; i < resolved.length; i++) {
    for (let j = i + 1; j < resolved.length; j++) {
      pairs.push({
        player_a_id: resolved[i].player_id,
        player_b_id: resolved[j].player_id,
        stat_a: resolved[i].stat_type,
        stat_b: resolved[j].stat_type,
        hit_a: resolved[i].hit,
        hit_b: resolved[j].hit,
      });
    }
  }
  return pairs;
}

function shouldRecalibrate(sampleSize) {
  return [25, 50, 75, 100].includes(sampleSize);
}

function clampOffset(raw) {
  return Math.max(-0.15, Math.min(0.15, raw));
}

function brierScore(confidence, hit) {
  return (confidence - (hit ? 1 : 0)) ** 2;
}

function detectBlindSpots(grades) {
  if (grades.length < 200) return [];
  const grouped = {};
  for (const g of grades) {
    const key = `${g.stat_type}|${g.context}`;
    if (!grouped[key]) grouped[key] = { total: 0, hits: 0 };
    grouped[key].total += 1;
    if (g.hit) grouped[key].hits += 1;
  }
  const overall = grades.filter((g) => g.hit).length / grades.length;
  const spots = [];
  for (const [key, val] of Object.entries(grouped)) {
    const rate = val.hits / val.total;
    if ((overall - rate) / overall >= 0.25) {
      spots.push({ key, rate, degradation: (overall - rate) / overall });
    }
  }
  return spots;
}

function catastrophicMisses(grades) {
  const resolved = grades.filter((g) => g.hit === false && g.actual_value != null);
  resolved.sort((a, b) => Math.abs(b.actual_value - b.prop_line) - Math.abs(a.actual_value - a.prop_line));
  const cutoff = Math.max(5, Math.ceil(resolved.length * 0.05));
  return resolved.slice(0, cutoff);
}

// NBA archetype detection & weight blending
const ARCHETYPE_WEIGHTS = {
  primary_scorer: { matchup_defense: 0.30, usage_context: 0.25, recent_form: 0.20, pace_impact: 0.15, rest_travel: 0.10 },
  secondary_creator: { usage_context: 0.35, matchup_defense: 0.20, recent_form: 0.20, pace_impact: 0.15, rest_travel: 0.10 },
  stretch_big: { matchup_defense: 0.25, usage_context: 0.20, recent_form: 0.20, pace_impact: 0.20, rest_travel: 0.15 },
  default: { matchup_defense: 0.20, usage_context: 0.20, recent_form: 0.20, pace_impact: 0.20, rest_travel: 0.20 },
};

function detectArchetypes(profile) {
  const scores = {};
  if (profile.pts_per_game >= 22 && profile.usage_rate >= 0.28) {
    scores.primary_scorer = (profile.pts_per_game / 30) * 0.5 + (profile.usage_rate / 0.35) * 0.5;
  }
  if (profile.ast_per_game >= 5 && profile.usage_rate >= 0.20) {
    scores.secondary_creator = (profile.ast_per_game / 10) * 0.5 + (profile.usage_rate / 0.30) * 0.5;
  }
  if (profile.reb_per_game >= 7 && profile.three_pa_rate >= 0.25) {
    scores.stretch_big = (profile.reb_per_game / 12) * 0.5 + (profile.three_pa_rate / 0.40) * 0.5;
  }
  return scores;
}

function blendWeights(archetypeScores) {
  const entries = Object.entries(archetypeScores).filter(([, s]) => s >= 0.1);
  if (entries.length === 0) return { ...ARCHETYPE_WEIGHTS.default };

  const totalScore = entries.reduce((s, [, v]) => s + v, 0);
  const blended = {};
  for (const [arch, score] of entries) {
    const proportion = score / totalScore;
    const aw = ARCHETYPE_WEIGHTS[arch];
    for (const [dim, w] of Object.entries(aw)) {
      blended[dim] = (blended[dim] || 0) + w * proportion;
    }
  }
  return blended;
}

// Parlay helpers
function isSameGame(legs) {
  const gameIds = legs.map((l) => l.game_id);
  return new Set(gameIds).size === 1;
}

function structuralPenalty(legCount) {
  return legCount > 2 ? (legCount - 2) * 0.03 : 0;
}

function canComputePhi(jointCount) {
  return jointCount >= 30;
}

// Capper content formatters
function formatCapperPick(pick) {
  return `VYNDR Scan #${pick.number}: ${pick.player} ${pick.direction} ${pick.line} ${pick.stat}`;
}

function formatBreakingAlert(alert) {
  return `BREAKING: ${alert.player} — ${alert.message}`;
}

function formatDailyResults(results) {
  return results.map((r) => `${r.hit ? '✅' : '❌'} ${r.player} ${r.stat} ${r.line}`).join('\n');
}

function formatMissAutopsy(miss) {
  return `${miss.player} ${miss.stat} ${miss.line}\nWhy: ${miss.reason}`;
}

// ==========================================================
//  TESTS
// ==========================================================

// ---- 1. Resolution hit/miss ----
describe('Resolution hit/miss', () => {
  test('over hit when actual > line', () => {
    expect(resolveHit('over', 22.5, 28)).toBe(true);
  });

  test('over miss when actual < line', () => {
    expect(resolveHit('over', 22.5, 18)).toBe(false);
  });

  test('under hit when actual < line', () => {
    expect(resolveHit('under', 22.5, 18)).toBe(true);
  });

  test('under miss when actual > line', () => {
    expect(resolveHit('under', 22.5, 28)).toBe(false);
  });
});

// ---- 2. CLV calculation ----
describe('CLV calculation', () => {
  test('positive CLV when closing moves toward our direction', () => {
    const result = calculateCLV(22.5, 24.0, 'over');
    expect(result.clv).toBeGreaterThan(0);
  });

  test('negative CLV when closing moves against our direction', () => {
    const result = calculateCLV(22.5, 20.0, 'over');
    expect(result.clv).toBeLessThan(0);
  });

  test('null when no odds data', () => {
    expect(calculateCLV(null, null, 'over')).toBeNull();
  });

  test('clv_magnitude is abs(movement)', () => {
    const result = calculateCLV(22.5, 20.0, 'over');
    expect(result.clv_magnitude).toBe(2.5);
  });
});

// ---- 3. Model-market alignment ----
describe('Model-market alignment', () => {
  test('confirming when market moves with us (over)', () => {
    expect(modelMarketAlignment(22.5, 24.0, 'over')).toBe('confirming');
  });

  test('contrarian when market moves against us', () => {
    expect(modelMarketAlignment(22.5, 20.0, 'over')).toBe('contrarian');
  });

  test('null when no data', () => {
    expect(modelMarketAlignment(null, null, 'over')).toBeNull();
  });
});

// ---- 4. Joint outcome logging ----
describe('Joint outcome logging', () => {
  test('creates pair for same-game grades', () => {
    const grades = [
      { player_id: 'A', stat_type: 'pts', hit: true },
      { player_id: 'B', stat_type: 'reb', hit: false },
    ];
    const pairs = createJointOutcomes(grades);
    expect(pairs).toHaveLength(1);
    expect(pairs[0].player_a_id).toBe('A');
    expect(pairs[0].player_b_id).toBe('B');
  });

  test('skips self-pair', () => {
    const grades = [{ player_id: 'A', stat_type: 'pts', hit: true }];
    const pairs = createJointOutcomes(grades);
    expect(pairs).toHaveLength(0);
  });

  test('skips unresolved pairs', () => {
    const grades = [
      { player_id: 'A', stat_type: 'pts', hit: true },
      { player_id: 'B', stat_type: 'reb', hit: null },
    ];
    const pairs = createJointOutcomes(grades);
    expect(pairs).toHaveLength(0);
  });
});

// ---- 5. Calibration thresholds ----
describe('Calibration thresholds', () => {
  test('triggers recalibration at 25, 50, 75, 100', () => {
    expect(shouldRecalibrate(25)).toBe(true);
    expect(shouldRecalibrate(50)).toBe(true);
    expect(shouldRecalibrate(75)).toBe(true);
    expect(shouldRecalibrate(100)).toBe(true);
    expect(shouldRecalibrate(30)).toBe(false);
  });

  test('point-biserial correlation bounds 0.05–0.50', () => {
    const lower = 0.05;
    const upper = 0.50;
    const validCorrelation = 0.22;
    expect(validCorrelation).toBeGreaterThanOrEqual(lower);
    expect(validCorrelation).toBeLessThanOrEqual(upper);
    expect(0.01).toBeLessThan(lower);
    expect(0.55).toBeGreaterThan(upper);
  });

  test('global offset thresholds at 100/250/500/1000', () => {
    const thresholds = [100, 250, 500, 1000];
    expect(thresholds).toContain(100);
    expect(thresholds).toContain(250);
    expect(thresholds).toContain(500);
    expect(thresholds).toContain(1000);
  });
});

// ---- 6. Global offset clamp ----
describe('Global offset clamp', () => {
  test('clamped to 0.15 max', () => {
    expect(clampOffset(0.30)).toBe(0.15);
  });

  test('clamped to -0.15 min', () => {
    expect(clampOffset(-0.25)).toBe(-0.15);
  });
});

// ---- 7. Brier score update ----
describe('Brier score', () => {
  test('Brier = 0.0 for perfect prediction', () => {
    expect(brierScore(1.0, true)).toBeCloseTo(0.0);
  });

  test('Brier = 0.25 for coin flip', () => {
    expect(brierScore(0.5, true)).toBeCloseTo(0.25);
  });
});

// ---- 8. Blind spot detection ----
describe('Blind spot detection', () => {
  test('requires 200+ grades minimum', () => {
    const grades = Array.from({ length: 150 }, (_, i) => ({
      stat_type: 'pts', context: 'home', hit: i % 2 === 0,
    }));
    expect(detectBlindSpots(grades)).toEqual([]);
  });

  test('flags 25%+ degradation', () => {
    // 200 grades: 140 hit overall (70%), but stat_type=reb|away: 10 total, 3 hit (30%) → degradation = (0.7-0.3)/0.7 = 0.57
    const grades = [];
    for (let i = 0; i < 190; i++) {
      grades.push({ stat_type: 'pts', context: 'home', hit: i < 137 });
    }
    for (let i = 0; i < 10; i++) {
      grades.push({ stat_type: 'reb', context: 'away', hit: i < 3 });
    }
    const spots = detectBlindSpots(grades);
    expect(spots.length).toBeGreaterThanOrEqual(1);
    expect(spots[0].degradation).toBeGreaterThanOrEqual(0.25);
  });

  test('returns empty below threshold', () => {
    const grades = Array.from({ length: 199 }, () => ({
      stat_type: 'pts', context: 'home', hit: true,
    }));
    expect(detectBlindSpots(grades)).toEqual([]);
  });
});

// ---- 9. Catastrophic miss tracking ----
describe('Catastrophic miss tracking', () => {
  test('finds worst 5%', () => {
    const grades = [];
    for (let i = 0; i < 200; i++) {
      grades.push({
        hit: false,
        actual_value: 10 + i,
        prop_line: 5,
      });
    }
    const misses = catastrophicMisses(grades);
    expect(misses.length).toBe(10); // 5% of 200
  });

  test('minimum 5 misses returned', () => {
    const grades = [];
    for (let i = 0; i < 20; i++) {
      grades.push({ hit: false, actual_value: 30 + i, prop_line: 10 });
    }
    const misses = catastrophicMisses(grades);
    expect(misses.length).toBeGreaterThanOrEqual(5);
  });

  test('sorted by abs_error descending', () => {
    const grades = [
      { hit: false, actual_value: 50, prop_line: 20 },
      { hit: false, actual_value: 25, prop_line: 20 },
      { hit: false, actual_value: 40, prop_line: 20 },
    ];
    const misses = catastrophicMisses(grades);
    const errors = misses.map((m) => Math.abs(m.actual_value - m.prop_line));
    for (let i = 1; i < errors.length; i++) {
      expect(errors[i - 1]).toBeGreaterThanOrEqual(errors[i]);
    }
  });
});

// ---- 10. NBA archetype weight blending ----
describe('NBA archetype weight blending', () => {
  test('primary_scorer has matchup_defense at 0.30', () => {
    expect(ARCHETYPE_WEIGHTS.primary_scorer.matchup_defense).toBe(0.30);
  });

  test('secondary_creator has usage_context at 0.35', () => {
    expect(ARCHETYPE_WEIGHTS.secondary_creator.usage_context).toBe(0.35);
  });

  test('default weights when all archetype scores < 0.1', () => {
    const weights = blendWeights({ primary_scorer: 0.05, secondary_creator: 0.02 });
    expect(weights).toEqual(ARCHETYPE_WEIGHTS.default);
  });

  test('blending with multiple archetypes produces proportional mix', () => {
    const scores = { primary_scorer: 0.6, secondary_creator: 0.4 };
    const weights = blendWeights(scores);
    // primary proportion = 0.6, secondary = 0.4, total = 1.0
    const expectedMatchupDefense = 0.30 * 0.6 + 0.20 * 0.4;
    expect(weights.matchup_defense).toBeCloseTo(expectedMatchupDefense);
  });

  test('weights sum to ~1.0', () => {
    const scores = { primary_scorer: 0.8, secondary_creator: 0.5 };
    const weights = blendWeights(scores);
    const sum = Object.values(weights).reduce((a, b) => a + b, 0);
    expect(sum).toBeCloseTo(1.0, 2);
  });

  test('stretch_big detected from reb_per_game + three_pa_rate', () => {
    const profile = {
      pts_per_game: 14, usage_rate: 0.18, ast_per_game: 2,
      reb_per_game: 9.5, three_pa_rate: 0.32,
    };
    const archetypes = detectArchetypes(profile);
    expect(archetypes).toHaveProperty('stretch_big');
    expect(archetypes.stretch_big).toBeGreaterThan(0);
  });
});

// ---- 11. Parlay correlation ----
describe('Parlay correlation', () => {
  test('same-game detected', () => {
    const legs = [
      { game_id: 'G1', player: 'A' },
      { game_id: 'G1', player: 'B' },
    ];
    expect(isSameGame(legs)).toBe(true);
  });

  test('structural penalty 0.03 per extra leg beyond 2', () => {
    expect(structuralPenalty(2)).toBe(0);
    expect(structuralPenalty(3)).toBeCloseTo(0.03);
    expect(structuralPenalty(5)).toBeCloseTo(0.09);
  });

  test('phi requires 30+ joints', () => {
    expect(canComputePhi(29)).toBe(false);
    expect(canComputePhi(30)).toBe(true);
  });

  test('warning on 4+ legs', () => {
    const legCount = 4;
    const warning = legCount >= 4 ? 'High-leg parlay — correlation risk elevated' : null;
    expect(warning).not.toBeNull();
    expect(warning).toContain('correlation');
  });
});

// ---- 12. Capper content ----
describe('Capper content', () => {
  test('pick number increments', () => {
    const pick1 = formatCapperPick({ number: 1, player: 'Tatum', direction: 'over', line: 27.5, stat: 'pts' });
    const pick2 = formatCapperPick({ number: 2, player: 'Brown', direction: 'under', line: 5.5, stat: 'ast' });
    expect(pick1).toContain('#1');
    expect(pick2).toContain('#2');
  });

  test('breaking alert format includes BREAKING', () => {
    const alert = formatBreakingAlert({ player: 'Embiid', message: 'ruled out' });
    expect(alert).toContain('BREAKING');
  });

  test('standard format includes VYNDR Scan', () => {
    const pick = formatCapperPick({ number: 5, player: 'Jokic', direction: 'over', line: 11.5, stat: 'reb' });
    expect(pick).toContain('VYNDR Scan');
  });

  test('daily results format includes hit/miss icons', () => {
    const results = formatDailyResults([
      { hit: true, player: 'LeBron', stat: 'pts', line: 25.5 },
      { hit: false, player: 'AD', stat: 'reb', line: 10.5 },
    ]);
    expect(results).toContain('✅');
    expect(results).toContain('❌');
  });

  test('miss autopsy includes Why:', () => {
    const autopsy = formatMissAutopsy({
      player: 'Harden', stat: 'ast', line: 9.5,
      reason: 'Early foul trouble limited minutes',
    });
    expect(autopsy).toContain('Why:');
  });
});

// ---- 13. Migration 005 SQL ----
describe('Migration 005 SQL', () => {
  test('creates lineup_scheme_data', () => {
    expect(sql005).toContain('CREATE TABLE IF NOT EXISTS lineup_scheme_data');
  });

  test('has RLS', () => {
    expect(sql005).toContain('ENABLE ROW LEVEL SECURITY');
  });

  test('has indexes', () => {
    expect(sql005).toContain('CREATE INDEX IF NOT EXISTS idx_ls_team');
    expect(sql005).toContain('CREATE INDEX IF NOT EXISTS idx_ls_hash');
    expect(sql005).toContain('CREATE INDEX IF NOT EXISTS idx_ls_date');
  });
});

// ---- 14. Migration 006 SQL ----
describe('Migration 006 SQL', () => {
  test('creates grade_outcomes with discipline_score column', () => {
    expect(sql006).toContain('CREATE TABLE IF NOT EXISTS grade_outcomes');
    expect(sql006).toContain('discipline_score DECIMAL');
  });

  test('creates nba_data_cache', () => {
    expect(sql006).toContain('CREATE TABLE IF NOT EXISTS nba_data_cache');
  });

  test('creates player_calibrated_weights', () => {
    expect(sql006).toContain('CREATE TABLE IF NOT EXISTS player_calibrated_weights');
  });

  test('grade_outcomes has clv columns', () => {
    expect(sql006).toContain('clv_opening_line DECIMAL');
    expect(sql006).toContain('clv_closing_line DECIMAL');
    expect(sql006).toContain('clv_movement DECIMAL');
    expect(sql006).toContain('clv_win BOOLEAN');
  });
});

// ---- 15. Migration 007 SQL ----
describe('Migration 007 SQL', () => {
  test('creates reporter_trust with source_type', () => {
    expect(sql007).toContain('CREATE TABLE IF NOT EXISTS reporter_trust');
    expect(sql007).toContain('source_type TEXT NOT NULL');
  });

  test('creates odds_warehouse', () => {
    expect(sql007).toContain('CREATE TABLE IF NOT EXISTS odds_warehouse');
  });

  test('creates reporter_line_correlation', () => {
    expect(sql007).toContain('CREATE TABLE IF NOT EXISTS reporter_line_correlation');
  });

  test('creates ship_joint_outcomes', () => {
    expect(sql007).toContain('CREATE TABLE IF NOT EXISTS ship_joint_outcomes');
  });

  test('creates global_calibration', () => {
    expect(sql007).toContain('CREATE TABLE IF NOT EXISTS global_calibration');
  });
});
