import type { PublicState, QuestionResult, Standing, TeamStanding } from '@pintrush/engine';
import { optionLetter } from './format';

type Options = NonNullable<NonNullable<PublicState['question']>['options']>;

/**
 * Whole-number percentages that always add up to exactly 100 (largest-remainder method), so the
 * bars on the TV never read 33 % + 33 % + 33 %. All zeros when nobody answered.
 */
export function wholePercentages(counts: readonly number[]): number[] {
  const safe = counts.map((c) => (Number.isFinite(c) && c > 0 ? c : 0));
  const total = safe.reduce((a, b) => a + b, 0);
  if (total === 0) return safe.map(() => 0);
  const raw = safe.map((c) => (c / total) * 100);
  const floors = raw.map(Math.floor);
  let remaining = 100 - floors.reduce((a, b) => a + b, 0);
  // Hand out the leftover points to the largest remainders; ties go to the earlier option.
  const order = raw.map((r, i) => ({ i, rem: r - Math.floor(r) })).sort((a, b) => b.rem - a.rem || a.i - b.i);
  for (const { i } of order) {
    if (remaining <= 0) break;
    floors[i] = (floors[i] ?? 0) + 1;
    remaining--;
  }
  return floors;
}

export interface DistributionRow {
  id: string;
  letter: string;
  text: string;
  count: number;
  percent: number;
  correct: boolean;
}

/** One row per option in the question's order, with the share of answers it got. */
export function distributionRows(options: Options, reveal: Pick<QuestionResult, 'distribution' | 'correctOptionId'>): DistributionRow[] {
  const counts = options.map((o) => reveal.distribution[o.id] ?? 0);
  const percents = wholePercentages(counts);
  return options.map((o, i) => ({
    id: o.id,
    letter: optionLetter(i),
    text: o.text,
    count: counts[i] ?? 0,
    percent: percents[i] ?? 0,
    correct: o.id === reveal.correctOptionId,
  }));
}

/** Question sizes on the live question screen (≥ 64 px; 56 only for unusually long custom prompts). */
export const OPEN_PROMPT_SIZES = [92, 80, 72, 64, 56] as const;
/** Smaller sizes for the reveal, where the answer bars need the room (spec minimum is 48 px). */
export const REVEAL_PROMPT_SIZES = [64, 56, 52, 48] as const;

/**
 * Average advance of the display face (Unbounded bold) per character, in em, padded for word
 * wrapping. Measured in the browser at ≈ 0.64 em per glyph; 0.8 leaves room for ragged lines.
 */
const PROMPT_EM_PER_CHAR = 0.8;
const PROMPT_LINE_HEIGHT = 1.1;

/**
 * Largest font size (px on the 1920×1080 stage) at which the prompt fits a box, estimated from its
 * length. Falls back to the smallest size if nothing fits.
 */
export function promptFontSize(prompt: string, box: { width: number; height: number }, sizes: readonly number[] = OPEN_PROMPT_SIZES): number {
  const n = Math.max(1, prompt.trim().length);
  for (const size of sizes) {
    const perLine = Math.max(1, Math.floor(box.width / (size * PROMPT_EM_PER_CHAR)));
    const lines = Math.ceil(n / perLine);
    if (lines * size * PROMPT_LINE_HEIGHT <= box.height) return size;
  }
  return sizes[sizes.length - 1] ?? 48;
}

/**
 * Single-line headline size (category reveal, champion's name): as large as `max`, shrunk so the
 * text fits `width` in the wide display face, never below `min`.
 */
export function fitDisplaySize(text: string, width: number, max: number, min: number): number {
  const n = Math.max(1, [...text.trim()].length);
  // Unbounded extra-bold runs ≈ 0.72 em per character on average (measured; wider than body text).
  return Math.max(min, Math.min(max, Math.floor(width / (n * 0.72))));
}

/** Answer option font size by the longest option text (never below 40 px). */
export function optionFontSize(options: Options): number {
  const longest = options.reduce((m, o) => Math.max(m, o.text.trim().length), 0);
  if (longest <= 18) return 52;
  if (longest <= 30) return 46;
  return 40;
}

export interface RankMove {
  /** Positive = climbed that many places. 0 for unchanged or new entries. */
  places: number;
  /** Points gained since `previous` (0 when new or unchanged). */
  gained: number;
  isNew: boolean;
}

/** Rank movement and points gained for each player in `current`, compared with `previous`. */
export function rankMoves(previous: readonly Standing[] | null | undefined, current: readonly Standing[]): Map<string, RankMove> {
  const before = new Map((previous ?? []).map((s) => [s.playerId, s]));
  const moves = new Map<string, RankMove>();
  for (const s of current) {
    const prev = before.get(s.playerId);
    if (!previous || !prev) {
      moves.set(s.playerId, { places: 0, gained: 0, isNew: previous !== null && previous !== undefined && !prev });
      continue;
    }
    moves.set(s.playerId, { places: prev.rank - s.rank, gained: s.points - prev.points, isNew: false });
  }
  return moves;
}

export interface PredictionRow {
  playerId: string;
  nickname: string;
  /** Whole percent of predictors who picked this finalist (rows add up to 100, or all 0). */
  percent: number;
}

