/**
 * PintRush engine — type contract.
 *
 * Everything the game server, the web clients and the tests agree on lives here.
 * The engine is deterministic: given the same state and the same command (including `now`),
 * it always produces the same next state, events and reply. It never reads the clock, never
 * does I/O and never uses Math.random (randomness comes from seeded PRNGs in rng.ts).
 *
 * All timestamps are epoch milliseconds from the SERVER clock. Client clocks are never used.
 * See docs/ARCHITECTURE.md for the reasoning behind each rule.
 */

export type Id = string;
/** Board cell, row-major: 0 1 2 / 3 4 5 / 6 7 8 */
export type Position = number;

// ───────────────────────────── Enums (string unions mirror Prisma enums) ─────────────────────────────

export type EventMode = 'ALWAYS_ON' | 'CHAMPIONSHIP';
export type SessionStatus = 'WAITING' | 'RUNNING' | 'PAUSED' | 'COMPLETED';
export type RoundKind = 'RUSH' | 'MUSIC_MAYHEM' | 'TABLE_WARS' | 'POWER_ROUND' | 'FINAL_RUSH';
export type Difficulty = 'STANDARD' | 'DIFFICULT';
export type QuestionType = 'MULTIPLE_CHOICE' | 'TRUE_FALSE';
export type PowerUpType = 'DOUBLE_DOWN' | 'SECOND_CHANCE' | 'CATEGORY_SWAP';
export type ScoreKind = 'CORRECT' | 'SPEED_BONUS' | 'LINE' | 'FULL_BOARD' | 'TABLE_CHALLENGE' | 'DOUBLE_DOWN';
export type PlayerStatus = 'ACTIVE' | 'KICKED' | 'LEFT';
export type TileSource = 'QUESTION' | 'WILDCARD';

/**
 * Round phase. Per question the lifecycle is PENDING → OPEN → LOCKED → REVEALED → (next question's PENDING | ROUND_OVER).
 * "completed" in the spec's lifecycle = the question's REVEALED phase ending (QuestionCompleted event).
 * INTRO happens once before question 1; ROUND_OVER once after the last reveal.
 */
export type RoundPhase = 'INTRO' | 'PENDING' | 'OPEN' | 'LOCKED' | 'REVEALED' | 'ROUND_OVER';

// ───────────────────────────── Constants ─────────────────────────────

export const BOARD_SIZE = 9;
export const CATEGORY_COUNT = 9;
export const BOARD_QUESTION_COUNT = 10; // 9 category questions + Q10 wildcard
export const FINAL_RUSH_QUESTION_COUNT = 5;
export const FINALIST_COUNT = 5;
export const MAX_NICKNAME_LENGTH = 16;
export const MIN_NICKNAME_LENGTH = 2;
export const MAX_TEAM_NAME_LENGTH = 24;

export const POINTS = {
  CORRECT_STANDARD: 100,
  CORRECT_DIFFICULT: 150,
  SPEED_BONUS_MAX: 50,
  LINE: 250,
  FULL_BOARD: 500,
  TABLE_CHALLENGE: 200,
  DOUBLE_DOWN_WIN: 200,
  DOUBLE_DOWN_LOSS: -100,
  DOUBLE_DOWN_MIN_BALANCE: 100,
} as const;

/** All 8 lines of the 3x3 board, in this fixed order (index = line id). */
export const LINES: readonly (readonly [Position, Position, Position])[] = [
  [0, 1, 2],
  [3, 4, 5],
  [6, 7, 8], // rows
  [0, 3, 6],
  [1, 4, 7],
  [2, 5, 8], // columns
  [0, 4, 8],
  [2, 4, 6], // diagonals
] as const;

// ───────────────────────────── Configuration ─────────────────────────────

export interface Timings {
  /** Round intro (title card + board reveal) before Q1's PENDING. */
  introMs: number;
  /** "Up next: <category>" before each question opens. Double Down / Category Swap window. */
  pendingMs: number;
  /** Answer window for STANDARD questions. */
  windowMs: number;
  /** Answer window for DIFFICULT questions and the wildcard question. */
  difficultWindowMs: number;
  /** Suspense pause between lock and reveal. */
  lockedMs: number;
  /** Correct answer + results on screen. */
  revealMs: number;
  /** Latency allowance: answers inside grace get the full speed bonus. */
  graceMs: number;
  /** ROUND_OVER summary length before the round is completed. */
  roundOverMs: number;
  /** Gap between rounds (always-on spec: 60 s, measured from round completion). */
  breakMs: number;
}

