import { convertFuturesToElo } from "~/services/probability-engine";
import { simulatorInputLabel, type SimulatorManifestProfile } from "./manifest";
import { logger } from "~/lib/logger";

export type MissingEloStrategy = "block" | "fallbackElo" | "averageKnown" | "worstKnownMinus";
export type MissingRatingStrategy = "block" | "fallbackRating" | "averageKnown" | "worstKnownMinus";

export interface SimulatorInputPolicy {
  missingEloStrategy: MissingEloStrategy;
  missingRatingStrategy: MissingRatingStrategy;
  fallbackElo: number;
  fallbackEloDelta: number;
  eloMin: number;
  eloMax: number;
  fallbackRating: number;
  fallbackRatingDelta: number;
  ratingMin: number;
  ratingMax: number;
}

export interface ParticipantInputForPolicy {
  participantId: string;
  sourceOdds: number | null;
  sourceElo: number | null;
  rating: number | null;
  projectedWins: number | null;
  projectedTablePoints: number | null;
}

export interface ResolvedSourceElo {
  participantId: string;
  sourceElo: number;
  method: "direct" | "projectedWins" | "projectedTablePoints" | "sourceOdds" | MissingEloStrategy;
}

export interface ResolvedRating {
  participantId: string;
  rating: number;
  method: "direct" | "sourceOdds" | MissingRatingStrategy;
}

const DEFAULT_POLICY: SimulatorInputPolicy = {
  missingEloStrategy: "block",
  missingRatingStrategy: "block",
  fallbackElo: 1400,
  fallbackEloDelta: 25,
  eloMin: 1100,
  eloMax: 1900,
  fallbackRating: 0,
  fallbackRatingDelta: 5,
  ratingMin: -20,
  ratingMax: 40,
};

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function optionalNumber(value: unknown, fallback: number): number {
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}

function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

function toWinRate(projection: number, maxProjection: number): number {
  if (maxProjection <= 0) return 0.5;
  return clamp(projection / maxProjection, 0.01, 0.99);
}

function projectionToElo(
  projection: number,
  maxProjection: number,
  parityFactor: number,
  policy: SimulatorInputPolicy
): number {
  const rate = toWinRate(projection, maxProjection);
  const elo = 1500 + parityFactor * Math.log10(rate / (1 - rate));
  return clamp(Math.round(elo), policy.eloMin, policy.eloMax);
}

export function getSimulatorInputPolicy(config: Record<string, unknown>): SimulatorInputPolicy {
  const rawPolicy = isRecord(config.inputPolicy) ? config.inputPolicy : {};
  const eloStrategy = rawPolicy.missingEloStrategy;
  const ratingStrategy = rawPolicy.missingRatingStrategy;

  return {
    missingEloStrategy:
      eloStrategy === "fallbackElo" || eloStrategy === "averageKnown" || eloStrategy === "worstKnownMinus"
        ? eloStrategy
        : "block",
    missingRatingStrategy:
      ratingStrategy === "fallbackRating" || ratingStrategy === "averageKnown" || ratingStrategy === "worstKnownMinus"
        ? ratingStrategy
        : "block",
    fallbackElo: optionalNumber(rawPolicy.fallbackElo, DEFAULT_POLICY.fallbackElo),
    fallbackEloDelta: optionalNumber(rawPolicy.fallbackEloDelta, DEFAULT_POLICY.fallbackEloDelta),
    eloMin: optionalNumber(rawPolicy.eloMin, DEFAULT_POLICY.eloMin),
    eloMax: optionalNumber(rawPolicy.eloMax, DEFAULT_POLICY.eloMax),
    fallbackRating: optionalNumber(rawPolicy.fallbackRating, DEFAULT_POLICY.fallbackRating),
    fallbackRatingDelta: optionalNumber(rawPolicy.fallbackRatingDelta, DEFAULT_POLICY.fallbackRatingDelta),
    ratingMin: optionalNumber(rawPolicy.ratingMin, DEFAULT_POLICY.ratingMin),
    ratingMax: optionalNumber(rawPolicy.ratingMax, DEFAULT_POLICY.ratingMax),
  };
}

export function sourceEloRequirementLabel(profile: SimulatorManifestProfile): string {
  const alternatives = profile.derivableInputs?.sourceElo ?? [];
  // Omit "configured fallback" from the label: if a participant appears in
  // missingInputs it means no fallback resolved it, so mentioning fallback
  // as an option would be misleading.
  return ["Elo rating", ...alternatives.map(simulatorInputLabel)].join(" / ");
}

export function ratingRequirementLabel(profile: SimulatorManifestProfile): string {
  const alternatives = profile.derivableInputs?.rating ?? [];
  return ["rating", ...alternatives.map(simulatorInputLabel)].join(" / ");
}

