import 'dotenv/config';
import pool from '../db';
import {
  extractMlbDirectFeatures,
  MLB_RUN_LINE_MODEL_LIVE_CONFIG,
  type MlbDirectMarketContext,
  type MlbRunLineModelConfig,
  scoreMlbRunLineModel,
} from '../services/mlb-team-direct-model';
import { getTeamAbbr, normalizeTeamNameKey } from '../lib/team-abbreviations';
import type { SignalResult } from '../services/rie/types';

type BacktestRow = {
  eventId: string;
  homeTeam: string;
  awayTeam: string;
  homeShort: string;
  awayShort: string;
  startsAt: string | Date;
  homeScore: number;
  awayScore: number;
  rieSignals: SignalResult[];
  oddsData: any;
};

type MarketHistoryRow = {
  gameDay: string;
  homeTeam: string | null;
  awayTeam: string | null;
  marketType: string | null;
  openLine: number | null;
  currentLine: number | null;
  closingLine: number | null;
  lineMovement: number | null;
  movementDirection: string | null;
  steamMove: boolean | null;
  reverseLineMove: boolean | null;
  recordedAt: string | Date | null;
};

type BookSnapshotRow = {
  gameDay: string;
  homeTeam: string | null;
  awayTeam: string | null;
  bookmaker: string | null;
  market: string | null;
  lineValue: number | null;
  homeOdds: number | null;
  awayOdds: number | null;
  overOdds: number | null;
  underOdds: number | null;
  openingLineValue: number | null;
  openingHomeOdds: number | null;
  openingAwayOdds: number | null;
  openingOverOdds: number | null;
  openingUnderOdds: number | null;
  fetchedAt: string | Date | null;
};

type Summary = {
  picks: number;
  wins: number;
  losses: number;
  pushes: number;
  wr: number;
};

type RunLineCandidate = MlbRunLineModelConfig & {
  minAbsAtsEdge: number;
};

function round3(value: number): number { return Math.round(value * 1000) / 1000; }
function average(values: number[]): number | null { const usable = values.filter((value) => Number.isFinite(value)); if (usable.length === 0) return null; return usable.reduce((sum, value) => sum + value, 0) / usable.length; }
function stddev(values: number[]): number { const usable = values.filter((value) => Number.isFinite(value)); if (usable.length < 2) return 0; const mean = usable.reduce((sum, value) => sum + value, 0) / usable.length; const variance = usable.reduce((sum, value) => sum + ((value - mean) ** 2), 0) / usable.length; return Math.sqrt(variance); }
function range(values: number[]): number { const usable = values.filter((value) => Number.isFinite(value)); if (usable.length < 2) return 0; return Math.max(...usable) - Math.min(...usable); }
function americanToProbability(odds: number | null | undefined): number | null { if (odds == null || !Number.isFinite(odds) || odds === 0) return null; return odds > 0 ? 100 / (odds + 100) : (-odds) / ((-odds) + 100); }
function timestampMs(value: string | Date | null | undefined): number | null { if (value instanceof Date) return value.getTime(); if (typeof value === 'string' && value) { const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : null; } return null; }
function toIsoDateKey(value: string | Date): string { if (value instanceof Date) return value.toISOString().slice(0, 10); return String(value || '').slice(0, 10); }
function candidateGameDateKeys(value: string | Date): string[] { const rawMs = timestampMs(value); if (rawMs == null) { const key = toIsoDateKey(value); return key ? [key] : []; } const sameDay = new Date(rawMs); const priorDay = new Date(rawMs - (24 * 60 * 60 * 1000)); return [...new Set([sameDay.toISOString().slice(0, 10), priorDay.toISOString().slice(0, 10)])]; }
function createGameDateMap<T extends { gameDay: string }>(rows: T[]): Map<string, T[]> { const map = new Map<string, T[]>(); for (const row of rows) { const bucket = map.get(row.gameDay); if (bucket) bucket.push(row); else map.set(row.gameDay, [row]); } return map; }
function buildExactKeys(params: { teamName: string | null | undefined; teamShort?: string | null | undefined }): string[] { const keys = new Set<string>(); for (const value of [params.teamName, params.teamShort, getTeamAbbr(params.teamName || null) || null]) { const key = normalizeTeamNameKey(value); if (key.length >= 2) keys.add(key); } return [...keys]; }
function matchesExactGame(row: { homeTeam: string | null; awayTeam: string | null }, homeKeys: string[], awayKeys: string[]): boolean { const homeKey = normalizeTeamNameKey(row.homeTeam); const awayKey = normalizeTeamNameKey(row.awayTeam); return homeKeys.includes(homeKey) && awayKeys.includes(awayKey); }
function splitTrainTest<T>(rows: T[]): { train: T[]; validation: T[]; test: T[] } { const trainCut = Math.max(1, Math.floor(rows.length * 0.5)); const validationCut = Math.max(trainCut + 1, Math.floor(rows.length * 0.7)); return { train: rows.slice(0, trainCut), validation: rows.slice(trainCut, validationCut), test: rows.slice(validationCut) }; }
function summarize(rows: Array<'win' | 'loss' | 'push'>): Summary { const wins = rows.filter((row) => row === 'win').length; const losses = rows.filter((row) => row === 'loss').length; const pushes = rows.filter((row) => row === 'push').length; const picks = rows.length; const graded = wins + losses; return { picks, wins, losses, pushes, wr: graded > 0 ? wins / graded : 0 }; }
function compareSummary(left: Summary, right: Summary): number { if (left.wr !== right.wr) return left.wr - right.wr; return left.wins - right.wins; }
function compareRuns(left: { summary: Summary }, right: { summary: Summary }): number { const cmp = compareSummary(left.summary, right.summary); if (cmp !== 0) return cmp; return left.summary.picks - right.summary.picks; }
function* buildCandidates(): Generator<RunLineCandidate> {
  for (const starterWeight of [0.8, 1.0, 1.2]) {
    for (const bullpenWeight of [0.35, 0.5, 0.7]) {
      for (const offenseWeight of [0.6, 0.9, 1.2]) {
        for (const phaseWeight of [0.5, 0.7, 0.9]) {
          for (const contextWeight of [0, 0.15]) {
            for (const marketWeight of [0.2, 0.35, 0.5]) {
              for (const marginScale of [2.2, 2.8, 3.4]) {
                for (const minAbsAtsEdge of [0.1, 0.2, 0.3, 0.4]) {
                  yield {
                    starterWeight,
                    bullpenWeight,
                    offenseWeight,
                    phaseWeight,
                    contextWeight,
                    marketWeight,
                    marginScale,
                    minAbsAtsEdge,
                  };
                }
              }
            }
          }
        }
      }
    }
  }
}

