/**
 * Auto Racing Season Standings Simulator
 *
 * Generic simulator for points-based auto racing championships (F1, IndyCar, etc.).
 * The race points table is injected at construction time so different series can
 * use their own scoring systems.
 *
 * Algorithm:
 *   1. Load participants + current championship points from DB
 *   2. Count remaining races (incomplete non-schedule scoring events)
 *   3. Convert sourceOdds → vig-removed probability weights
 *   4. Two simulation paths:
 *      a. remainingRaces === 0 (pre-season): pure weighted draws from odds
 *      b. remainingRaces > 0 (in-season): simulate each remaining race,
 *         starting from real standings, awarding series-specific points per finish
 *   5. Convert finish counts → probability distributions + normalize columns
 *
 * Notes:
 *   - Drivers without odds fall back to uniform probability (1/N)
 *   - PARTICIPANT_VOLATILITY and RACE_NOISE only apply to the in-season path
 */

import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
import { getSeasonResults } from "~/models/participant-season-result";
import type { Simulator, SimulationResult } from "./types";

// ─── Simulation parameters (mirrors Python constants) ────────────────────────

const NUM_SIMULATIONS = 10000;

/** Per-race performance variance. 0 = no noise, 1 = fully random each race. */
const RACE_NOISE = 0.50;

/**
 * Season-long multiplier range per driver.
 * Each driver gets uniform(1 - V, 1 + V) applied to their base probability
 * for the entire season, capturing "cars that over/underperform expectations".
 */
const PARTICIPANT_VOLATILITY = 1.5;

/**
 * Optional smoothing toward the mean after vig removal.
 * 0.0 = use vig-removed market odds exactly (recommended).
 * Increase slightly (e.g. 0.1) to soften extreme probabilities.
 */
const UNCERTAINTY_FACTOR = 0.0;

/** Lookup points for a finishing position; returns 0 for unscored positions. */
function getRacePoints(racePoints: Record<number, number>, position: number): number {
  return racePoints[position] ?? 0;
}

// ─── Odds helpers ─────────────────────────────────────────────────────────────

/** Convert American odds to implied probability (no vig removal). */
function americanToImpliedProb(americanOdds: number): number {
  if (americanOdds > 0) {
    return 100 / (americanOdds + 100);
  }
  return Math.abs(americanOdds) / (Math.abs(americanOdds) + 100);
}

// ─── Core simulation helper ───────────────────────────────────────────────────

/**
 * Weighted sequential draw without replacement.
 * Returns all items in a simulated finishing order.
 * Each draw is proportional to remaining weights.
 */
function weightedDrawWithoutReplacement(ids: string[], weights: number[]): string[] {
  const pool = ids.slice();
  const w = weights.slice();
  const result: string[] = [];

  while (pool.length > 0) {
    const total = w.reduce((s, v) => s + v, 0);
    let r = Math.random() * total;
    let idx = 0;
    while (idx < w.length - 1 && r > w[idx]) {
      r -= w[idx];
      idx++;
    }
    result.push(pool[idx]);
    pool.splice(idx, 1);
    w.splice(idx, 1);
  }

  return result;
}

// ─── Simulator ────────────────────────────────────────────────────────────────

export class AutoRacingSimulator implements Simulator {
  constructor(
    private readonly racePoints: Record<number, number>,
    private readonly source: string,
  ) {}

  async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
    const db = database();

    // 1. Load all participants for this sports season
    const participants = await db.query.participants.findMany({
      where: eq(schema.participants.sportsSeasonId, sportsSeasonId),
    });

    if (participants.length === 0) {
      throw new Error(`No participants found for sports season ${sportsSeasonId}.`);
    }

    // 2. Load current championship standings (existing points earned this season)
    const seasonResults = await getSeasonResults(sportsSeasonId);
    const currentPointsMap = new Map<string, number>(
      seasonResults.map((r) => [r.participant.id, parseFloat(r.currentPoints ?? "0")])
    );

    // 3. Count remaining races: incomplete scoring events, excluding schedule_event entries
    const allEvents = await db.query.scoringEvents.findMany({
      where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
    });
    const remainingRaces = allEvents.filter(
      (e) => !e.isComplete && e.eventType !== "schedule_event"
    ).length;

    // 4. Load EV data for championship win probabilities
    const evs = await getAllParticipantEVsForSeason(sportsSeasonId);
    const evMap = new Map(evs.map((ev) => [ev.participantId, ev]));

    const ids = participants.map((p) => p.id);

    // 5. Build raw implied championship win probabilities from odds.
    // americanToImpliedProb includes vig (sum > 1.0), so we normalize to sum = 1.0
    // before using as weights. This is standard "vig removal" and ensures a driver
    // with -200 odds (~66.7% implied) gets ~55% weight when the total vig is ~1.2.
    const fallbackProb = 1 / participants.length;
    const rawProbs = new Map<string, number>();

    for (const p of participants) {
      const ev = evMap.get(p.id);
      rawProbs.set(p.id, ev !== undefined && ev.sourceOdds !== null && ev.sourceOdds !== undefined ? americanToImpliedProb(ev.sourceOdds) : fallbackProb);
    }

