// Client-side geometry of the play being composed: which cells the formed word(s) cover, and
// where to anchor the move-score badge. This is deliberately independent of the move evaluator
// (lib/dict/eval.ts) — the network `evaluate` returns legality/score/word strings but no cell
// coordinates. Legality, the score and the play's ORIENTATION come from the preview (every
// preview source carries it: the on-device evaluator, the network `evaluate` and a reused hint);
// the geometry is derived here from the board, the staged tiles and that orientation. The
// orientation is never re-derived here — inferring it a second time is what made a lone staged
// tile under the single-word rule highlight the perpendicular run instead of the word actually
// played (lib/formed.engine.test.ts pins the two against each other). Both functions are pure
// (testable in lib/formed.test.ts); the board stays the source of truth for legality.

import type { Board } from './board';
import { BOARD_SIZE } from './premiums';

/** Play axis: horizontal or vertical. */
export type Dir = 'H' | 'V';

/** MainWord locates the play's main word: its start cell, axis and length in cells. */
export interface MainWord {
  row: number;
  col: number;
  dir: Dir;
  len: number;
}

/** FormedGeometry is the composed play's shape: the main word plus the set of every cell (as a
 *  "row,col" key) that the main word and any cross words cover — committed tiles included. */
export interface FormedGeometry {
  main: MainWord;
  cells: Set<string>;
}

/** A minimal cell reference; PendingTile satisfies it structurally. */
interface Cell {
  row: number;
  col: number;
}

const key = (r: number, c: number): string => `${r},${c}`;

/**
 * filledPredicate returns a test for whether a cell holds a tile once the staged play is on the
 * board: either a committed tile sits there or a pending tile is being placed there.
 */
function filledPredicate(board: Board, pending: readonly Cell[]): (r: number, c: number) => boolean {
  const pend = new Set(pending.map((p) => key(p.row, p.col)));
  return (r, c) =>
    r >= 0 &&
    r < BOARD_SIZE &&
    c >= 0 &&
    c < BOARD_SIZE &&
    (board[r]?.[c] != null || pend.has(key(r, c)));
}

/**
 * span walks the maximal contiguous filled run through cell (row, col) along dir and returns the
 * run's start index and length on the moving axis (columns for H, rows for V).
 */
function span(
  filled: (r: number, c: number) => boolean,
  dir: Dir,
  row: number,
  col: number,
): { start: number; len: number } {
  if (dir === 'H') {
    let s = col;
    while (filled(row, s - 1)) s--;
    let e = col;
    while (filled(row, e + 1)) e++;
    return { start: s, len: e - s + 1 };
  }
  let s = row;
  while (filled(s - 1, col)) s--;
  let e = row;
  while (filled(e + 1, col)) e++;
  return { start: s, len: e - s + 1 };
}

/**
 * formedGeometry derives the composed play's word geometry from the board, the staged tiles and
 * dir — the orientation the engine resolved for this very placement, taken from the preview. It
 * returns the main word (the maximal run along dir through the staged tiles) and the set of every
 * cell the main word and each cross word (a perpendicular run of two or more cells through a
 * staged tile) cover. Under the **single-word rule** (multipleWords = false) cross words are
 * omitted, matching the engine, which ignores them entirely — a perpendicular run there is neither
 * validated nor scored (and need not even be a real word), so highlighting it would be misleading.
 *
 * dir must be the engine's own answer rather than a local guess: for a lone staged tile under the
 * single-word rule the two orientations can differ in legality, so the engine settles it through
 * the validator (lib/dict/direction.ts playDirection) and the geometry must follow that verdict or
 * it lights up a run the play does not form.
 *
 * It returns null when nothing is staged, or when the staged tiles do not all sit on one line
 * along dir — a shape only an illegal play produces, which the caller never asks about (it is
 * invoked only for a legal preview).
 */
export function formedGeometry(
  board: Board,
  pending: readonly Cell[],
  multipleWords: boolean,
  dir: Dir,
): FormedGeometry | null {
  if (pending.length === 0) return null;
  const first = pending[0];
  const colinear =
    dir === 'H' ? pending.every((p) => p.row === first.row) : pending.every((p) => p.col === first.col);
  if (!colinear) return null;

  const filled = filledPredicate(board, pending);
  const cells = new Set<string>();

  // Main word: the maximal run along the play axis through the staged tiles (contiguous via any
  // committed tiles between them), collected from any one staged tile.
  const ms = span(filled, dir, first.row, first.col);
  let main: MainWord;
  if (dir === 'H') {
    main = { row: first.row, col: ms.start, dir, len: ms.len };
    for (let c = ms.start; c < ms.start + ms.len; c++) cells.add(key(first.row, c));
  } else {
    main = { row: ms.start, col: first.col, dir, len: ms.len };
    for (let r = ms.start; r < ms.start + ms.len; r++) cells.add(key(r, first.col));
  }

  // Cross words: the perpendicular run through each staged tile, kept only when it is a real word
  // (two or more cells). Skipped under the single-word rule, where the engine ignores cross words,
  // so a perpendicular run must not be highlighted as if it were a formed word.
  if (multipleWords) {
    const cross: Dir = dir === 'H' ? 'V' : 'H';
    for (const p of pending) {
      const cs = span(filled, cross, p.row, p.col);
      if (cs.len < 2) continue;
      if (cross === 'H') {
        for (let c = cs.start; c < cs.start + cs.len; c++) cells.add(key(p.row, c));
      } else {
        for (let r = cs.start; r < cs.start + cs.len; r++) cells.add(key(r, p.col));
      }
    }
  }

  return { main, cells };
}

/** Where the move-score badge is anchored: a main-word cell and the tile corner it sits on. */
export interface BadgeAnchor {
  row: number;
  col: number;
  corner: 'tr' | 'bl';
}

/**
 * badgePlacement chooses the main-word tile and corner for the move-score badge, so the badge
 * points into the board and away from the nearest edge (the board clamps the final pill fully
 * on-board). The rules: a word touching the right or top edge anchors at a tile's bottom-left
 * corner (horizontal → its first letter, vertical → its last letter); a word touching the left or
 * bottom edge — and any mid-board word — anchors at the top-right corner (horizontal → its last
 * letter, vertical → its first letter). A full-width horizontal word (it touches both side edges)
 * anchors at the last letter's top-right, which the board then clamps leftward.
 */
export function badgePlacement(main: MainWord): BadgeAnchor {
  const last = BOARD_SIZE - 1;
  const h = main.dir === 'H';
  const minRow = main.row;
  const maxRow = h ? main.row : main.row + main.len - 1;
  const minCol = main.col;
  const maxCol = h ? main.col + main.len - 1 : main.col;

  if (h && main.len === BOARD_SIZE) {
    return { row: main.row, col: maxCol, corner: 'tr' };
  }
  if (maxCol === last || minRow === 0) {
    return h ? { row: main.row, col: minCol, corner: 'bl' } : { row: maxRow, col: main.col, corner: 'bl' };
  }
  return h ? { row: main.row, col: maxCol, corner: 'tr' } : { row: minRow, col: main.col, corner: 'tr' };
}
