/**
 * The game, in the browser.
 *
 * This is a port of `envs/wordle_five/wordle_five/engine.py`, and "port" is
 * meant strictly: CI scores every (guess, answer) pair in the answer list
 * through both implementations and compares a SHA-256 of the result. If these
 * two files ever disagree by one tile, the build fails. That gate is what lets
 * the page claim it verified a recorded run rather than merely replayed it.
 *
 * Keep this module pure and dependency-free. It runs on the main thread, in a
 * Web Worker, and under `node --test`.
 */

export const WORD_LENGTH = 5;
export const MAX_GUESSES = 6;

/** A tile: correct position, present elsewhere, or absent. */
export type Tile = 'G' | 'Y' | 'X';
/** Five tiles, as a string. `'GYXXY'`. */
export type Pattern = string;

export const ALL_GREEN: Pattern = 'G'.repeat(WORD_LENGTH);

/**
 * Green/yellow/grey feedback, as two passes.
 *
 * The two passes are not stylistic. A letter may be marked non-grey at most as
 * many times as it occurs in the answer, and greens have first claim on that
 * allocation — so every green in the word must be resolved before any yellow
 * is assigned. A single pass marks the first S of SASSY yellow when BASIS has
 * already spent both its S's on the greens that come later.
 *
 * This is the single most common bug in implementations of this game. It is
 * also the bug that put a correction video on the most-watched explanation of
 * it ever made, so it is worth the extra loop.
 */
export function scoreGuess(guess: string, answer: string): Pattern {
  const g = guess.toLowerCase();
  const a = answer.toLowerCase();
  if (g.length !== a.length) {
    throw new Error(`length mismatch: ${guess} vs ${answer}`);
  }

  const n = a.length;
  const pattern: Tile[] = new Array(n).fill('X');
  // Counts of each answer letter still available to yellows, keyed by char
  // code so this stays allocation-free in the hot loop the solver runs.
  const remaining = new Map<string, number>();

  // Pass 1 — greens claim their letters out of the pool.
  for (let i = 0; i < n; i += 1) {
    if (g[i] === a[i]) {
      pattern[i] = 'G';
    } else {
      const c = a[i]!;
      remaining.set(c, (remaining.get(c) ?? 0) + 1);
    }
  }

  // Pass 2 — yellows take only what pass 1 left, left to right.
  for (let i = 0; i < n; i += 1) {
    if (pattern[i] === 'G') continue;
    const c = g[i]!;
    const left = remaining.get(c) ?? 0;
    if (left > 0) {
      pattern[i] = 'Y';
      remaining.set(c, left - 1);
    }
  }

  return pattern.join('');
}

/**
 * Would `candidate` have produced `pattern` for `guess`?
 *
 * This is the whole of constraint filtering, and it is also how `consistency`
 * decides whether a guess contradicted what the player had already been told:
 * a guess is consistent exactly when it was still a possible answer.
 */
export function isConsistent(candidate: string, guess: string, pattern: Pattern): boolean {
  return scoreGuess(guess, candidate) === pattern;
}

/**
 * Hard-mode legality, or null if the guess is legal.
 *
 * Three details are routinely got wrong and are deliberate here: greens are
 * positional and locked; yellows are COUNTED, not merely present, so a guess
 * must carry at least as many copies as were revealed; and grey letters are
 * not banned at all — hard mode places no constraint on known-absent letters.
 */
export function hardModeViolation(
  guess: string,
  prevGuess: string,
  prevPattern: Pattern,
): string | null {
  const g = guess.toLowerCase();
  const p = prevGuess.toLowerCase();

  for (let i = 0; i < prevPattern.length; i += 1) {
    if (prevPattern[i] === 'G' && g[i] !== p[i]) {
      return `${p[i]!.toUpperCase()} must stay in position ${i + 1}`;
    }
  }

  const need = new Map<string, number>();
  for (let i = 0; i < prevPattern.length; i += 1) {
    const tile = prevPattern[i];
    if (tile === 'G' || tile === 'Y') {
      const c = p[i]!;
      need.set(c, (need.get(c) ?? 0) + 1);
    }
  }
  const have = new Map<string, number>();
  for (const c of g) have.set(c, (have.get(c) ?? 0) + 1);

  for (const [letter, count] of need) {
    if ((have.get(letter) ?? 0) < count) {
      const copies = count === 1 ? '' : ` ${count} copies of`;
      return `guess must contain${copies} ${letter.toUpperCase()}`;
    }
  }
  return null;
}