    // Normalize to remove vig
    const rawSum = [...rawProbs.values()].reduce((a, b) => a + b, 0);
    for (const [id, prob] of rawProbs) {
      rawProbs.set(id, prob / rawSum);
    }

    // 6. Optionally smooth toward the mean (no-op when UNCERTAINTY_FACTOR = 0)
    const baseProbs = new Map<string, number>();
    if (UNCERTAINTY_FACTOR === 0) {
      for (const [id, prob] of rawProbs) baseProbs.set(id, prob);
    } else {
      const avgProb = [...rawProbs.values()].reduce((a, b) => a + b, 0) / participants.length;
      for (const [id, prob] of rawProbs) {
        baseProbs.set(id, prob * (1 - UNCERTAINTY_FACTOR) + avgProb * UNCERTAINTY_FACTOR);
      }
    }

    // 7. Accumulate finish counts across simulations
    // rankCounts[id][0..7] = number of times driver finished 1st..8th
    const rankCounts = new Map<string, number[]>();
    for (const id of ids) {
      rankCounts.set(id, Array.from({ length: 8 }, () => 0));
    }

    if (remainingRaces === 0) {
      // Pre-season: no races to simulate, derive placement probabilities
      // from sourceOdds via pure weighted draws.
      const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb);
      for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
        const finishOrder = weightedDrawWithoutReplacement(ids, weights);
        for (let rank = 0; rank < Math.min(8, finishOrder.length); rank++) {
          const counts = rankCounts.get(finishOrder[rank]);
          if (counts) counts[rank]++;
        }
      }
    } else {
      // In-season: simulate remaining races from current standings.
      for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
        // 7a. Season-long performance multiplier per driver
        const seasonWeights = new Map<string, number>();
        for (const id of ids) {
          const base = baseProbs.get(id) ?? fallbackProb;
          const mult = Math.max(
            0.05,
            1 - PARTICIPANT_VOLATILITY + Math.random() * PARTICIPANT_VOLATILITY * 2
          );
          seasonWeights.set(id, base * mult);
        }

        // 7b. Start from current championship points
        const simPoints = new Map<string, number>(
          ids.map((id) => [id, currentPointsMap.get(id) ?? 0])
        );

        // 7c. Simulate each remaining race
        for (let race = 0; race < remainingRaces; race++) {
          const raceWeights = ids.map((id) => {
            const sw = seasonWeights.get(id) ?? fallbackProb;
            const noise = Math.max(0.01, 1 - RACE_NOISE + Math.random() * RACE_NOISE * 2);
            return sw * noise;
          });

          const finishOrder = weightedDrawWithoutReplacement(ids, raceWeights);

          for (let pos = 0; pos < finishOrder.length; pos++) {
            const pts = getRacePoints(this.racePoints, pos + 1);
            if (pts === 0) break; // unscored positions earn no points
            simPoints.set(finishOrder[pos], (simPoints.get(finishOrder[pos]) ?? 0) + pts);
          }
        }

        // 7d. Sort by final championship points, record top-8 finishes
        const finalOrder = [...simPoints.entries()]
          .toSorted((a, b) => b[1] - a[1])
          .map(([id]) => id);

        for (let rank = 0; rank < Math.min(8, finalOrder.length); rank++) {
          const counts = rankCounts.get(finalOrder[rank]);
          if (counts) counts[rank]++;
        }
      }
    }

    // 8. Convert counts → probability distributions
    const results: SimulationResult[] = participants.map((p) => {
      const counts = rankCounts.get(p.id) ?? [0,0,0,0,0,0,0,0];
      return {
        participantId: p.id,
        probabilities: {
          probFirst:   counts[0] / NUM_SIMULATIONS,
          probSecond:  counts[1] / NUM_SIMULATIONS,
          probThird:   counts[2] / NUM_SIMULATIONS,
          probFourth:  counts[3] / NUM_SIMULATIONS,
          probFifth:   counts[4] / NUM_SIMULATIONS,
          probSixth:   counts[5] / NUM_SIMULATIONS,
          probSeventh: counts[6] / NUM_SIMULATIONS,
          probEighth:  counts[7] / NUM_SIMULATIONS,
        },
        source: this.source,
      };
    });

    // 9. Per-position normalization: each column should sum to exactly 1.0 but
    // floating-point division (count / 10000) accumulates small errors across
    // ~20 drivers, causing the total EV to drift (e.g. 340.02 instead of 340).
    // Fix: add the residual (1.0 - colSum) to the largest probability in each
    // column so the sum is exactly 1.0 in IEEE 754 arithmetic.
    const positionKeys: Array<keyof typeof results[0]["probabilities"]> = [
      "probFirst", "probSecond", "probThird", "probFourth",
      "probFifth", "probSixth", "probSeventh", "probEighth",
    ];
    for (const key of positionKeys) {
      const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
      const residual = 1.0 - colSum;
      if (residual !== 0) {
        const maxResult = results.reduce((best, r) =>
          r.probabilities[key] > best.probabilities[key] ? r : best
        );
        maxResult.probabilities[key] += residual;
      }
    }

    return results;
  }
}
