// Local move validator + scorer, ported from the scrabble-solver engine
// (scrabble/score.go and scrabble/solver.go, v1.1.1). It answers exactly what the
// server `evaluate` endpoint answers — is a play legal, what words does it form,
// and for how many points — so the client can preview a move without a round
// trip. The server stays authoritative: `submit_play` re-validates on commit, so
// this is an advisory accelerator, never a source of truth.
//
// Everything here works in alphabet-index space, mirroring the Go engine. The
// caller adapts the client's board/placements (letters) into indices via
// lib/alphabet.ts and the premium geometry via lib/premiums.ts. Faithfulness to
// the Go engine is pinned by validate.parity.test.ts against golden fixtures.

/** Horizontal is an across play (fixed row, axis along columns). Mirrors scrabble.Horizontal. */
export const Horizontal = 0;
/** Vertical is a down play (fixed column, axis along rows). Mirrors scrabble.Vertical. */
export const Vertical = 1;
export type Direction = typeof Horizontal | typeof Vertical;

/** A single newly-placed tile (alphabet-index letter; blank scores 0). */
export interface Placement {
  row: number;
  col: number;
  letter: number;
  blank: boolean;
}

/** An occupied board square: an alphabet-index letter and whether it is a blank. */
export interface Cell {
  letter: number;
  blank: boolean;
}

/**
 * Board is the minimal read view the validator needs over the current position.
 * cellAt is only consulted for squares that filled() reports as occupied.
 */
export interface Board {
  inBounds(row: number, col: number): boolean;
  /** filled reports whether (row, col) is on the board AND occupied. */
  filled(row: number, col: number): boolean;
  cellAt(row: number, col: number): Cell;
  /** isEmpty reports whether the whole board is empty (first-move detection). */
  isEmpty(): boolean;
}

/**
 * Ruleset carries the per-variant scoring data the validator needs. values is
 * indexed by alphabet letter index; letterMult/wordMult return the premium
 * multipliers of a square (1 when none); center is the row-major centre index;
 * ignoreCrossWords selects the single-word-per-turn rule.
 */
export interface Ruleset {
  cols: number;
  center: number;
  rackSize: number;
  bingo: number;
  values: readonly number[];
  letterMult(row: number, col: number): number;
  wordMult(row: number, col: number): number;
  ignoreCrossWords: boolean;
}

/** Dict is the dictionary membership test (see Dawg.indexOf). */
export interface Dict {
  indexOf(word: ArrayLike<number>): number;
}

/** A word formed by a play, with its location, letters (indices) and score. */
export interface Word {
  row: number;
  col: number;
  dir: Direction;
  letters: number[];
  blanks: boolean[];
  score: number;
}

/** A scored play: the main word, any cross words, the bingo bonus and the total. */
export interface Move {
  dir: Direction;
  tiles: Placement[];
  main: Word;
  cross: Word[];
  bonus: number;
  score: number;
}

/** Reason a play is rejected. Mirrors the error conditions in the Go engine. */
export type EvalError =
  | 'empty'
  | 'not-one-line'
  | 'off-board'
  | 'occupied'
  | 'same-square'
  | 'gap'
  | 'too-short'
  | 'main-not-in-dict'
  | 'cross-not-in-dict'
  | 'not-connected';

/** Result of {@link validatePlay}: a scored move plus whether it is legal. */
export interface ValidateResult {
  move?: Move;
  legal: boolean;
  err?: EvalError;
}

// coord maps a line coordinate (fixed, axis) to a board (row, col) for dir.
function coord(dir: Direction, fixed: number, axis: number): [number, number] {
  return dir === Horizontal ? [fixed, axis] : [axis, fixed];
}

// fixedAxis is the inverse of coord: it splits (row, col) into (fixed, axis).
function fixedAxis(dir: Direction, row: number, col: number): [number, number] {
  return dir === Horizontal ? [row, col] : [col, row];
}

function perpendicular(d: Direction): Direction {
  return d === Horizontal ? Vertical : Horizontal;
}

