import {
  lambdasFromElo,
  scoreMatrix,
  outcomeProbs,
  topScorelines,
  type ModelParams,
  type OutcomeProbs,
  type Scoreline,
} from './poisson';
import { teamDcLambdas, poolMatrices, type TeamDcParams } from './teamDc';

/** The ratings.json payload (also the runtime model state after live re-rating). */
export interface RatingsModel {
  asOf: string;
  params: ModelParams;
  ratings: Record<string, number>;
  /** Team-level Dixon-Coles strengths (second ensemble member); optional so a
   *  v2 ratings file still works as pure Elo-DC. */
  teamDc?: TeamDcParams;
  /** Log-pool weight on the Elo-DC member (backtest-tuned). */
  ensembleW?: number;
  /** Fast Elo member — the same walk at K×fastM, so recent results move it
   *  harder. Blended into the Elo member as (1−β)·slow + β·fast. All optional:
   *  an older ratings file behaves exactly as before. */
  ratingsFast?: Record<string, number>;
  fastBeta?: number;
  fastM?: number;
  /** Team-DC fit hyper-params, shipped so the server can refit in-tournament. */
  teamDcFit?: { xi: number; shrinkK: number; windowYears: number };
  /** Isotonic outcome calibration (fit walk-forward on the CV folds, validated
   *  leave-one-fold-out). Applied by scaling the matrix's outcome regions, so
   *  scorelines, simulations and displayed probabilities stay consistent. */
  calibration?: Calibration;
}

export interface IsoMap {
  x: number[];
  y: number[];
}

export interface Calibration {
  home: IsoMap;
  draw: IsoMap;
  away: IsoMap;
}

function isoApply(map: IsoMap, p: number): number {
  const { x, y } = map;
  if (p <= x[0]!) return y[0]!;
  for (let i = 1; i < x.length; i++) {
    if (p <= x[i]!) {
      const f = (p - x[i - 1]!) / (x[i]! - x[i - 1]!);
      return y[i - 1]! + f * (y[i]! - y[i - 1]!);
    }
  }
  return y[y.length - 1]!;
}

/** Scale the matrix's home/draw/away regions to the calibrated outcome masses.
 *  Scoreline shapes inside each region are preserved. */
function calibrateMatrix(m: number[][], cal: Calibration): number[][] {
  const p = outcomeProbs(m);
  const h = Math.max(1e-6, isoApply(cal.home, p.home));
  const d = Math.max(1e-6, isoApply(cal.draw, p.draw));
  const a = Math.max(1e-6, isoApply(cal.away, p.away));
  const z = h + d + a;
  const fh = p.home > 1e-12 ? h / z / p.home : 1;
  const fd = p.draw > 1e-12 ? d / z / p.draw : 1;
  const fa = p.away > 1e-12 ? a / z / p.away : 1;
  return m.map((row, i) => row.map((v, j) => v * (i > j ? fh : i === j ? fd : fa)));
}

export const DEFAULT_ELO = 1500;

export function ratingOf(model: RatingsModel, team: string): number {
  return model.ratings[team] ?? DEFAULT_ELO;
}

/** The rating that drives probabilities: slow Elo blended with the fast walk
 *  when the model ships one (backtest-validated recency component). */
export function blendedRating(model: RatingsModel, team: string): number {
  const slow = model.ratings[team] ?? DEFAULT_ELO;
  const beta = model.fastBeta ?? 0;
  if (beta <= 0 || !model.ratingsFast) return slow;
  return (1 - beta) * slow + beta * (model.ratingsFast[team] ?? DEFAULT_ELO);
}

export interface MatchPrediction {
  home: string;
  away: string;
  eloHome: number;
  eloAway: number;
  lambdaHome: number;
  lambdaAway: number;
  probs: OutcomeProbs;
  /** Most likely scorelines, descending. */
  scorelines: Scoreline[];
}

/**
 * The full scoreline distribution for a match: Elo-DC matrix, log-pooled with
 * the Team-DC matrix when the model carries ensemble params (the backtested
 * shipped configuration). `homeAdv` is the SIGNED Elo bump for the home slot:
 * positive = home side is the host at home, negative = the away side is, 0 = neutral.
 */
export function matchMatrix(
  home: string,
  away: string,
  model: RatingsModel,
  homeAdv = 0,
): number[][] {
  const { lambdaHome, lambdaAway } = lambdasFromElo(
    blendedRating(model, home), blendedRating(model, away), model.params, homeAdv,
  );
  const mElo = scoreMatrix(lambdaHome, lambdaAway, model.params.rho);
  const finish = (m: number[][]): number[][] => (model.calibration ? calibrateMatrix(m, model.calibration) : m);
  if (!model.teamDc || model.ensembleW == null || model.ensembleW >= 1) return finish(mElo);

  // Team-DC orientation: homeAdv > 0 → home side at home; < 0 → away side at
  // home (compute flipped, then swap); 0 → neutral.
  let dcHome: number, dcAway: number;
  if (homeAdv < 0) {
    const f = teamDcLambdas(model.teamDc, away, home, false);
    dcHome = f.lambdaAway; dcAway = f.lambdaHome;
  } else {
    const f = teamDcLambdas(model.teamDc, home, away, homeAdv === 0);
    dcHome = f.lambdaHome; dcAway = f.lambdaAway;
  }
  const mDc = scoreMatrix(dcHome, dcAway, model.params.rho);
  return finish(poolMatrices(mElo, mDc, model.ensembleW));
}

/** Expected goals per side under a scoreline matrix. */
function matrixLambdas(m: number[][]): { lambdaHome: number; lambdaAway: number } {
  let lh = 0, la = 0;
  for (let i = 0; i < m.length; i++) {
    for (let j = 0; j < m[i]!.length; j++) {
      lh += i * m[i]![j]!;
      la += j * m[i]![j]!;
    }
  }
  return { lambdaHome: lh, lambdaAway: la };
}

/**
 * Win/draw/loss + likely scores for one match. `homeAdv` is the Elo bump for the
 * home slot (0 neutral; use hostAdvantage() for host nations at home).
 */
export function predictMatch(
  home: string,
  away: string,
  model: RatingsModel,
  homeAdv = 0,
): MatchPrediction {
  const m = matchMatrix(home, away, model, homeAdv);
  const { lambdaHome, lambdaAway } = matrixLambdas(m);
  return {
    home,
    away,
    eloHome: ratingOf(model, home),
    eloAway: ratingOf(model, away),
    lambdaHome,
    lambdaAway,
    probs: outcomeProbs(m),
    scorelines: topScorelines(m, 5),
  };
}

/** Probability the home side advances in a knockout (regulation result, draws
 *  resolved by an Elo-weighted shootout). */
export function knockoutAdvanceProb(p: OutcomeProbs, eloHome: number, eloAway: number): number {
  // Penalty edge: small, rating-tilted; mostly a coin flip.
  const shootoutHome = 1 / (1 + 10 ** ((eloAway - eloHome) / 800));
  return p.home + p.draw * shootoutHome;
}
