// Pure placement state machine for composing a play. The UI lifts tiles from the
// rack onto board cells (via drag or tap); this tracks the pending tiles, infers the
// play direction, supports per-tile recall and a full reset, and builds the submit
// payload. It is board-agnostic (the gateway/engine does full legality validation at
// submit), which keeps it trivially unit-testable.

import type { EvalResult, MoveRecord, Tile } from './model';
import type { PlacedTile } from './client';

export interface PendingTile {
  /** Index of the rack slot this tile was lifted from. */
  rackIndex: number;
  row: number;
  col: number;
  /** Designated concrete letter (for a blank, the letter the player chose). */
  letter: string;
  /** Whether this tile came from a blank rack slot ("?"). */
  blank: boolean;
}

export interface Placement {
  /** The player's rack as dealt, e.g. ['A','Q','?','N','I','W','E']. */
  rack: string[];
  pending: PendingTile[];
}

export interface RackSlot {
  index: number;
  letter: string;
  used: boolean;
}

export const BLANK = '?';

export function newPlacement(rack: string[]): Placement {
  return { rack: [...rack], pending: [] };
}

/**
 * placementFromHint turns a hint move's tiles into a pending placement by matching each
 * tile to a rack slot (a blank "?" for blank tiles, else the matching letter), so the
 * player sees the suggested move laid out and decides whether to commit it.
 */
export function placementFromHint(tiles: Tile[], rack: string[]): Placement {
  const used = new Set<number>();
  const pending: PendingTile[] = [];
  const take = (pred: (letter: string, i: number) => boolean) => rack.findIndex((l, i) => !used.has(i) && pred(l, i));
  for (const t of tiles) {
    let idx = t.blank ? take((l) => l === BLANK) : take((l) => l === t.letter.toUpperCase());
    if (idx < 0) idx = take((l) => l === BLANK); // fall back to a blank
    if (idx < 0) continue;
    used.add(idx);
    pending.push({ rackIndex: idx, row: t.row, col: t.col, letter: t.letter.toUpperCase(), blank: rack[idx] === BLANK });
  }
  return { rack: [...rack], pending };
}

/**
 * previewFromHint turns a hint's move into a move preview. A hint is the engine's own
 * top-ranked, fully scored legal play, so its move already carries the score, words and
 * direction an evaluate would compute for the same placement; reusing it lets the client
 * skip a redundant evaluate round-trip after auto-placing the hint. The words are
 * lower-cased to match the evaluate result the caption renders: a hint's MoveRecord words
 * are upper-cased on decode (for the move-history view), whereas an EvalResult keeps the
 * backend's lower case, so reusing them verbatim would otherwise change the caption's case.
 */
export function previewFromHint(move: MoveRecord): EvalResult {
  return { legal: true, score: move.score, words: move.words.map((w) => w.toLowerCase()), dir: move.dir };
}

function usedIndexes(p: Placement): Set<number> {
  return new Set(p.pending.map((t) => t.rackIndex));
}

/** rackView lists each rack slot with whether it is currently placed on the board. */
export function rackView(p: Placement): RackSlot[] {
  const used = usedIndexes(p);
  return p.rack.map((letter, index) => ({ index, letter, used: used.has(index) }));
}

export function isBlankSlot(p: Placement, rackIndex: number): boolean {
  return p.rack[rackIndex] === BLANK;
}

export function cellOccupied(p: Placement, row: number, col: number): boolean {
  return p.pending.some((t) => t.row === row && t.col === col);
}

/**
 * place lifts a rack slot onto (row, col). For a blank slot the caller must pass the
 * designated letter. Returns the unchanged placement if the move is invalid (slot out
 * of range, already used, target occupied, or a blank with no letter).
 */
export function place(
  p: Placement,
  rackIndex: number,
  row: number,
  col: number,
  blankLetter?: string,
): Placement {
  if (rackIndex < 0 || rackIndex >= p.rack.length) return p;
  if (usedIndexes(p).has(rackIndex)) return p;
  if (cellOccupied(p, row, col)) return p;
  const blank = p.rack[rackIndex] === BLANK;
  const letter = blank ? (blankLetter ?? '').toUpperCase() : p.rack[rackIndex];
  if (blank && !letter) return p;
  return { ...p, pending: [...p.pending, { rackIndex, row, col, letter, blank }] };
}

export function recallAt(p: Placement, row: number, col: number): Placement {
  return { ...p, pending: p.pending.filter((t) => !(t.row === row && t.col === col)) };
}

export function recallIndex(p: Placement, rackIndex: number): Placement {
  return { ...p, pending: p.pending.filter((t) => t.rackIndex !== rackIndex) };
}

/**
 * recallToSlot recalls the pending tile at (row, col) and reinserts its rack letter at the
 * given visible slot, reordering the rack the way a drag-to-reorder does (the drop position the
 * player chose) instead of snapping the tile back to its original slot. It returns the new
 * placement together with the index permutation applied to the rack, so the caller can permute
 * its parallel stable ids in lockstep. toSlot counts the slots that stay visible after the
 * recall (those still on the board are skipped); it is clamped to a valid position. The result
 * is the unchanged placement and an identity permutation when no pending tile sits at (row, col).
 */
export function recallToSlot(
  p: Placement,
  row: number,
  col: number,
  toSlot: number,
): { placement: Placement; order: number[] } {
  const identity = p.rack.map((_, i) => i);
  const tile = p.pending.find((t) => t.row === row && t.col === col);
  if (!tile) return { placement: p, order: identity };
  const ri = tile.rackIndex;
  const rest = p.pending.filter((t) => t !== tile);
  const usedByRest = new Set(rest.map((t) => t.rackIndex));
  // Reinsert ri just before the toSlot-th slot that stays visible after the recall.
  const others = identity.filter((i) => i !== ri);
  let visible = 0;
  let insertPos = others.length;
  for (let pos = 0; pos < others.length; pos++) {
    if (usedByRest.has(others[pos])) continue;
    if (visible === toSlot) {
      insertPos = pos;
      break;
    }
    visible++;
  }
  const order = [...others];
  order.splice(insertPos, 0, ri);
  const newIndex = new Map(order.map((oldIdx, idx) => [oldIdx, idx]));
  return {
    placement: {
      rack: order.map((i) => p.rack[i]),
      pending: rest.map((t) => ({ ...t, rackIndex: newIndex.get(t.rackIndex)! })),
    },
    order,
  };
}

export function reset(p: Placement): Placement {
  return { ...p, pending: [] };
}

/**
 * reorderIndices returns the permutation of [0, n) that lifts the element at `from` and
 * drops it at slot `toSlot` among the remaining elements (clamped to a valid slot). It is
 * applied in parallel to the rack letters and their stable ids when a tile is dragged to a
 * new rack position.
 */
export function reorderIndices(n: number, from: number, toSlot: number): number[] {
  const order: number[] = [];
  for (let i = 0; i < n; i++) if (i !== from) order.push(i);
  order.splice(Math.max(0, Math.min(toSlot, order.length)), 0, from);
  return order;
}

/** toSubmit builds the submit payload: the placed tiles in board order. The backend
 * infers the play's orientation from the tiles and the board, so none is sent. */
export function toSubmit(p: Placement): { tiles: PlacedTile[] } | null {
  if (p.pending.length === 0) return null;
  const tiles: PlacedTile[] = p.pending
    .slice()
    .sort((a, b) => a.row - b.row || a.col - b.col)
    .map((t) => ({ row: t.row, col: t.col, letter: t.letter, blank: t.blank }));
  return { tiles };
}