/**
 * evaluate computes the words and score for placing tiles on b in direction dir.
 * It checks geometry only (tiles on one line, on empty squares, contiguous); the
 * dictionary and connectivity are layered on by {@link validatePlay}. Mirrors
 * scrabble.EvaluateOpts.
 */
export function evaluate(
  b: Board,
  rs: Ruleset,
  dir: Direction,
  tiles: Placement[],
): { move?: Move; err?: EvalError } {
  if (tiles.length === 0) {
    return { err: 'empty' };
  }

  const ts = tiles.slice().sort((x, y) => fixedAxis(dir, x.row, x.col)[1] - fixedAxis(dir, y.row, y.col)[1]);

  const fixed = fixedAxis(dir, ts[0].row, ts[0].col)[0];
  let prevAxis = 0;
  for (let i = 0; i < ts.length; i++) {
    const t = ts[i];
    const [f, a] = fixedAxis(dir, t.row, t.col);
    if (f !== fixed) return { err: 'not-one-line' };
    if (!b.inBounds(t.row, t.col)) return { err: 'off-board' };
    if (b.filled(t.row, t.col)) return { err: 'occupied' };
    if (i > 0 && a === prevAxis) return { err: 'same-square' };
    prevAxis = a;
  }

  const main = buildMainWord(b, rs, dir, fixed, ts);
  if ('err' in main) return { err: main.err };

  const move: Move = { dir, tiles: ts, main: main.word, cross: [], bonus: 0, score: main.word.score };
  if (!rs.ignoreCrossWords) {
    for (const t of ts) {
      const cw = crossWord(b, rs, dir, t);
      if (cw) {
        move.cross.push(cw);
        move.score += cw.score;
      }
    }
  }
  if (ts.length === rs.rackSize) {
    move.bonus = rs.bingo;
    move.score += rs.bingo;
  }
  return { move };
}

// buildMainWord assembles and scores the word along dir through the sorted
// placements plus the existing tiles that extend and bridge them. Mirrors
// scrabble.buildMainWord.
function buildMainWord(
  b: Board,
  rs: Ruleset,
  dir: Direction,
  fixed: number,
  ts: Placement[],
): { word: Word } | { err: EvalError } {
  const minA = fixedAxis(dir, ts[0].row, ts[0].col)[1];
  const maxA = fixedAxis(dir, ts[ts.length - 1].row, ts[ts.length - 1].col)[1];

  let start = minA;
  for (;;) {
    const [r, c] = coord(dir, fixed, start - 1);
    if (!b.filled(r, c)) break;
    start--;
  }
  let end = maxA;
  for (;;) {
    const [r, c] = coord(dir, fixed, end + 1);
    if (!b.filled(r, c)) break;
    end++;
  }

  const letters: number[] = [];
  const blanks: boolean[] = [];
  let letterSum = 0;
  let wordMult = 1;
  let ti = 0;
  for (let a = start; a <= end; a++) {
    const [r, c] = coord(dir, fixed, a);
    if (ti < ts.length) {
      const ta = fixedAxis(dir, ts[ti].row, ts[ti].col)[1];
      if (ta === a) {
        const t = ts[ti];
        ti++;
        if (!t.blank) letterSum += rs.values[t.letter] * rs.letterMult(r, c);
        wordMult *= rs.wordMult(r, c);
        letters.push(t.letter);
        blanks.push(t.blank);
        continue;
      }
    }
    if (b.filled(r, c)) {
      const cell = b.cellAt(r, c);
      if (!cell.blank) letterSum += rs.values[cell.letter];
      letters.push(cell.letter);
      blanks.push(cell.blank);
      continue;
    }
    return { err: 'gap' };
  }

  const [wr, wc] = coord(dir, fixed, start);
  return { word: { row: wr, col: wc, dir, letters, blanks, score: letterSum * wordMult } };
}

