// The local game source: a GatewayClient-shaped facade over the offline engine, so the SAME game
// screen drives a local vs_ai game with no backend. It implements the game-loop subset of the
// gateway (GameLoopSource) for a local game id, backed by LocalGame + the IndexedDB store + the
// real robot (lib/robot/strategy). Unlike the network flow there is no live stream: after the
// human's move the robot plays synchronously in the same call, its move persisted atomically and
// delivered to the screen through this source's own per-game event emitter (events()).
//
// It translates between the UI's glyph space (PlacedTile.letter, rack strings) and the engine's
// alphabet-index space using the static letters table (ruleset.ts), so it needs no server alphabet.

import { LocalGame, type LocalMove } from './engine';
import { serializeGame, replayGame, type LocalGameRecord, type Seat as LocalSeat } from './serialize';
import { getLocalGame, saveLocalGame, listLocalGames, deleteLocalGame } from './store';
import { RULESETS } from './ruleset';
import { getDawg } from '../dict';
import { setAlphabet, hasAlphabet } from '../alphabet';
import { HINT_GATE_MS } from '../hints';
import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
import { decide } from '../robot/strategy';
import { verifyPin, type PinLock } from '../pin';
import { noRepeatWordsFor } from '../variants';
import { GatewayError, type PlacedTile } from '../client';
import type {
  EvalResult,
  GameView,
  History,
  HintResult,
  MoveRecord,
  MoveResult,
  PushEvent,
  Seat,
  StateView,
  Tile,
  Variant,
  WordCheckResult,
} from '../model';
import type { Move } from '../dict/validate';

export { LOCAL_ID_PREFIX, isLocalGameId };

/**
 * GameLoopSource is the subset of GatewayClient the game screen calls to run a game. The real
 * gateway satisfies it structurally; the local source implements it for offline play.
 */
export interface GameLoopSource {
  gameState(gameId: string, includeAlphabet: boolean): Promise<StateView>;
  gameHistory(gameId: string): Promise<History>;
  submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<MoveResult>;
  pass(gameId: string): Promise<MoveResult>;
  exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult>;
  resign(gameId: string): Promise<MoveResult>;
  hint(gameId: string): Promise<HintResult>;
  evaluate(gameId: string, tiles: PlacedTile[], variant: Variant, signal?: AbortSignal): Promise<EvalResult>;
  checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
  draftGet(gameId: string): Promise<string>;
  draftSave(gameId: string, json: string): Promise<void>;
}

const BLANK_GLYPH = '?';

// letterToIndex builds a glyph -> index map for a variant's alphabet (upper-cased, matching the
// static letters table and lib/alphabet.ts).
const indexCache = new Map<Variant, Map<string, number>>();
function letterToIndex(variant: Variant, glyph: string): number {
  if (glyph === BLANK_GLYPH) return 255; // BLANK_INDEX
  let m = indexCache.get(variant);
  if (!m) {
    m = new Map();
    RULESETS[variant].letters.forEach((l, i) => m!.set(l, i));
    indexCache.set(variant, m);
  }
  const i = m.get(glyph.toUpperCase());
  if (i === undefined) throw new GatewayError('illegal_play', `no index for "${glyph}" in ${variant}`);
  return i;
}

// indexToLetter decodes an alphabet index to its display glyph; the blank sentinel maps to "?".
function indexToLetter(variant: Variant, index: number): string {
  if (index === 255) return BLANK_GLYPH;
  return RULESETS[variant].letters[index] ?? '';
}

// encodePlacements turns submitted glyph tiles into the engine's index-space placements.
function encodePlacements(variant: Variant, tiles: PlacedTile[]): { row: number; col: number; letter: number; blank: boolean }[] {
  return tiles.map((t) => ({ row: t.row, col: t.col, letter: letterToIndex(variant, t.letter), blank: t.blank }));
}

// A live local game held in memory for the session, so calls do not re-replay from the store.
interface Live {
  game: LocalGame;
  record: LocalGameRecord;
  /** Offline hotseat only: which seat is currently unlocked (its PIN was entered this turn), or null.
   *  Ephemeral — never persisted; cleared on every turn advance so each turn re-locks. */
  unlockedSeat: number | null;
}

/**
 * LocalSource runs local games. One instance backs every local game the app has open; it caches
 * the live engine per id and persists after each move.
 */
export class LocalSource implements GameLoopSource {
  private readonly live = new Map<string, Live>();
  private readonly listeners = new Map<string, Set<(e: PushEvent) => void>>();

