// The offline game engine — a faithful port of backend/internal/engine/game.go. It owns the
// board, the bag, each seat's hand, the scores, whose turn it is and the move log, applies plays
// (reusing the already-ported validator lib/dict/validate.ts and generator lib/dict/generate.ts),
// and detects the end of a game (out-of-tiles / scoreless / resign) with the standard end-of-game
// rack adjustment. It is pure in-memory logic with no I/O; a local vs_ai game is driven by feeding
// it the robot's choice (lib/robot/strategy.ts) each turn. The bag RNG is our own deterministic
// PRNG (see bag.ts), so a game replays from its seed but is not bit-identical to a server game.
//
// End-of-game scoring, the winner rule and the rack value are exported as pure functions so they
// can be pinned against the Go engine (engine.parity.test.ts) on constructed positions.

import { LocalBoard } from './board';
import { Bag } from './bag';
import { RULESETS } from './ruleset';
import { generateMoves, GenRack, Both } from '../dict/generate';
import { validatePlay, type Direction, type Placement, type Ruleset, type Move } from '../dict/validate';
import { noRepeatScore, playedWordKeys } from '../dict/norepeat';
import { playDirection } from '../dict/direction';
import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums';
import { BLANK_INDEX } from '../alphabet';
import type { Dawg } from '../dict/dawg';
import type { Variant } from '../model';

/** scorelessLimit is the number of consecutive scoreless turns (passes and exchanges) that ends
 *  a game, mirroring engine.scorelessLimit. */
export const scorelessLimit = 6;

/** EndReason explains why a game finished, using the engine's stable labels. */
export type EndReason = 'not_over' | 'out_of_tiles' | 'scoreless' | 'resign' | 'aborted';

/** Disposition of a dropped-out (resigned/timed-out) seat's tiles in a 3-4 player game. */
export type DropoutTiles = 'remove' | 'return';

/** LocalMove is one recorded turn. tiles/dir are set on a play, count on an exchange. */
export interface LocalMove {
  player: number;
  action: 'play' | 'pass' | 'exchange' | 'resign';
  dir?: Direction;
  tiles?: Placement[];
  /** ActionPlay only: the main word's first-letter coordinate. */
  mainRow?: number;
  mainCol?: number;
  /** ActionPlay only: the words formed as alphabet-index letter arrays — the main word first, then
   *  the cross words. Decoded to glyphs when building the history's MoveRecord. */
  words?: number[][];
  /** ActionExchange only: number of tiles swapped. */
  count?: number;
  /** ActionExchange only: the swapped tiles (alphabet-index bytes, BLANK_INDEX for a blank),
   *  recorded so the game can be reconstructed by replaying the journal (see localgame/serialize). */
  exchanged?: number[];
  score: number;
  total: number;
}

/** GameError carries a stable code for the engine's rejection reasons. */
export class GameError extends Error {
  constructor(public readonly code: string) {
    super(code);
    this.name = 'GameError';
  }
}

function letterMultOf(p: Premium): number {
  return p === 'DL' ? 2 : p === 'TL' ? 3 : 1;
}
function wordMultOf(p: Premium): number {
  return p === 'DW' ? 2 : p === 'TW' ? 3 : 1;
}

/**
 * buildRuleset assembles the validator/generator ruleset for a variant from the static offline
 * tile values (ruleset.ts) and the board geometry (premiums.ts). multipleWords false selects the
 * single-word-per-turn rule (perpendicular cross-words ignored). Mirrors the server engine's
 * ruleset for the same variant; online scoring instead reads the server-sent alphabet values.
 */
export function buildRuleset(variant: Variant, multipleWords: boolean): Ruleset {
  const prem = premiumGrid(variant);
  const ctr = centre(variant);
  return {
    cols: BOARD_SIZE,
    center: ctr.row * BOARD_SIZE + ctr.col,
    rackSize: RACK_SIZE,
    bingo: BINGO[variant],
    values: RULESETS[variant].values,
    letterMult: (r, c) => letterMultOf(prem[r][c]),
    wordMult: (r, c) => wordMultOf(prem[r][c]),
    ignoreCrossWords: !multipleWords,
  };
}

/** rackValue sums the tile values left on a hand; blanks (BLANK_INDEX) count zero. Mirrors
 *  engine (*Game).rackValue. */
export function rackValue(hand: readonly number[], values: readonly number[]): number {
  let v = 0;
  for (const t of hand) if (t !== BLANK_INDEX) v += values[t];
  return v;
}

/**
 * applyEndAdjustment settles the unplayed racks and returns the adjusted scores. Out-of-tiles: the
 * player who went out (toMove) gains the sum of every opponent's rack value and each opponent loses
 * their own. Scoreless: everyone loses their own rack value. Resign/aborted: no adjustment.
 * Mirrors engine (*Game).applyEndAdjustment.
 */