export const DEFAULT_TIMINGS: Timings = {
  introMs: 6_000,
  pendingMs: 4_000,
  windowMs: 15_000,
  difficultWindowMs: 18_000,
  lockedMs: 1_500,
  revealMs: 5_000,
  graceMs: 750,
  roundOverMs: 10_000,
  breakMs: 60_000,
};

export interface CategoryDef {
  key: string;
  label: string;
}

/** A question as the server knows it (includes the answer). Never send this to clients. */
export interface QuestionInput {
  id: Id;
  category: string; // CategoryDef.key
  difficulty: Difficulty;
  type: QuestionType;
  prompt: string;
  options: { id: string; text: string }[];
  correctOptionId: string;
  explanation?: string;
  wildcardEligible?: boolean;
}

/** One item of a championship plan (always-on sessions have plan = null → endless RUSH rounds). */
export interface RoundPlanItem {
  kind: RoundKind;
  packKey: string;
  /** Human label shown on TV, e.g. "The Rush", "Music Mayhem". */
  label: string;
  /** Segment index this round belongs to (rounds of the same segment share a label). */
  segment: number;
}

export interface SessionSettings {
  timings: Timings;
  /** Teams of 1: false = ranked on a separate Solo board and not eligible for table challenges. */
  mergeSolos: boolean;
  /** Late join is allowed until this question index OPENS (spec: until question 3 opens → 2). */
  lateJoinUntilQuestionIndex: number;
  /** Break between rounds inside a championship plan (same segment). */
  championshipRoundBreakMs: number;
  /** Break before a new championship segment starts. */
  championshipSegmentBreakMs: number;
  powerUpsEnabled: boolean;
}

export const DEFAULT_SESSION_SETTINGS: SessionSettings = {
  timings: DEFAULT_TIMINGS,
  mergeSolos: false,
  lateJoinUntilQuestionIndex: 2,
  championshipRoundBreakMs: 20_000,
  championshipSegmentBreakMs: 30_000,
  powerUpsEnabled: true,
};

/** Default 50-minute championship (spec 3.6): 2 rounds per 10-minute segment, Final Rush last. */
export const DEFAULT_CHAMPIONSHIP_PLAN: RoundPlanItem[] = [
  { kind: 'RUSH', packKey: 'originals-v1', label: 'The Rush', segment: 0 },
  { kind: 'RUSH', packKey: 'originals-v1', label: 'The Rush', segment: 0 },
  { kind: 'MUSIC_MAYHEM', packKey: 'music-mayhem-v1', label: 'Music Mayhem', segment: 1 },
  { kind: 'MUSIC_MAYHEM', packKey: 'music-mayhem-v1', label: 'Music Mayhem', segment: 1 },
  { kind: 'TABLE_WARS', packKey: 'originals-v1', label: 'Table Wars', segment: 2 },
  { kind: 'TABLE_WARS', packKey: 'originals-v1', label: 'Table Wars', segment: 2 },
  { kind: 'POWER_ROUND', packKey: 'originals-v1', label: 'Power Round', segment: 3 },
  { kind: 'POWER_ROUND', packKey: 'originals-v1', label: 'Power Round', segment: 3 },
  { kind: 'FINAL_RUSH', packKey: 'originals-v1', label: 'Final Rush', segment: 4 },
];

export interface RoundKindRules {
  hasBoard: boolean;
  teamChallenge: boolean;
  finalistsOnly: boolean;
  questionCount: number;
}

export const ROUND_RULES: Record<RoundKind, RoundKindRules> = {
  RUSH: { hasBoard: true, teamChallenge: false, finalistsOnly: false, questionCount: 10 },
  MUSIC_MAYHEM: { hasBoard: true, teamChallenge: false, finalistsOnly: false, questionCount: 10 },
  POWER_ROUND: { hasBoard: true, teamChallenge: false, finalistsOnly: false, questionCount: 10 },
  TABLE_WARS: { hasBoard: false, teamChallenge: true, finalistsOnly: false, questionCount: 10 },
  FINAL_RUSH: { hasBoard: false, teamChallenge: false, finalistsOnly: true, questionCount: 5 },
};