export function resolveSourceElos(
  inputs: ParticipantInputForPolicy[],
  profile: Pick<SimulatorManifestProfile, "derivableInputs">,
  config: Record<string, unknown>
): Map<string, ResolvedSourceElo> {
  const policy = getSimulatorInputPolicy(config);
  const resolved = new Map<string, ResolvedSourceElo>();
  const alternatives = new Set(profile.derivableInputs?.sourceElo ?? []);
  const parityFactor = optionalNumber(config.parityFactor, 400);

  for (const input of inputs) {
    if (input.sourceElo !== null && input.sourceElo !== undefined) {
      resolved.set(input.participantId, {
        participantId: input.participantId,
        sourceElo: input.sourceElo,
        method: "direct",
      });
    }
  }

  if (alternatives.has("projectedWins")) {
    const seasonGames = optionalNumber(config.seasonGames, Math.max(...inputs.map((input) => input.projectedWins ?? 0), 1));
    for (const input of inputs) {
      if (resolved.has(input.participantId) || input.projectedWins === null || input.projectedWins === undefined) continue;
      resolved.set(input.participantId, {
        participantId: input.participantId,
        sourceElo: projectionToElo(input.projectedWins, seasonGames, parityFactor, policy),
        method: "projectedWins",
      });
    }
  }

  if (alternatives.has("projectedTablePoints")) {
    const seasonGames = optionalNumber(config.seasonGames, 38);
    const maxPoints = optionalNumber(config.maxTablePoints, seasonGames * 3);
    for (const input of inputs) {
      if (resolved.has(input.participantId) || input.projectedTablePoints === null || input.projectedTablePoints === undefined) continue;
      resolved.set(input.participantId, {
        participantId: input.participantId,
        sourceElo: projectionToElo(input.projectedTablePoints, maxPoints, parityFactor, policy),
        method: "projectedTablePoints",
      });
    }
  }

  if (alternatives.has("sourceOdds")) {
    const oddsInputs = inputs
      .filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined)
      .map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
    const converted = convertFuturesToElo(oddsInputs);
    for (const [participantId, elo] of converted) {
      resolved.set(participantId, {
        participantId,
        sourceElo: clamp(Math.round(elo), policy.eloMin, policy.eloMax),
        method: "sourceOdds",
      });
    }
  }

  const knownElos = [...resolved.values()].map((value) => value.sourceElo);
  const averageKnown = knownElos.length > 0
    ? knownElos.reduce((sum, elo) => sum + elo, 0) / knownElos.length
    : policy.fallbackElo;
  const worstKnown = knownElos.length > 0 ? Math.min(...knownElos) : policy.fallbackElo;

  for (const input of inputs) {
    if (resolved.has(input.participantId) || policy.missingEloStrategy === "block") continue;

    let sourceElo: number;
    if (policy.missingEloStrategy === "averageKnown") {
      sourceElo = averageKnown;
    } else if (policy.missingEloStrategy === "worstKnownMinus") {
      sourceElo = worstKnown - policy.fallbackEloDelta;
    } else {
      sourceElo = policy.fallbackElo;
    }

    resolved.set(input.participantId, {
      participantId: input.participantId,
      sourceElo: clamp(Math.round(sourceElo), policy.eloMin, policy.eloMax),
      method: policy.missingEloStrategy,
    });
  }

  return resolved;
}

export function resolveRatings(
  inputs: ParticipantInputForPolicy[],
  profile: Pick<SimulatorManifestProfile, "derivableInputs">,
  config: Record<string, unknown>
): Map<string, ResolvedRating> {
  const policy = getSimulatorInputPolicy(config);
  const resolved = new Map<string, ResolvedRating>();
  const alternatives = new Set(profile.derivableInputs?.rating ?? []);

  for (const input of inputs) {
    if (input.rating !== null && input.rating !== undefined) {
      resolved.set(input.participantId, {
        participantId: input.participantId,
        rating: input.rating,
        method: "direct",
      });
    }
  }

  if (alternatives.has("sourceOdds")) {
    const oddsInputs = inputs
      .filter((input) => !resolved.has(input.participantId) && input.sourceOdds !== null && input.sourceOdds !== undefined)
      .map((input) => ({ participantId: input.participantId, odds: input.sourceOdds as number }));
    if (oddsInputs.length === 1) {
      logger.warn(
        `resolveRatings: only 1 participant has sourceOdds (${oddsInputs[0].participantId}). ` +
        `convertFuturesToElo requires at least 2 to derive a meaningful rating — falling through to missing-rating strategy.`
      );
    }
    if (oddsInputs.length >= 2) {
      const converted = convertFuturesToElo(oddsInputs, "american", {
        exponent: 0.33,
        eloMin: policy.ratingMin,
        eloMax: policy.ratingMax,
      });
      for (const [participantId, rating] of converted) {
        resolved.set(participantId, {
          participantId,
          rating: clamp(rating, policy.ratingMin, policy.ratingMax),
          method: "sourceOdds",
        });
      }
    }
  }

  const knownRatings = [...resolved.values()].map((value) => value.rating);
  const averageKnown = knownRatings.length > 0
    ? knownRatings.reduce((sum, rating) => sum + rating, 0) / knownRatings.length
    : policy.fallbackRating;
  const worstKnown = knownRatings.length > 0 ? Math.min(...knownRatings) : policy.fallbackRating;

  for (const input of inputs) {
    if (resolved.has(input.participantId) || policy.missingRatingStrategy === "block") continue;

    let rating: number | null;
    if (policy.missingRatingStrategy === "averageKnown") {
      rating = averageKnown;
    } else if (policy.missingRatingStrategy === "worstKnownMinus") {
      rating = knownRatings.length > 0 ? worstKnown - policy.fallbackRatingDelta : null;
    } else {
      rating = policy.fallbackRating;
    }

    if (rating === null) continue;

    resolved.set(input.participantId, {
      participantId: input.participantId,
      rating: clamp(rating, policy.ratingMin, policy.ratingMax),
      method: policy.missingRatingStrategy,
    });
  }

  return resolved;
}