export function applyEndAdjustment(
  reason: EndReason,
  hands: readonly (readonly number[])[],
  scores: readonly number[],
  toMove: number,
  values: readonly number[],
): number[] {
  const out = [...scores];
  if (reason === 'out_of_tiles') {
    let bonus = 0;
    for (let i = 0; i < hands.length; i++) {
      if (i === toMove) continue;
      const v = rackValue(hands[i], values);
      out[i] -= v;
      bonus += v;
    }
    out[toMove] += bonus;
  } else if (reason === 'scoreless') {
    for (let i = 0; i < hands.length; i++) out[i] -= rackValue(hands[i], values);
  }
  return out;
}

/**
 * winner returns the index of the single highest-scoring seat, or -1 on a tie for the lead, an
 * unfinished game or an aborted (draw) game. Resigned seats are excluded, so a two-player game
 * returns the remaining player even when the resigner led. Mirrors engine (*Game).winner.
 */
export function winner(over: boolean, reason: EndReason, scores: readonly number[], resigned: readonly boolean[]): number {
  if (!over || reason === 'aborted') return -1;
  let best = -1;
  let tie = false;
  for (let i = 0; i < scores.length; i++) {
    if (resigned[i]) continue;
    if (best === -1 || scores[i] > scores[best]) {
      best = i;
      tie = false;
    } else if (scores[i] === scores[best]) {
      tie = true;
    }
  }
  return tie ? -1 : best;
}

/** Options for a new local game. */
export interface LocalGameOptions {
  variant: Variant;
  version: string;
  /** Seeds the bag; the whole game replays from it. Also the strategy seed the caller uses. */
  seed: bigint;
  players: number;
  dawg: Dawg;
  multipleWords: boolean;
  /** Forbids laying a word that is already on the board (the Erudit rule, see dict/norepeat).
   *  Pinned per game, so a game started before the rule existed replays and scores unchanged;
   *  absent reads as off. */
  noRepeatWords?: boolean;
  dropoutTiles?: DropoutTiles;
}

// placementTiles maps placements to the tiles they consume (BLANK_INDEX for a blank).
function placementTiles(tiles: readonly Placement[]): number[] {
  return tiles.map((p) => (p.blank ? BLANK_INDEX : p.letter));
}

/**
 * LocalGame is the in-memory state of one local match and the rules engine over it. Construct it
 * with a loaded dictionary; drive it with play/pass/exchange/resign. It performs no I/O.
 */
export class LocalGame {
  readonly variant: Variant;
  readonly version: string;
  readonly seed: bigint;
  private readonly vrs: Ruleset;
  private readonly values: readonly number[];
  private readonly rackSize: number;
  private readonly dawg: Dawg;
  private readonly multipleWords: boolean;
  private readonly noRepeatWords: boolean;
  private readonly dropoutTiles: DropoutTiles;

  private readonly board: LocalBoard;
  private readonly bag: Bag;
  private readonly hands: number[][];
  private readonly scores: number[];
  private readonly resigned: boolean[];
  private toMove = 0;
  private scorelessRun = 0;
  private over = false;
  private reason: EndReason = 'not_over';
  private readonly log: LocalMove[] = [];

  constructor(opts: LocalGameOptions) {
    if (opts.players < 2 || opts.players > 4) {
      throw new GameError('players_out_of_range');
    }
    this.variant = opts.variant;
    this.version = opts.version;
    this.seed = opts.seed;
    this.dawg = opts.dawg;
    this.multipleWords = opts.multipleWords;
    this.noRepeatWords = opts.noRepeatWords ?? false;
    this.dropoutTiles = opts.dropoutTiles ?? 'remove';
    this.vrs = buildRuleset(opts.variant, opts.multipleWords);
    this.values = RULESETS[opts.variant].values;
    this.rackSize = RULESETS[opts.variant].rackSize;

    this.board = new LocalBoard();
    this.bag = new Bag(opts.variant, Number(BigInt.asUintN(32, opts.seed)));
    this.hands = [];
    this.scores = [];
    this.resigned = [];
    for (let i = 0; i < opts.players; i++) {
      this.hands.push(this.bag.draw(this.rackSize));
      this.scores.push(0);
      this.resigned.push(false);
    }
  }