// ───────────────────────────── State ─────────────────────────────

export interface PlayerState {
  id: Id;
  nickname: string;
  teamId: Id | null;
  joinedAt: number;
  /** 0-based join order within the session; drives board symmetry assignment. */
  joinIndex: number;
  status: PlayerStatus;
  isBot?: boolean;
}

export interface TeamState {
  id: Id;
  name: string;
  createdAt: number;
}

export interface PlayerTotals {
  points: number;
  correct: number;
  answered: number;
  /** Sum of server-measured elapsed ms on correct answers (tie-break 3). */
  correctTimeMs: number;
  /** Server receive time of the most recent correct answer (tie-break 4); null if none. */
  lastCorrectAt: number | null;
  lines: number;
  fullBoards: number;
}

export interface PowerUpWallet {
  /** Unspent tokens. One token per completed line, capped so tokens + used.length ≤ 3. */
  tokens: number;
  /** sourceRefs of lines that granted a token, e.g. "<roundId>:line:3". */
  grants: string[];
  /** Types already used in this event (each at most once). */
  used: PowerUpType[];
  /** Idempotency keys of accepted power-up commands (for duplicate detection). */
  keys: string[];
}

export interface TileState {
  position: Position;
  category: string;
  activated: boolean;
  source: TileSource | null;
  questionId: Id | null;
}

export interface BoardState {
  symmetry: number; // 0..7, see board.ts SYMMETRIES
  baseLayoutRow: number; // 0..8
  tiles: TileState[]; // length 9, tiles[i].position === i
  /** Line indices (0..7) already awarded this round. */
  lines: number[];
  fullBoard: boolean;
}

export interface AnswerRecord {
  playerId: Id;
  questionId: Id;
  optionId: string;
  receivedAt: number;
  elapsedMs: number;
  wildcardPosition: Position | null;
  idempotencyKey: string;
  // Filled in at grading time (LOCKED → REVEALED):
  correct?: boolean;
  points?: number; // base points (100/150) if correct, else 0
  speedBonus?: number;
}

export interface QuestionResult {
  questionId: Id;
  correctOptionId: string;
  /** optionId → number of players who picked it */
  distribution: Record<string, number>;
  answeredCount: number;
  correctCount: number;
  /** Fastest correct answers (up to 3) for the TV. */
  fastest: { playerId: Id; nickname: string; elapsedMs: number }[];
  /** TABLE_WARS only: winning team ids for this challenge. */
  challengeWinners?: Id[];
}

export interface RoundState {
  id: Id;
  index: number; // 0-based round index within the session
  kind: RoundKind;
  label: string;
  packKey: string;
  seed: string;
  baseLayoutRow: number;
  categories: CategoryDef[];
  /** In schedule order. Board rounds: 9 category questions (one per category) then the wildcard. */
  questions: QuestionInput[];
  /** Board rounds: index of the wildcard question (always questions.length - 1), else null. */
  wildcardIndex: number | null;
  qIndex: number; // -1 during INTRO
  phase: RoundPhase;
  phaseStartedAt: number;
  phaseEndsAt: number;
  /** t_open of the current question (set when it OPENS). */
  openedAt: number | null;
  /** openedAt + window for the current question. */
  closesAt: number | null;
  participants: Id[];
  finalists: Id[] | null; // FINAL_RUSH only
  boards: Record<Id, BoardState>;
  /** questionId → playerId → answer */
  answers: Record<Id, Record<Id, AnswerRecord>>;
  /** questionId → playerIds that Double Down'd that question */
  doubleDowns: Record<Id, Id[]>;
  /** questionId → playerId → removed (incorrect) option id */
  secondChances: Record<Id, Record<Id, string>>;
  /** questionId → result (present once revealed) */
  results: Record<Id, QuestionResult>;
  /** Points earned in this round per player (for round standings). */
  roundPoints: Record<Id, number>;
  /** FINAL_RUSH: non-finalist playerId → predicted finalist id */
  predictions: Record<Id, Id>;
  startedAt: number;
  completedAt: number | null;
}