/** Final Rush crowd predictions as whole percentages, in finalist (seed) order. */
export function predictionRows(
  finalists: readonly { playerId: string; nickname: string }[] | null | undefined,
  predictions: Readonly<Record<string, number>> | null | undefined,
): PredictionRow[] {
  if (!finalists || finalists.length === 0) return [];
  const shares = finalists.map((f) => predictions?.[f.playerId] ?? 0);
  const percents = wholePercentages(shares.map((s) => Math.round(s * 1000)));
  return finalists.map((f, i) => ({ playerId: f.playerId, nickname: f.nickname, percent: percents[i] ?? 0 }));
}

export interface FinalistLane {
  playerId: string;
  nickname: string;
  points: number;
  /** 0..1 of the leader's points, for race bars. */
  progress: number;
  rank: number | null;
}

/** Final Rush race lanes: finalists with their session points from the public leaderboard. */
export function finalistLanes(
  finalists: readonly { playerId: string; nickname: string }[] | null | undefined,
  leaderboard: readonly Standing[],
): FinalistLane[] {
  if (!finalists) return [];
  const byId = new Map(leaderboard.map((s) => [s.playerId, s]));
  const lanes = finalists.map((f) => {
    const s = byId.get(f.playerId);
    return { playerId: f.playerId, nickname: s?.nickname ?? f.nickname, points: s?.points ?? 0, rank: s?.rank ?? null };
  });
  const top = Math.max(1, ...lanes.map((l) => l.points));
  return lanes
    .map((l) => ({ ...l, progress: Math.max(0, l.points) / top }))
    .sort((a, b) => (a.rank ?? Number.MAX_SAFE_INTEGER) - (b.rank ?? Number.MAX_SAFE_INTEGER));
}

/** Names of the Table Wars challenge winners (team ids → names from the public team board). */
export function challengeWinnerNames(pub: Pick<PublicState, 'teamLeaderboard' | 'soloLeaderboard'>, winners: readonly string[] | undefined): string[] {
  if (!winners || winners.length === 0) return [];
  const names = new Map([...pub.teamLeaderboard, ...(pub.soloLeaderboard ?? [])].map((t) => [t.teamId, t.name]));
  return winners.map((id) => names.get(id)).filter((n): n is string => typeof n === 'string' && n.length > 0);
}

export interface TableBoards {
  /** The host picked "merge solos": one ranking of tables and solo players together. */
  merged: boolean;
  /** Heading for the main board. */
  title: 'Tables' | 'Tables & solo players';
  /** The main board: the merged ranking (solos included), or real tables only. */
  tables: TeamStanding[];
  /** The separate solo board; always empty when merged. */
  solos: TeamStanding[];
  /** Worth a panel: at least one real table is playing (a merged board of only solos is just the player board). */
  show: boolean;
}

/**
 * Table standings as the host configured them (spec 3.4). With "merge solos" the engine sends one
 * ranking in `teamLeaderboard` with `soloLeaderboard: null`, so nothing may be filtered out of it;
 * solos only get their own board in separate mode.
 */
export function tableBoards(pub: Pick<PublicState, 'teamLeaderboard' | 'soloLeaderboard'>): TableBoards {
  const merged = pub.soloLeaderboard === null;
  const realTables = pub.teamLeaderboard.some((t) => !t.isSolo);
  if (merged) return { merged, title: 'Tables & solo players', tables: [...pub.teamLeaderboard], solos: [], show: realTables };
  return {
    merged,
    title: 'Tables',
    tables: pub.teamLeaderboard.filter((t) => !t.isSolo),
    solos: [...(pub.soloLeaderboard ?? [])],
    show: realTables,
  };
}

export type AttractPanel = 'leaderboard' | 'lastRound' | 'howTo';

type LastRound = PublicState['lastRound'];
type PodiumPlace = NonNullable<LastRound>['podium'][number];

/** The last round's podium without 0-point places: nobody "wins" a round they scored nothing in. */
export function lastRoundPodium(lastRound: LastRound | undefined): PodiumPlace[] {
  return lastRound ? lastRound.podium.filter((p) => p.points > 0) : [];
}

/** Panels the attract loop rotates through: only ones with real data, plus "how to play". */
export function attractPanels(input: { standings: readonly Standing[]; lastRound: LastRound }): AttractPanel[] {
  const panels: AttractPanel[] = [];
  if (input.standings.length > 0) panels.push('leaderboard');
  if (lastRoundPodium(input.lastRound).length > 0) panels.push('lastRound');
  panels.push('howTo');
  return panels;
}

export interface AttractBoard {
  kind: 'championship' | 'tonight';
  standings: Standing[];
}

/**
 * The attract-mode leaderboard. Whenever the TV is showing a session its live public board wins:
 * it is re-sent with every reveal, while `snapshot.attract.nightlyTop` is only refreshed when a full
 * room snapshot happens to go out (it went stale for whole rounds). For the always-on session the
 * two are the same ranking. The snapshot's copy is only for an idle room (no session on screen).
 */
export function attractBoard(nightlyTop: readonly Standing[] | undefined, pub: PublicState | null): AttractBoard {
  if (pub) return { kind: pub.mode === 'CHAMPIONSHIP' ? 'championship' : 'tonight', standings: [...pub.leaderboard] };
  return { kind: 'tonight', standings: [...(nightlyTop ?? [])] };
}