  // --- queries ---------------------------------------------------------------
  get playerCount(): number {
    return this.hands.length;
  }
  get currentPlayer(): number {
    return this.toMove;
  }
  get isOver(): boolean {
    return this.over;
  }
  get endReason(): EndReason {
    return this.reason;
  }
  get bagLength(): number {
    return this.bag.length;
  }
  get moveCount(): number {
    return this.log.length;
  }
  scoreOf(player: number): number {
    return this.scores[player];
  }
  /** rackOf returns a copy of a seat's hand (alphabet-index bytes, BLANK_INDEX for blanks). */
  handOf(player: number): number[] {
    return [...this.hands[player]];
  }
  /** resignedOf reports whether a seat has resigned or been excluded by the host. */
  resignedOf(player: number): boolean {
    return this.resigned[player] ?? false;
  }
  /** winnerIndex is the finished game's winner, or -1 (tie / in progress / aborted). */
  get winnerIndex(): number {
    return winner(this.over, this.reason, this.scores, this.resigned);
  }
  /** config returns the rule settings the game was created with — the bits, alongside the seed and
   *  journal, needed to reconstruct it (see localgame/serialize). */
  get config(): { multipleWords: boolean; noRepeatWords: boolean; dropoutTiles: DropoutTiles } {
    return { multipleWords: this.multipleWords, noRepeatWords: this.noRepeatWords, dropoutTiles: this.dropoutTiles };
  }
  /** history returns a copy of the move log. */
  get history(): LocalMove[] {
    return this.log.map((m) => ({ ...m }));
  }