export interface CompletedRoundSummary {
  id: Id;
  index: number;
  kind: RoundKind;
  label: string;
  completedAt: number;
  /** Top 3 of the round by round points (deterministic tie-break). */
  podium: { playerId: Id; nickname: string; points: number }[];
}

export interface GameState {
  schemaVersion: 1;
  sessionId: Id;
  eventId: Id;
  venueId: Id;
  roomId: Id;
  mode: EventMode;
  status: SessionStatus;
  settings: SessionSettings;
  /** Championship plan; null for always-on (endless RUSH rounds on packKey `alwaysOnPackKey`). */
  plan: RoundPlanItem[] | null;
  alwaysOnPackKey: string;
  /** Index of the next plan item to start. */
  planIndex: number;
  players: Record<Id, PlayerState>;
  nextJoinIndex: number;
  /** Player ids connected at the last TICK (bots included). Drives always-on idling and lazy board dealing. */
  present: Id[];
  teams: Record<Id, TeamState>;
  totals: Record<Id, PlayerTotals>;
  wallets: Record<Id, PowerUpWallet>;
  round: RoundState | null;
  completedRounds: CompletedRoundSummary[];
  roundsStarted: number;
  /** When the next round becomes due (null while a round runs or before START). */
  nextRoundAt: number | null;
  /** RoundDue was emitted; waiting for the server to send START_ROUND. */
  awaitingRound: boolean;
  pausedAt: number | null;
  startedAt: number | null;
  completedAt: number | null;
  /** Idempotency keys of accepted host/system commands that must not repeat. */
  commandKeys: string[];
}

// ───────────────────────────── Commands ─────────────────────────────

/** Built by the server (questions come from the DB via selectRoundQuestions). */
export interface RoundSetup {
  id: Id;
  kind: RoundKind;
  label: string;
  packKey: string;
  seed: string;
  categories: CategoryDef[];
  /** Already in schedule order (see selectRoundQuestions). */
  questions: QuestionInput[];
}

export type Command =
  | { type: 'START_SESSION'; now: number; idempotencyKey?: string }
  | { type: 'PAUSE'; now: number; idempotencyKey?: string }
  | { type: 'RESUME'; now: number; idempotencyKey?: string }
  | { type: 'END_SESSION'; now: number; idempotencyKey?: string }
  /** Host round.advance: cut the current phase / break short. */
  | { type: 'ADVANCE'; now: number; idempotencyKey?: string }
  | { type: 'START_ROUND'; now: number; round: RoundSetup }
  /** `present` = ids of players with a live connection (bots count as present). */
  | { type: 'TICK'; now: number; present: Id[] }
  | {
      type: 'JOIN';
      now: number;
      player: { id: Id; nickname: string; isBot?: boolean };
      team?: { id: Id; name: string } | null;
    }
  | { type: 'SET_TEAM'; now: number; playerId: Id; team: { id: Id; name: string } | null }
  | {
      type: 'SUBMIT_ANSWER';
      now: number;
      playerId: Id;
      questionId: Id;
      optionId: string;
      wildcardPosition?: Position | null;
      idempotencyKey: string;
    }
  | {
      type: 'USE_POWERUP';
      now: number;
      playerId: Id;
      powerUp: PowerUpType;
      /** DOUBLE_DOWN: the upcoming question; SECOND_CHANCE: the open question. */
      questionId?: Id;
      /** CATEGORY_SWAP: the two unactivated tiles to swap. */
      from?: Position;
      to?: Position;
      idempotencyKey: string;
    }
  | { type: 'PREDICT'; now: number; playerId: Id; finalistId: Id; idempotencyKey: string }
  | { type: 'RENAME_PLAYER'; now: number; playerId: Id; nickname: string }
  | { type: 'KICK_PLAYER'; now: number; playerId: Id }
  | { type: 'LEAVE'; now: number; playerId: Id };