  /** create starts a new local game — a vs_ai game or an offline pass-and-play (hotseat) game when
   *  hotseat is set — persists it and returns its state. hostPin is the hotseat master PIN. */
  async create(opts: {
    id: string;
    variant: Variant;
    dictVersion: string;
    seed: bigint;
    multipleWords: boolean;
    seats: LocalSeat[];
    hotseat?: boolean;
    hostPin?: PinLock;
  }): Promise<StateView> {
    const dawg = await getDawg(opts.variant, opts.dictVersion);
    if (!dawg) throw new GatewayError('dict_unavailable');
    const game = new LocalGame({
      variant: opts.variant,
      version: opts.dictVersion,
      seed: opts.seed,
      players: opts.seats.length,
      dawg,
      multipleWords: opts.multipleWords,
      noRepeatWords: noRepeatWordsFor(opts.variant),
    });
    const now = nowUnix();
    const record = serializeGame(game, {
      id: opts.id,
      seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId, pin: s.pin })),
      hotseat: opts.hotseat,
      hostPin: opts.hostPin,
      createdAtUnix: now,
      updatedAtUnix: now,
      hintUnlockAtMs: 0, // no robot move yet: a human-first opening's hint is open at once
    });
    const entry: Live = { game, record, unlockedSeat: null };
    this.live.set(opts.id, entry);
    await saveLocalGame(record);
    return this.stateView(entry);
  }

  /**
   * list returns a lobby GameView for every stored local game, most-recently-updated first, so the
   * offline lobby renders them with the same card machinery as the online games. Each is
   * reconstructed by replay; a record whose dictionary is unavailable (e.g. offline without its
   * dawg) is skipped, since it cannot be rendered — never throws.
   */
  async list(): Promise<GameView[]> {
    const records = await listLocalGames();
    records.sort((a, b) => b.updatedAtUnix - a.updatedAtUnix);
    const views: GameView[] = [];
    for (const record of records) {
      try {
        views.push(this.gameView(await this.load(record.id)));
      } catch {
        /* a record that cannot be replayed (its dawg is not cached offline) is left out */
      }
    }
    return views;
  }

  /** delete removes a local game from the store and the in-memory cache — the offline lobby's
   *  finished-game delete. Best-effort: a failed store delete still drops it from this session. */
  async delete(gameId: string): Promise<void> {
    this.live.delete(gameId);
    this.listeners.delete(gameId);
    await deleteLocalGame(gameId);
  }

  async gameState(gameId: string): Promise<StateView> {
    return this.stateView(await this.load(gameId));
  }

  async gameHistory(gameId: string): Promise<History> {
    const { game, record } = await this.load(gameId);
    return { gameId, moves: game.history.map((m) => this.moveRecord(record.variant, m)) };
  }

  async submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<MoveResult> {
    return this.humanMove(gameId, (g) => g.submitPlay(encodePlacements(variant, tiles)));
  }

  async pass(gameId: string): Promise<MoveResult> {
    return this.humanMove(gameId, (g) => g.pass());
  }

  async exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult> {
    const idx = tiles.map((t) => letterToIndex(variant, t));
    return this.humanMove(gameId, (g) => g.exchange(idx));
  }

  async resign(gameId: string): Promise<MoveResult> {
    // A resignation ends a two-player game immediately; no robot reply follows.
    const entry = await this.load(gameId);
    const seat = entry.game.currentPlayer;
    const move = entry.game.resign();
    entry.unlockedSeat = null;
    entry.record.draft = undefined; // the turn is over; the abandoned composition goes with it
    await this.persist(entry);
    return this.moveResult(entry, move, seat);
  }

  /**
   * unlockSeat reveals the current hotseat seat's rack once its PIN is entered. It verifies pin
   * against the seat's stored lock (a seat with no lock unlocks with any pin) and, on success, marks
   * the seat unlocked for this turn only — a subsequent move re-locks it. Throws 'wrong_pin' on a
   * mismatch. Returns the freshly unlocked state (rack revealed).
   */
  async unlockSeat(gameId: string, pin: string): Promise<StateView> {
    const entry = await this.load(gameId);
    const seat = entry.game.currentPlayer;
    const lock = entry.record.seats[seat]?.pin;
    if (lock && !(await verifyPin(pin, lock))) throw new GatewayError('wrong_pin');
    entry.unlockedSeat = seat;
    return this.stateView(entry);
  }

  /** relock drops any per-turn seat unlock, so re-opening a hotseat game re-prompts the current
   *  seat's PIN. Called when the game screen is left (returning to the lobby). No-op when the game is
   *  not cached, or a seat with no PIN (which is never withheld anyway). */
  relock(gameId: string): void {
    const entry = this.live.get(gameId);
    if (entry) entry.unlockedSeat = null;
  }

  /** verifyHostPin reports whether pin matches the hotseat master (host/referee) PIN. */
  async verifyHostPin(gameId: string, pin: string): Promise<boolean> {
    const entry = await this.load(gameId);
    return !!entry.record.hostPin && (await verifyPin(pin, entry.record.hostPin));
  }

  /**
   * hostAction runs a host (referee) override on a hotseat game, gated by the master PIN:
   *   'skip'      — pass the current seat's turn;
   *   'resign'    — resign seat targetSeat (exclude a player / concede on their behalf);
   *   'terminate' — end the game with no result and delete it from the store.
   * Returns the updated StateView, or null when the game was terminated. Throws 'wrong_pin' on a bad
   * PIN.
   */
  async hostAction(
    gameId: string,
    pin: string,
    action: 'skip' | 'resign' | 'terminate',
    targetSeat?: number,
  ): Promise<StateView | null> {
    const entry = await this.load(gameId);
    if (!entry.record.hostPin || !(await verifyPin(pin, entry.record.hostPin))) throw new GatewayError('wrong_pin');
    if (action === 'terminate') {
      await this.delete(gameId);
      return null;
    }
    if (action === 'skip') entry.game.pass();
    else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer);
    entry.unlockedSeat = null;
    entry.record.draft = undefined; // the host advanced the turn; the seat's composition is stale
    await this.persist(entry);
    return this.stateView(entry);
  }

  async hint(gameId: string): Promise<HintResult> {
    // The idle gate is enforced client-side with a monotonic clock (see Game.svelte / lib/hints):
    // a wall-clock timestamp here could be defeated by changing the device clock. This just serves
    // the engine's top-ranked move; the client only calls it once the gate is open.
    const entry = await this.load(gameId);
    const top = this.topMove(entry.game);
    if (!top) throw new GatewayError('no_hint');
    return { move: this.moveFromGen(entry.record.variant, entry.game.currentPlayer, top), hintsRemaining: 1, walletBalance: 0 };
  }

  async evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult> {
    const { game } = await this.load(gameId);
    const r = game.evaluatePlay(encodePlacements(variant, tiles));
    const decode = (w: readonly number[]) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase();
    return {
      legal: r.legal,
      score: r.score,
      words: r.words.map(decode),
      dir: r.legal ? (r.dir === 0 ? 'H' : 'V') : '',
      repeatedWord: r.repeated ? decode(r.repeated) : undefined,
    };
  }

  async checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult> {
    const { game } = await this.load(gameId);
    const idx = Array.from(word, (ch) => letterToIndex(variant, ch));
    return { word, legal: game.dictionaryHas(idx) };
  }

  // Drafts (the in-progress composition: rack order + pending board tiles) live in the game's own
  // record — a local game has no server to hold them. Persisting them keeps every reload of the
  // state (the game screen refetches on several triggers) from dropping the arrangement back into
  // the rack, and carries it across leaving and reopening the app.
  async draftGet(gameId: string): Promise<string> {
    const entry = await this.load(gameId);
    return entry.record.draft ?? '';
  }
  async draftSave(gameId: string, json: string): Promise<void> {
    const entry = await this.load(gameId);
    entry.record.draft = json || undefined;
    await this.persist(entry);
  }

  /** events subscribes to a local game's push events (the robot's reply, game over). Returns an
   *  unsubscribe. The network path uses the gateway's global stream instead. */
  events(gameId: string, onEvent: (e: PushEvent) => void): () => void {
    let set = this.listeners.get(gameId);
    if (!set) {
      set = new Set();
      this.listeners.set(gameId, set);
    }
    set.add(onEvent);
    return () => set!.delete(onEvent);
  }

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

  private async load(gameId: string): Promise<Live> {
    const cached = this.live.get(gameId);
    if (cached) return cached;
    const record = await getLocalGame(gameId);
    if (!record) throw new GatewayError('game_not_found');
    const dawg = await getDawg(record.variant, record.dictVersion);
    if (!dawg) throw new GatewayError('dict_unavailable');
    const entry: Live = { game: replayGame(record, dawg), record, unlockedSeat: null };
    this.live.set(gameId, entry);
    return entry;
  }

  // humanMove applies the human's action, persists, returns their MoveResult, then (if the game is
  // still on and it is the robot's turn) schedules the robot's synchronous reply — already applied
  // and saved here, but emitted to subscribers on a microtask so the human's move renders first.
  private async humanMove(gameId: string, act: (g: LocalGame) => LocalMove): Promise<MoveResult> {
    const entry = await this.load(gameId);
    const seat = entry.game.currentPlayer;
    const move = act(entry.game);
    entry.unlockedSeat = null; // the turn has advanced; a hotseat seat re-locks (no effect for vs_ai)
    entry.record.draft = undefined; // the composition was committed (or swapped away) with the turn
    const robotMoves = this.runRobots(entry);
    await this.persist(entry);
    const result = this.moveResult(entry, move, seat);
    if (robotMoves.length > 0) {
      queueMicrotask(() => this.emitRobot(entry, robotMoves));
    }
    return result;
  }

  // runRobots plays every consecutive robot seat until it is a human's turn or the game ends,
  // returning the robot moves in order. For a two-player vs_ai game this is a single move.
  private runRobots(entry: Live): LocalMove[] {
    const out: LocalMove[] = [];
    while (!entry.game.isOver && this.isRobotSeat(entry, entry.game.currentPlayer)) {
      const g = entry.game;
      const seat = g.currentPlayer;
      const cands = this.legalMoves(g);
      const dec = decide(g.seed, g.moveCount, cands, g.scoreOf(seat), this.bestOpponent(g, seat), g.handOf(seat), g.bagLength);
      if (dec.kind === 'play') out.push(g.play(dec.move.dir, dec.move.tiles));
      else if (dec.kind === 'exchange' && g.bagLength >= RULESETS[entry.record.variant].rackSize) out.push(g.exchange(dec.exchange));
      else out.push(g.pass());
    }
    // Arm the idle hint gate from the robot's reply: a wall-clock unlock time, persisted so the wait
    // survives a relaunch. Read back through a sanitiser (lib/hints / stateView) so a clock set back
    // cannot freeze it.
    if (out.length > 0) entry.record.hintUnlockAtMs = Date.now() + HINT_GATE_MS;
    return out;
  }

  private emitRobot(entry: Live, moves: LocalMove[]): void {
    const set = this.listeners.get(entry.record.id);
    if (!set) return;
    for (const m of moves) {
      const game = this.gameView(entry);
      const ev: PushEvent = { kind: 'opponent_moved', gameId: entry.record.id, move: this.moveRecord(entry.record.variant, m), game, bagLen: entry.game.bagLength };
      for (const cb of set) cb(ev);
    }
    if (entry.game.isOver) {
      const game = this.gameView(entry);
      const ev: PushEvent = { kind: 'game_over', gameId: entry.record.id, result: entry.game.endReason, scoreLine: '', game };
      for (const cb of set) cb(ev);
    }
  }

  private async persist(entry: Live): Promise<void> {
    entry.record = serializeGame(entry.game, {
      id: entry.record.id,
      seats: entry.record.seats,
      hotseat: entry.record.hotseat,
      hostPin: entry.record.hostPin,
      createdAtUnix: entry.record.createdAtUnix,
      updatedAtUnix: nowUnix(),
      hintUnlockAtMs: entry.record.hintUnlockAtMs,
      draft: entry.record.draft,
    });
    await saveLocalGame(entry.record);
  }

  private isRobotSeat(entry: Live, seat: number): boolean {
    return entry.record.seats[seat]?.kind === 'robot';
  }

  private humanSeat(entry: Live): number {
    const i = entry.record.seats.findIndex((s) => s.kind === 'human');
    return i >= 0 ? i : 0;
  }

  private bestOpponent(game: LocalGame, seat: number): number {
    let best = 0;
    for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i));
    return best;
  }

  private legalMoves(game: LocalGame): Move[] {
    // The generator can emit a one-letter play only for a dictionary that has one-letter words;
    // real dictionaries have none. Filtering to main length >= 2 keeps the robot to legal plays.
    return game.generateMoves().filter((m) => m.main.letters.length >= 2);
  }

  private topMove(game: LocalGame): Move | null {
    const moves = this.legalMoves(game);
    return moves.length > 0 ? moves[0] : null;
  }

  // --- shape builders --------------------------------------------------------

  // The vs_ai idle-hint seconds left, from the stored unlock instant (device wall clock, persisted
  // for the offline record) minus now — capped at the window so a device clock set back cannot push
  // it far ahead and freeze the gate, ceiled to whole seconds and floored at 0. The client anchors a
  // monotonic countdown to this; 0 while open (a human-first opening, no robot move yet).
  private hintUnlockLeft(entry: Live): number {
    if (!entry.record.hintUnlockAtMs) return 0;
    const leftMs = Math.min(HINT_GATE_MS, entry.record.hintUnlockAtMs - Date.now());
    return leftMs > 0 ? Math.ceil(leftMs / 1000) : 0;
  }

  // stateView is the seated player's private view. For a vs_ai game that is the single human seat; for
  // a hotseat game it is the seat to move — and when that seat carries a PIN, the rack is withheld
  // (empty, locked=true) until unlockSeat is called this turn.
  private stateView(entry: Live): StateView {
    const hotseat = !!entry.record.hotseat;
    const seat = hotseat ? entry.game.currentPlayer : this.humanSeat(entry);
    // A finished game is never locked — its final rack is shown for review, not an unlock button.
    const locked = !entry.game.isOver && hotseat && !!entry.record.seats[seat]?.pin && entry.unlockedSeat !== seat;
    const rack = locked ? [] : entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t));
    return {
      game: this.gameView(entry),
      seat,
      rack,
      bagLen: entry.game.bagLength,
      hintsRemaining: 1,
      hintUnlockLeftSeconds: this.hintUnlockLeft(entry),
      locked,
    };
  }

  // Offline there is no server to send the per-variant alphabet table (letters + tile values) the
  // rack and board render from (lib/alphabet); seed it from the static offline ruleset so a cold-
  // booted local game shows real tile weights instead of zeros. Idempotent and cheap — a no-op once
  // the table is cached (from here or a prior online session).
  private ensureAlphabet(variant: Variant): void {
    if (hasAlphabet(variant)) return;
    const rs = RULESETS[variant];
    setAlphabet(
      variant,
      rs.letters.map((letter, index) => ({ index, letter, value: rs.values[index] })),
    );
  }

  private gameView(entry: Live): GameView {
    const { game, record } = entry;
    this.ensureAlphabet(record.variant);
    const seats: Seat[] = record.seats.map((s, i) => ({
      seat: i,
      accountId: s.accountId ?? `${LOCAL_ID_PREFIX}${s.kind}:${i}`,
      displayName: s.name,
      score: game.scoreOf(i),
      hintsUsed: 0,
      isWinner: game.isOver && i === game.winnerIndex,
      resigned: game.resignedOf(i),
    }));
    return {
      id: record.id,
      variant: record.variant,
      dictVersion: record.dictVersion,
      status: game.isOver ? 'finished' : 'active',
      players: game.playerCount,
      toMove: game.currentPlayer,
      turnTimeoutSecs: 0,
      multipleWordsPerTurn: record.multipleWords,
      noRepeatWords: !!record.noRepeatWords,
      moveCount: game.moveCount,
      endReason: game.isOver ? game.endReason : '',
      lastActivityUnix: record.updatedAtUnix,
      vsAi: !record.hotseat,
      hotseat: !!record.hotseat,
      unreadChat: false,
      unreadMessages: false,
      kind: 0,
      seats,
    };
  }

  private moveResult(entry: Live, move: LocalMove, seat: number): MoveResult {
    return {
      move: this.moveRecord(entry.record.variant, move),
      game: this.gameView(entry),
      rack: entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t)),
      bagLen: entry.game.bagLength,
    };
  }

  private moveRecord(variant: Variant, m: LocalMove): MoveRecord {
    const tiles: Tile[] = (m.tiles ?? []).map((t) => ({ row: t.row, col: t.col, letter: indexToLetter(variant, t.letter), blank: t.blank }));
    const words = (m.words ?? []).map((w) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase());
    return {
      player: m.player,
      action: m.action,
      dir: m.dir === undefined ? '' : m.dir === 0 ? 'H' : 'V',
      mainRow: m.mainRow ?? 0,
      mainCol: m.mainCol ?? 0,
      tiles,
      words,
      count: m.count ?? 0,
      score: m.score,
      total: m.total,
    };
  }

  private moveFromGen(variant: Variant, player: number, gm: Move): MoveRecord {
    const tiles: Tile[] = gm.tiles.map((t) => ({ row: t.row, col: t.col, letter: indexToLetter(variant, t.letter), blank: t.blank }));
    const words = [gm.main, ...gm.cross].map((w) => w.letters.map((i) => indexToLetter(variant, i)).join('').toLowerCase());
    return {
      player,
      action: 'play',
      dir: gm.dir === 0 ? 'H' : 'V',
      mainRow: gm.main.row,
      mainCol: gm.main.col,
      tiles,
      words,
      count: 0,
      score: gm.score,
      total: 0,
    };
  }
}

function nowUnix(): number {
  return Math.floor(Date.now() / 1000);
}