// crossWord builds the perpendicular word formed by a single new tile, or null
// when the tile has no perpendicular neighbour. Mirrors scrabble.crossWord.
function crossWord(b: Board, rs: Ruleset, dir: Direction, t: Placement): Word | null {
  const cdir = perpendicular(dir);
  const [fixed, axis] = fixedAxis(cdir, t.row, t.col);

  let start = axis;
  for (;;) {
    const [r, c] = coord(cdir, fixed, start - 1);
    if (!b.filled(r, c)) break;
    start--;
  }
  let end = axis;
  for (;;) {
    const [r, c] = coord(cdir, fixed, end + 1);
    if (!b.filled(r, c)) break;
    end++;
  }
  if (start === end) return null;

  const letters: number[] = [];
  const blanks: boolean[] = [];
  let letterSum = 0;
  let wordMult = 1;
  for (let a = start; a <= end; a++) {
    const [r, c] = coord(cdir, fixed, a);
    if (a === axis) {
      if (!t.blank) letterSum += rs.values[t.letter] * rs.letterMult(r, c);
      wordMult *= rs.wordMult(r, c);
      letters.push(t.letter);
      blanks.push(t.blank);
    } else {
      const cell = b.cellAt(r, c);
      if (!cell.blank) letterSum += rs.values[cell.letter];
      letters.push(cell.letter);
      blanks.push(cell.blank);
    }
  }
  const [wr, wc] = coord(cdir, fixed, start);
  return { row: wr, col: wc, dir: cdir, letters, blanks, score: letterSum * wordMult };
}

/**
 * validatePlay scores a play and checks that every word it forms is in the
 * dictionary and that it connects to the board (or covers the centre on the
 * first move). legal is true exactly when the play is legal. Mirrors
 * scrabble.ValidatePlayOpts.
 */
export function validatePlay(
  b: Board,
  rs: Ruleset,
  dict: Dict,
  dir: Direction,
  tiles: Placement[],
): ValidateResult {
  const res = evaluate(b, rs, dir, tiles);
  if (res.err) return { legal: false, err: res.err };
  const m = res.move!;

  if (m.main.letters.length < 2) return { move: m, legal: false, err: 'too-short' };
  if (dict.indexOf(m.main.letters) < 0) return { move: m, legal: false, err: 'main-not-in-dict' };
  for (const cw of m.cross) {
    if (dict.indexOf(cw.letters) < 0) return { move: m, legal: false, err: 'cross-not-in-dict' };
  }
  if (!connected(b, rs, m)) return { move: m, legal: false, err: 'not-connected' };
  return { move: m, legal: true };
}

// connected reports whether the play connects to the position (or covers the
// centre on the first move). Mirrors (*Solver).connected. Exported so the move
// generator can apply the same post-generation connectivity filter.
export function connected(b: Board, rs: Ruleset, m: Move): boolean {
  if (b.isEmpty()) {
    const cr = Math.floor(rs.center / rs.cols);
    const cc = rs.center % rs.cols;
    return wordCovers(m.main, cr, cc);
  }
  if (rs.ignoreCrossWords) {
    return m.main.letters.length > m.tiles.length;
  }
  return m.main.letters.length > m.tiles.length || touchesPerpendicular(b, m);
}

// touchesPerpendicular reports whether any new tile abuts an existing tile
// perpendicular to the main word. Mirrors scrabble.touchesPerpendicular.
function touchesPerpendicular(b: Board, m: Move): boolean {
  const cdir = perpendicular(m.dir);
  for (const t of m.tiles) {
    const [fixed, axis] = fixedAxis(cdir, t.row, t.col);
    let [r, c] = coord(cdir, fixed, axis - 1);
    if (b.filled(r, c)) return true;
    [r, c] = coord(cdir, fixed, axis + 1);
    if (b.filled(r, c)) return true;
  }
  return false;
}

// wordCovers reports whether word w passes through square (r, c). Mirrors
// scrabble.wordCovers.
function wordCovers(w: Word, r: number, c: number): boolean {
  for (let i = 0; i < w.letters.length; i++) {
    let rr = w.row;
    let cc = w.col;
    if (w.dir === Horizontal) cc += i;
    else rr += i;
    if (rr === r && cc === c) return true;
  }
  return false;
}