async function loadRows(): Promise<BacktestRow[]> {
  const { rows } = await pool.query(`SELECT fa.event_id, fa.home_score, fa.away_score, fc.home_team, fc.away_team, fc.starts_at, fc.odds_data, ev.home_short, ev.away_short, fc.model_signals FROM rm_forecast_accuracy_v2 fa JOIN rm_forecast_cache fc ON fc.event_id = fa.event_id LEFT JOIN rm_events ev ON ev.event_id = fa.event_id WHERE fa.model_version = 'rm_2.0' AND fa.forecast_type = 'spread' AND fa.league = 'mlb' AND fa.resolved_at >= NOW() - INTERVAL '120 days' AND fa.home_score IS NOT NULL AND fa.away_score IS NOT NULL AND fc.model_signals ? 'rieSignals' ORDER BY fa.resolved_at ASC`);
  return rows.map((row: any) => ({ eventId: row.event_id, homeTeam: row.home_team, awayTeam: row.away_team, homeShort: row.home_short || '', awayShort: row.away_short || '', startsAt: row.starts_at, homeScore: Number(row.home_score), awayScore: Number(row.away_score), rieSignals: Array.isArray(row.model_signals?.rieSignals) ? row.model_signals.rieSignals : [], oddsData: row.odds_data || {} })).filter((row) => row.homeShort && row.awayShort);
}
async function loadPrefetchedMarketData(dateKeys: string[]): Promise<{ movementByDate: Map<string, MarketHistoryRow[]>; booksByDate: Map<string, BookSnapshotRow[]>; }> {
  const [movementRes, booksRes] = await Promise.all([
    pool.query(`SELECT DATE("gameDate")::text AS "gameDay", "homeTeam" AS "homeTeam", "awayTeam" AS "awayTeam", "marketType", "openLine", "currentLine", "closingLine", "lineMovement", "movementDirection", "steamMove", "reverseLineMove", "recordedAt" FROM "LineMovement" WHERE league='mlb' AND DATE("gameDate") = ANY($1::date[])`, [dateKeys]),
    pool.query(`SELECT DATE("gameDate")::text AS "gameDay", "homeTeam" AS "homeTeam", "awayTeam" AS "awayTeam", bookmaker, market, "lineValue", "homeOdds", "awayOdds", "overOdds", "underOdds", "openingLineValue", "openingHomeOdds", "openingAwayOdds", "openingOverOdds", "openingUnderOdds", "fetchedAt" FROM "GameOdds" WHERE league='mlb' AND DATE("gameDate") = ANY($1::date[])`, [dateKeys]),
  ]);
  return { movementByDate: createGameDateMap(movementRes.rows as MarketHistoryRow[]), booksByDate: createGameDateMap(booksRes.rows as BookSnapshotRow[]) };
}
function fetchHistoricalMarketContext(row: BacktestRow, prefetched: { movementByDate: Map<string, MarketHistoryRow[]>; booksByDate: Map<string, BookSnapshotRow[]>; }): MlbDirectMarketContext {
  const homeKeys = buildExactKeys({ teamName: row.homeTeam, teamShort: row.homeShort });
  const awayKeys = buildExactKeys({ teamName: row.awayTeam, teamShort: row.awayShort });
  const gameDays = candidateGameDateKeys(row.startsAt);
  const startsAtMs = timestampMs(row.startsAt) ?? Number.POSITIVE_INFINITY;
  const movementRows = gameDays.flatMap((gameDay) => prefetched.movementByDate.get(gameDay) || []).filter((entry) => matchesExactGame(entry, homeKeys, awayKeys)).sort((left, right) => (timestampMs(right.recordedAt) ?? 0) - (timestampMs(left.recordedAt) ?? 0));
  const exactBookRows = gameDays.flatMap((gameDay) => prefetched.booksByDate.get(gameDay) || []).filter((entry) => matchesExactGame(entry, homeKeys, awayKeys));
  const latestBookRows = new Map<string, BookSnapshotRow>();
  const openerFallbackRows = new Map<string, BookSnapshotRow>();
  for (const entry of exactBookRows) {
    const bucketKey = `${String(entry.bookmaker || '')}:${String(entry.market || '')}`;
    const fetchedAtMs = timestampMs(entry.fetchedAt);
    if (fetchedAtMs != null && fetchedAtMs <= startsAtMs) { const existing = latestBookRows.get(bucketKey); if (!existing || (timestampMs(existing.fetchedAt) ?? 0) < fetchedAtMs) latestBookRows.set(bucketKey, entry); continue; }
    const hasOpening = [entry.openingLineValue, entry.openingHomeOdds, entry.openingAwayOdds, entry.openingOverOdds, entry.openingUnderOdds].some((value) => value != null && Number.isFinite(Number(value)));
    if (!hasOpening) continue;
    const existingFallback = openerFallbackRows.get(bucketKey);
    if (!existingFallback || (timestampMs(existingFallback.fetchedAt) ?? Number.POSITIVE_INFINITY) > (fetchedAtMs ?? Number.POSITIVE_INFINITY)) openerFallbackRows.set(bucketKey, entry);
  }
  const bookRows = [...latestBookRows.values()];
  for (const [bucketKey, fallback] of openerFallbackRows.entries()) {
    if (latestBookRows.has(bucketKey)) continue;
    bookRows.push({ ...fallback, lineValue: fallback.openingLineValue ?? fallback.lineValue, homeOdds: fallback.openingHomeOdds ?? fallback.homeOdds, awayOdds: fallback.openingAwayOdds ?? fallback.awayOdds, overOdds: fallback.openingOverOdds ?? fallback.overOdds, underOdds: fallback.openingUnderOdds ?? fallback.underOdds, fetchedAt: null });
  }
  const spreadMovement = movementRows.find((entry) => String(entry.marketType || '').toUpperCase() === 'SPREAD') || null;
  let marketMoveEdge = 0; let marketMoveMagnitude = 0;
  if (spreadMovement?.openLine != null && (spreadMovement.closingLine != null || spreadMovement.currentLine != null)) { const terminalLine = Number(spreadMovement.closingLine ?? spreadMovement.currentLine); const deltaTowardHome = Number(spreadMovement.openLine) - terminalLine; marketMoveEdge = Math.max(-0.25, Math.min(0.25, deltaTowardHome / 1.5)); marketMoveMagnitude = Math.abs(Number(spreadMovement.lineMovement ?? deltaTowardHome)); }
  const moneylineRows = bookRows.filter((entry) => ['moneyline', 'h2h'].includes(String(entry.market || '').toLowerCase()));
  const homeProbSamples = moneylineRows.map((entry) => { const homeProb = americanToProbability(entry.homeOdds); const awayProb = americanToProbability(entry.awayOdds); if (homeProb == null || awayProb == null) return null; const vig = homeProb + awayProb; return vig > 0 ? homeProb / vig : homeProb; }).filter((value): value is number => value != null);
  const spreadRows = bookRows.filter((entry) => ['spread', 'spreads'].includes(String(entry.market || '').toLowerCase()));
  const spreadLines = spreadRows.map((entry) => Number(entry.lineValue ?? null)).filter((value) => Number.isFinite(value));
  const totalRows = bookRows.filter((entry) => ['total', 'totals'].includes(String(entry.market || '').toLowerCase()));
  const totalLines = totalRows.map((entry) => Number(entry.lineValue ?? null)).filter((value) => Number.isFinite(value));
  return { marketHomeProb: average(homeProbSamples), marketSpreadHome: average(spreadLines), marketTotal: average(totalLines), marketMoveEdge: round3(marketMoveEdge), marketMoveMagnitude: round3(marketMoveMagnitude), reverseLineMove: movementRows.some((entry) => Boolean(entry.reverseLineMove)), steamMove: movementRows.some((entry) => Boolean(entry.steamMove)), movementRowCount: movementRows.length, bookHomeProbStddev: round3(stddev(homeProbSamples)), bookHomeProbRange: round3(range(homeProbSamples)), bookHomeProbSampleCount: homeProbSamples.length, totalLineRange: round3(range(totalLines)), totalLineSampleCount: totalLines.length };
}
function gradeRunLine(row: BacktestRow, side: 'home' | 'away', line: number | null): 'win' | 'loss' | 'push' | null {
  if (line == null || !Number.isFinite(line)) return null;
  const actualMargin = side === 'home' ? (row.homeScore - row.awayScore) : (row.awayScore - row.homeScore);
  const ats = actualMargin + line;
  if (ats > 0) return 'win';
  if (ats < 0) return 'loss';
  return 'push';
}
function runCandidate(rows: Array<BacktestRow & { marketContext: MlbDirectMarketContext; featureVector: ReturnType<typeof extractMlbDirectFeatures> }>, candidate: RunLineCandidate) {
  const outcomes: Array<'win' | 'loss' | 'push'> = [];
  for (const row of rows) {
    const decision = scoreMlbRunLineModel({ homeTeam: row.homeTeam, awayTeam: row.awayTeam, featureVector: row.featureVector, config: candidate });
    if (!decision.available || Math.abs(decision.atsEdge) < candidate.minAbsAtsEdge) continue;
    const outcome = gradeRunLine(row, decision.side, decision.line);
    if (!outcome) continue;
    outcomes.push(outcome);
  }
  return { summary: summarize(outcomes) };
}
async function main(): Promise<void> {
  const rows = await loadRows();
  const split = splitTrainTest(rows);
  const dateKeys = [...new Set(rows.flatMap((row) => candidateGameDateKeys(row.startsAt)))].sort();
  const prefetched = await loadPrefetchedMarketData(dateKeys);
  const allRows = rows.map((row) => { const marketContext = fetchHistoricalMarketContext(row, prefetched); return { ...row, marketContext, featureVector: extractMlbDirectFeatures({ signals: row.rieSignals, oddsData: row.oddsData, marketContext }) }; });
  const validationRows = allRows.slice(split.train.length, split.train.length + split.validation.length);
  const testRows = allRows.slice(split.train.length + split.validation.length);
  const liveRun = runCandidate(testRows, { ...MLB_RUN_LINE_MODEL_LIVE_CONFIG, minAbsAtsEdge: 0.2 });
  let bestCandidate: RunLineCandidate | null = null;
  let bestValidation = { summary: summarize([]) };
  let bestTest = { summary: summarize([]) };
  for (const candidate of buildCandidates()) {
    const validationRun = runCandidate(validationRows, candidate);
    if (validationRun.summary.picks < 6) continue;
    if (!bestCandidate || compareRuns(validationRun, bestValidation) > 0) { bestCandidate = candidate; bestValidation = validationRun; bestTest = runCandidate(testRows, candidate); }
  }
  console.log('=== MLB RUN LINE BACKTEST ===');
  console.log(`rows=${rows.length} train=${split.train.length} validation=${split.validation.length} test=${split.test.length}`);
  console.log(`live_run_line test picks=${liveRun.summary.picks} wins=${liveRun.summary.wins} losses=${liveRun.summary.losses} pushes=${liveRun.summary.pushes} wr=${(liveRun.summary.wr*100).toFixed(2)}%`);
  if (bestCandidate) {
    console.log(`best_run_line validation picks=${bestValidation.summary.picks} wins=${bestValidation.summary.wins} losses=${bestValidation.summary.losses} pushes=${bestValidation.summary.pushes} wr=${(bestValidation.summary.wr*100).toFixed(2)}%`);
    console.log(`best_run_line test picks=${bestTest.summary.picks} wins=${bestTest.summary.wins} losses=${bestTest.summary.losses} pushes=${bestTest.summary.pushes} wr=${(bestTest.summary.wr*100).toFixed(2)}%`);
    console.log(`best_run_line config=${JSON.stringify(bestCandidate)}`);
  } else { console.log('best_run_line none'); }
}
main().catch(async (error) => { console.error(error); await pool.end(); process.exit(1); }).finally(async () => { await pool.end(); });