export type ErrorCode =
  | 'SESSION_NOT_RUNNING'
  | 'SESSION_COMPLETED'
  | 'ALREADY_STARTED'
  | 'NOT_PAUSED'
  | 'NO_ROUND'
  | 'ROUND_NOT_EXPECTED'
  | 'INVALID_ROUND'
  | 'UNKNOWN_PLAYER'
  | 'PLAYER_KICKED'
  | 'NICKNAME_TAKEN'
  | 'NOT_PARTICIPANT'
  | 'WRONG_PHASE'
  | 'WRONG_QUESTION'
  | 'TOO_LATE'
  | 'ALREADY_ANSWERED'
  | 'INVALID_OPTION'
  | 'INVALID_TILE'
  | 'POWERUPS_DISABLED'
  | 'NO_TOKEN'
  | 'POWERUP_ALREADY_USED'
  | 'POWERUP_NOT_APPLICABLE'
  | 'INSUFFICIENT_BALANCE'
  | 'NOT_FINALIST_ROUND'
  | 'CANNOT_PREDICT';

export interface Reply {
  ok: boolean;
  error?: { code: ErrorCode; message: string };
  /** True when an idempotency key was seen before: no state change, same success reply. */
  duplicate?: boolean;
  data?: { removedOptionId?: string };
}

// ───────────────────────────── Domain events (for persistence + messaging) ─────────────────────────────

export type DomainEvent =
  | { type: 'SessionStarted'; at: number }
  | { type: 'SessionPaused'; at: number }
  | { type: 'SessionResumed'; at: number; pausedForMs: number }
  | { type: 'SessionCompleted'; at: number; standings: Standing[]; cancelledRoundId?: Id }
  /** The server must build the round (select questions) and dispatch START_ROUND. */
  | { type: 'RoundDue'; at: number; roundIndex: number; kind: RoundKind; packKey: string; label: string }
  | { type: 'RoundStarted'; at: number; roundId: Id; index: number; kind: RoundKind; baseLayoutRow: number; participants: Id[] }
  | { type: 'PlayerJoined'; at: number; playerId: Id; nickname: string; teamId: Id | null; joinIndex: number }
  | { type: 'TeamCreated'; at: number; teamId: Id; name: string }
  | { type: 'PlayerTeamChanged'; at: number; playerId: Id; teamId: Id | null }
  | { type: 'PlayerRenamed'; at: number; playerId: Id; nickname: string }
  | { type: 'PlayerKicked'; at: number; playerId: Id }
  | { type: 'PlayerLeft'; at: number; playerId: Id }
  | { type: 'BoardDealt'; at: number; roundId: Id; playerId: Id; symmetry: number; baseLayoutRow: number; tiles: TileState[] }
  | { type: 'QuestionPending'; at: number; roundId: Id; questionId: Id; qIndex: number; opensAt: number }
  | { type: 'QuestionOpened'; at: number; roundId: Id; questionId: Id; qIndex: number; openedAt: number; closesAt: number }
  | {
      type: 'AnswerAccepted';
      at: number;
      roundId: Id;
      questionId: Id;
      playerId: Id;
      optionId: string;
      openedAt: number;
      receivedAt: number;
      elapsedMs: number;
      wildcardPosition: Position | null;
      idempotencyKey: string;
    }
  | { type: 'QuestionLocked'; at: number; roundId: Id; questionId: Id }
  | {
      type: 'AnswerGraded';
      at: number;
      roundId: Id;
      questionId: Id;
      playerId: Id;
      correct: boolean;
      points: number;
      speedBonus: number;
    }
  | { type: 'TileActivated'; at: number; roundId: Id; playerId: Id; position: Position; source: TileSource; questionId: Id }
  | { type: 'ScoreAwarded'; at: number; roundId: Id | null; playerId: Id; kind: ScoreKind; refId: string; points: number }
  | { type: 'PowerUpGranted'; at: number; roundId: Id; playerId: Id; sourceRef: string }
  | {
      type: 'PowerUpUsed';
      at: number;
      roundId: Id;
      playerId: Id;
      powerUp: PowerUpType;
      questionId: Id | null;
      payload: { removedOptionId?: string; from?: Position; to?: Position };
      idempotencyKey: string;
    }
  | { type: 'QuestionRevealed'; at: number; roundId: Id; result: QuestionResult }
  | { type: 'QuestionCompleted'; at: number; roundId: Id; questionId: Id }
  | { type: 'PredictionMade'; at: number; roundId: Id; playerId: Id; finalistId: Id }
  | { type: 'RoundOver'; at: number; roundId: Id }
  | { type: 'RoundCompleted'; at: number; roundId: Id; summary: CompletedRoundSummary; standings: Standing[] };

export interface ReduceResult {
  state: GameState;
  events: DomainEvent[];
  reply: Reply;
}