/**
 * FNV-1a, 32-bit. Chosen because it is trivial to reproduce exactly.
 *
 * A language's built-in RNG is not portable: Python's `random.Random(7)` is a
 * Mersenne Twister with no honest one-line equivalent here, so seed 7 would
 * pick one word in the environment and a different one in this tab. Every
 * permalink would then disagree with the recorded run it claims to show. A
 * hash sidesteps it — both sides compute the same integer from the same
 * string, and there is nothing to keep in step.
 *
 * `Math.imul` is what makes the 32-bit multiply exact; a plain `*` overflows
 * into a double and silently diverges from Python after the first few bytes.
 */
export function fnv1a32(text: string): number {
  let h = 0x811c9dc5;
  for (let i = 0; i < text.length; i += 1) {
    h ^= text.charCodeAt(i);
    h = Math.imul(h, 0x01000193) >>> 0;
  }
  return h >>> 0;
}

/** The hidden word for a seed. Identical in engine.py — see fnv1a32. */
export function answerForSeed(seed: number, pool: readonly string[]): string {
  return pool[fnv1a32(String(seed)) % pool.length]!;
}

/** Why a guess would be refused, or null if it is playable. */
export function rejectionReason(
  word: string,
  history: readonly [string, Pattern][],
  allowed: ReadonlySet<string>,
  hardMode = false,
): string | null {
  const w = word.toLowerCase().trim();
  if (w.length !== WORD_LENGTH) return `'${w}' is not ${WORD_LENGTH} letters`;
  if (!allowed.has(w)) return `'${w}' is not in the word list`;
  if (history.some(([prev]) => prev === w)) return `'${w}' has already been guessed`;
  if (hardMode && history.length > 0) {
    const last = history[history.length - 1]!;
    return hardModeViolation(w, last[0], last[1]);
  }
  return null;
}

/** The board, as the surface renders it. Snapshots, never deltas. */
export interface BoardState {
  seed: number;
  answer: string;
  /** Completed rows. */
  rows: { guess: string; pattern: Pattern }[];
  /** What is being typed into the next row, if the board is interactive. */
  draft: string;
  /** Replies the game refused. They cost a turn of patience, not a row. */
  rejected: number;
  status: 'playing' | 'won' | 'lost';
  /** Set for one render after an illegal guess, to drive the shake. */
  invalid: string | null;
  hardMode: boolean;
}

export function emptyBoard(seed: number, answer: string, hardMode = false): BoardState {
  return {
    seed,
    answer,
    rows: [],
    draft: '',
    rejected: 0,
    status: 'playing',
    invalid: null,
    hardMode,
  };
}

export function isOver(board: BoardState): boolean {
  return board.status !== 'playing';
}

/** Play a guess, returning the next board. Pure — never mutates its input. */
export function play(board: BoardState, word: string, allowed: ReadonlySet<string>): BoardState {
  if (isOver(board)) return board;

  const history = board.rows.map((r) => [r.guess, r.pattern] as [string, Pattern]);
  const reason = rejectionReason(word, history, allowed, board.hardMode);
  if (reason) {
    return { ...board, rejected: board.rejected + 1, invalid: reason, draft: board.draft };
  }

  const guess = word.toLowerCase().trim();
  const pattern = scoreGuess(guess, board.answer);
  const rows = [...board.rows, { guess, pattern }];
  const status: BoardState['status'] =
    pattern === ALL_GREEN ? 'won' : rows.length >= MAX_GUESSES ? 'lost' : 'playing';

  return { ...board, rows, draft: '', invalid: null, status };
}

/**
 * What a screen reader hears when a row lands.
 *
 * Not optional decoration: `prefers-reduced-motion` clamps the tile flip to
 * nothing, and colour alone is not a result. This sentence IS the feedback for
 * anyone who is not looking at the tiles.
 */
export function announceRow(guess: string, pattern: Pattern, remaining?: number): string {
  const parts = [...guess].map((letter, i) => {
    const tile = pattern[i];
    const state = tile === 'G' ? 'placed' : tile === 'Y' ? 'present' : 'absent';
    return `${letter.toUpperCase()} ${state}`;
  });
  const tail = remaining === undefined ? '' : ` ${remaining} words remain.`;
  return `${guess.toUpperCase()}: ${parts.join(', ')}.${tail}`;
}