  /** generateMoves returns every legal play for the current player, ranked by descending score.
   *  Under the no-repeat-words rule the plays it forbids are dropped and the rest are rescored and
   *  re-ranked, so neither the robot nor the hint can offer a move the engine would then refuse. */
  generateMoves(): Move[] {
    const moves = generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both);
    if (!this.noRepeatWords) return moves;
    const played = this.playedKeys();
    const out: Move[] = [];
    for (const m of moves) {
      const score = noRepeatScore(m, played);
      if (score === null) continue;
      out.push(score === m.score ? m : { ...m, score });
    }
    // A stable sort, so plays the generator ranked equally keep its order.
    return out.sort((a, b) => b.score - a.score);
  }

  /** playedKeys is the no-repeat-words rule's lookup set: every word this game has already
   *  formed, main words and cross-words alike (see dict/norepeat). */
  private playedKeys(): ReadonlySet<string> {
    const words: number[][] = [];
    for (const m of this.log) {
      if (m.action === 'play' && m.words) words.push(...m.words);
    }
    return playedWordKeys(words);
  }

  /** evaluatePlay scores a candidate placement for the current position without committing it,
   *  returning its legality (dictionary + connectivity), score, the words it forms (as alphabet-index
   *  arrays, main first) and the inferred direction. Backs the local move preview. */
  evaluatePlay(tiles: Placement[]): {
    legal: boolean;
    score: number;
    words: number[][];
    dir: Direction;
    /** The main word when the no-repeat-words rule is what rejects the play: a real word the game
     *  has already used. Absent on every other outcome. */
    repeated?: number[];
  } {
    const dir = playDirection(this.board, this.vrs, this.dawg, tiles);
    const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
    if (!res.legal || !res.move) return { legal: false, score: 0, words: [], dir };
    const m = res.move;
    const score = this.noRepeatWords ? noRepeatScore(m, this.playedKeys()) : m.score;
    if (score === null) return { legal: false, score: 0, words: [], dir, repeated: m.main.letters };
    return { legal: true, score, words: [m.main.letters, ...m.cross.map((w) => w.letters)], dir };
  }

  /** dictionaryHas reports whether the word (alphabet-index letters) is in the game's dictionary. */
  dictionaryHas(word: readonly number[]): boolean {
    return this.dawg.indexOf(word) >= 0;
  }

  // --- turns -----------------------------------------------------------------

  /** submitPlay infers the play's orientation from the placement (like the server's SubmitPlay),
   *  then plays it — the client submits tiles without a direction and the engine resolves it. */
  submitPlay(tiles: Placement[]): LocalMove {
    const dir = playDirection(this.board, this.vrs, this.dawg, tiles);
    return this.play(dir, tiles);
  }

  /** play validates and applies the current player's placement, scores it, refills the rack and
   *  advances the turn (or ends the game). Throws GameError on an illegal play. */
  play(dir: Direction, tiles: Placement[]): LocalMove {
    if (this.over) throw new GameError('game_over');
    const player = this.toMove;
    const used = placementTiles(tiles);
    if (!this.holds(player, used)) throw new GameError('tiles_not_on_rack');

    const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles);
    if (!res.legal || !res.move) throw new GameError('illegal_play');
    const move = res.move;
    const score = this.noRepeatWords ? noRepeatScore(move, this.playedKeys()) : move.score;
    if (score === null) throw new GameError('illegal_play');

    for (const t of tiles) this.board.set(t.row, t.col, t.letter, t.blank);
    this.removeFromHand(player, used);
    this.scores[player] += score;
    this.refill(player);
    this.scorelessRun = 0;

    const rec: LocalMove = {
      player,
      action: 'play',
      dir,
      tiles: tiles.map((t) => ({ ...t })),
      mainRow: move.main.row,
      mainCol: move.main.col,
      words: [move.main.letters, ...move.cross.map((w) => w.letters)],
      score,
      total: this.scores[player],
    };
    this.log.push(rec);

    if (this.hands[player].length === 0 && this.bag.length === 0) this.finish('out_of_tiles');
    else this.advance();
    return rec;
  }

  /** pass forfeits the current turn, extending the scoreless run (which may end the game). */
  pass(): LocalMove {
    if (this.over) throw new GameError('game_over');
    const player = this.toMove;
    this.scorelessRun++;
    const rec: LocalMove = { player, action: 'pass', score: 0, total: this.scores[player] };
    this.log.push(rec);
    this.endTurnAfterScoreless();
    return rec;
  }

  /** exchange swaps the given tiles (alphabet-index bytes, BLANK_INDEX for blanks) for fresh ones.
   *  Legal only while the bag holds at least a full rack; the fresh tiles are drawn before the
   *  swapped ones return, so a player cannot draw back their own. Extends the scoreless run. */
  exchange(tiles: number[]): LocalMove {
    if (this.over) throw new GameError('game_over');
    if (tiles.length === 0) throw new GameError('nothing_to_exchange');
    if (this.bag.length < this.rackSize) throw new GameError('not_enough_tiles_to_exchange');
    const player = this.toMove;
    if (!this.holds(player, tiles)) throw new GameError('tiles_not_on_rack');

    this.removeFromHand(player, tiles);
    const drawn = this.bag.draw(tiles.length);
    for (const t of drawn) this.hands[player].push(t);
    this.bag.return(tiles);
    this.scorelessRun++;

    const rec: LocalMove = { player, action: 'exchange', count: tiles.length, exchanged: [...tiles], score: 0, total: this.scores[player] };
    this.log.push(rec);
    this.endTurnAfterScoreless();
    return rec;
  }

  /** resign drops the current player out of the game (they forfeit the win, keep their score). */
  resign(): LocalMove {
    return this.resignSeat(this.toMove);
  }

  /** resignSeat resigns a specific seat regardless of whose turn it is. */
  resignSeat(seat: number): LocalMove {
    if (this.over) throw new GameError('game_over');
    if (seat < 0 || seat >= this.hands.length || this.resigned[seat]) throw new GameError('game_over');
    this.resigned[seat] = true;
    this.disposeHand(seat);
    const rec: LocalMove = { player: seat, action: 'resign', score: 0, total: this.scores[seat] };
    this.log.push(rec);
    if (this.activeCount() <= 1) this.finish('resign');
    else if (seat === this.toMove) this.advance();
    return rec;
  }

  // --- internals -------------------------------------------------------------

  private rackOf(player: number): GenRack {
    const letters: number[] = [];
    let blanks = 0;
    for (const t of this.hands[player]) {
      if (t === BLANK_INDEX) blanks++;
      else letters.push(t);
    }
    return GenRack.from(this.values.length, letters, blanks);
  }

  private finish(reason: EndReason): void {
    this.over = true;
    this.reason = reason;
    const adjusted = applyEndAdjustment(reason, this.hands, this.scores, this.toMove, this.values);
    for (let i = 0; i < adjusted.length; i++) this.scores[i] = adjusted[i];
  }

  private endTurnAfterScoreless(): void {
    if (this.scorelessRun >= scorelessLimit) this.finish('scoreless');
    else this.advance();
  }

  private advance(): void {
    const n = this.hands.length;
    for (let i = 1; i <= n; i++) {
      const next = (this.toMove + i) % n;
      if (!this.resigned[next]) {
        this.toMove = next;
        return;
      }
    }
  }

  private activeCount(): number {
    return this.resigned.reduce((n, r) => (r ? n : n + 1), 0);
  }

  private disposeHand(player: number): void {
    if (this.dropoutTiles === 'return') this.bag.return(this.hands[player]);
    this.hands[player] = [];
  }

  private holds(player: number, want: readonly number[]): boolean {
    const avail = new Map<number, number>();
    for (const t of this.hands[player]) avail.set(t, (avail.get(t) ?? 0) + 1);
    const need = new Map<number, number>();
    for (const t of want) need.set(t, (need.get(t) ?? 0) + 1);
    for (const [t, n] of need) if ((avail.get(t) ?? 0) < n) return false;
    return true;
  }

  private removeFromHand(player: number, used: readonly number[]): void {
    const hand = this.hands[player];
    for (const t of used) {
      const i = hand.indexOf(t);
      if (i >= 0) hand.splice(i, 1);
    }
  }

  private refill(player: number): void {
    const need = this.rackSize - this.hands[player].length;
    if (need > 0) for (const t of this.bag.draw(need)) this.hands[player].push(t);
  }
}