// ───────────────────────────── Standings & views ─────────────────────────────

export interface Standing {
  rank: number; // 1-based, unique (ties are broken deterministically)
  playerId: Id;
  nickname: string;
  teamId: Id | null;
  points: number;
  correct: number;
  correctTimeMs: number;
  lastCorrectAt: number | null;
  lines: number;
}

export interface TeamStanding {
  rank: number;
  teamId: Id;
  name: string;
  /** Mean of member points over members with ≥ 1 answer (rounded to 1 decimal). */
  score: number;
  members: number;
  activeMembers: number;
  isSolo: boolean;
}

/** What the TV and every phone may see. Never contains a correct answer before reveal. */
export interface PublicState {
  sessionId: Id;
  eventId: Id;
  mode: EventMode;
  status: SessionStatus;
  round: {
    id: Id;
    index: number;
    kind: RoundKind;
    label: string;
    categories: CategoryDef[];
    hasBoard: boolean;
    questionCount: number;
    qIndex: number;
    phase: RoundPhase;
    phaseEndsAt: number;
    finalists: { playerId: Id; nickname: string }[] | null;
    estimatedEndAt: number;
  } | null;
  /** Current question. `prompt`/`options` are null during PENDING (category teaser only). */
  question: {
    id: Id;
    index: number;
    category: string;
    categoryLabel: string;
    difficulty: Difficulty;
    type: QuestionType;
    isWildcard: boolean;
    prompt: string | null;
    options: { id: string; text: string }[] | null;
    opensAt: number | null;
    openedAt: number | null;
    closesAt: number | null;
  } | null;
  /** Present only in REVEALED (null in every other phase, including ROUND_OVER). */
  reveal: QuestionResult | null;
  answeredCount: number;
  participantCount: number;
  playerCount: number;
  leaderboard: Standing[]; // top 10
  teamLeaderboard: TeamStanding[]; // top 10 (merged or teams-only, per mergeSolos)
  soloLeaderboard: TeamStanding[] | null; // when mergeSolos = false
  recentJoins: string[]; // last 8 nicknames
  nextRoundAt: number | null;
  plan: { index: number; total: number; label: string | null; nextLabel: string | null } | null;
  lastRound: CompletedRoundSummary | null;
  pausedAt: number | null;
  /** FINAL_RUSH: share of predictors per finalist. */
  predictions: Record<Id, number> | null;
}

/** One player's private view (sent only to that player). */
export interface PrivateState {
  playerId: Id;
  nickname: string;
  status: PlayerStatus;
  teamId: Id | null;
  teamName: string | null;
  totals: PlayerTotals & { rank: number | null };
  /** True if the player is playing the current round. */
  inRound: boolean;
  /** True if the player joined too late for the current round and waits for the next. */
  waitingForNextRound: boolean;
  board: (BoardState & { categoryLabels: Record<string, string> }) | null;
  currentAnswer: { questionId: Id; optionId: string; wildcardPosition: Position | null } | null;
  /** Result for the current question once revealed. */
  lastResult: {
    questionId: Id;
    correct: boolean;
    answered: boolean;
    correctOptionId: string;
    points: number;
    speedBonus: number;
    doubleDown: 'WON' | 'LOST' | null;
    tileActivated: Position | null;
    newLines: number[];
    fullBoard: boolean;
    tokenGranted: boolean;
  } | null;
  roundPoints: number;
  powerUps: {
    enabled: boolean;
    tokens: number;
    used: PowerUpType[];
    /** Types the player could activate right now (phase, tokens, balance, question type). */
    available: PowerUpType[];
    doubleDownOn: Id | null; // question id the player has doubled down on (upcoming/current)
    removedOptionId: string | null; // Second Chance on the current question
  };
  isFinalist: boolean;
  prediction: Id | null;
}

/** Host dashboard view: public + full roster. */
export interface HostState {
  public: PublicState;
  players: {
    playerId: Id;
    nickname: string;
    teamName: string | null;
    status: PlayerStatus;
    points: number;
    answered: number;
    correct: number;
    joinedAt: number;
    isBot: boolean;
  }[];
  teams: { teamId: Id; name: string; members: number }[];
  roundsCompleted: number;
  planIndex: number;
  planLength: number | null;
}
