// Differential test: the board highlight (lib/formed.ts) against the engine port
// (lib/dict/{direction,validate}.ts) that decides what the play actually is.
//
// The two are separate implementations by necessity — the engine names the words, the highlight
// names the cells — and they drifted once already: formedGeometry used to infer the play's
// orientation itself, and for a lone staged tile under the single-word rule its "longer run wins"
// guess disagreed with the engine, which settles that case through the validator. The result was a
// legal vertical play greening the horizontal run it merely abuts. Nothing in either unit suite
// caught it, because each was right about its own half.
//
// So this walks random positions, asks the engine for every legal play it can find, and asserts the
// highlighted cells are exactly the cells of the words the engine says the play forms — and that
// the highlighted main word runs along the engine's orientation. Two dictionaries are used: one
// that accepts every run (the widest set of legal plays) and one that accepts a pseudo-random half
// of them (so the two orientations of a lone tile genuinely differ in legality, which is what makes
// the engine's validator tie-break matter).

import { describe, expect, it } from 'vitest';
import { formedGeometry, type Dir } from './formed';
import { emptyBoard, type Board } from './board';
import { playDirection } from './dict/direction';
import {
  validatePlay,
  Horizontal,
  type Board as EngineBoard,
  type Dict,
  type Move,
  type Placement,
  type Ruleset,
} from './dict/validate';
import { BOARD_SIZE } from './premiums';

/** Size of the toy alphabet the fuzz draws letters from (indices 0..LETTERS-1). */
const LETTERS = 6;

/** rng is a small deterministic PRNG (mulberry32), so a failure is always reproducible by seed. */
function rng(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** Grid maps a "row,col" key to the letter index committed there. */
type Grid = Map<string, number>;

// engineBoardOf adapts a grid into the validator's read view.
function engineBoardOf(grid: Grid): EngineBoard {
  const inBounds = (r: number, c: number): boolean => r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE;
  return {
    inBounds,
    filled: (r, c) => inBounds(r, c) && grid.has(`${r},${c}`),
    cellAt: (r, c) => ({ letter: grid.get(`${r},${c}`)!, blank: false }),
    isEmpty: () => grid.size === 0,
  };
}

// clientBoardOf adapts the same grid into the client's letter-space board.
function clientBoardOf(grid: Grid): Board {
  const b = emptyBoard();
  for (const [k, letter] of grid) {
    const [r, c] = k.split(',').map(Number);
    b[r][c] = { letter: String.fromCharCode(97 + letter), blank: false };
  }
  return b;
}

// ruleset builds a flat-premium ruleset (the premiums are irrelevant to geometry) with distinct
// letter values, so two legal orientations of one tile can differ in score and exercise the
// engine's higher-scoring tie-break.
function ruleset(ignoreCrossWords: boolean): Ruleset {
  return {
    cols: BOARD_SIZE,
    center: 7 * BOARD_SIZE + 7,
    rackSize: 7,
    bingo: 50,
    values: Array.from({ length: LETTERS }, (_, i) => i + 1),
    letterMult: () => 1,
    wordMult: () => 1,
    ignoreCrossWords,
  };
}

/** acceptAll is a dictionary holding every run of two or more letters. */
const acceptAll: Dict = { indexOf: (w) => (w.length >= 2 ? 1 : -1) };

/** acceptHalf holds a deterministic pseudo-random half of the runs, so legality is axis-dependent. */
const acceptHalf: Dict = {
  indexOf: (w) => {
    if (w.length < 2) return -1;
    let h = 2166136261;
    for (let i = 0; i < w.length; i++) h = Math.imul(h ^ (w[i] as number), 16777619);
    return (h >>> 0) % 2 === 0 ? 1 : -1;
  },
};

// engineCells collects every cell covered by the words the engine says the play forms.
function engineCells(m: Move): Set<string> {
  const out = new Set<string>();
  for (const w of [m.main, ...m.cross]) {
    for (let i = 0; i < w.letters.length; i++) {
      const r = w.dir === Horizontal ? w.row : w.row + i;
      const c = w.dir === Horizontal ? w.col + i : w.col;
      out.add(`${r},${c}`);
    }
  }
  return out;
}

// position lays a handful of random runs around the centre, giving hooks and abutments to play off.
function position(rnd: () => number): Grid {
  const grid: Grid = new Map();
  const runs = 1 + Math.floor(rnd() * 5);
  for (let i = 0; i < runs; i++) {
    const horiz = rnd() < 0.5;
    const len = 2 + Math.floor(rnd() * 4);
    const row = 4 + Math.floor(rnd() * 7);
    const col = 4 + Math.floor(rnd() * 7);
    for (let j = 0; j < len; j++) {
      const r = horiz ? row : row + j;
      const c = horiz ? col + j : col;
      if (r >= BOARD_SIZE || c >= BOARD_SIZE) break;
      grid.set(`${r},${c}`, Math.floor(rnd() * LETTERS));
    }
  }
  return grid;
}

// candidates enumerates every lone-tile play on the position plus a batch of random multi-tile line
// plays (walking along the axis over occupied squares, so bridging plays appear too).
function candidates(grid: Grid, rnd: () => number): Placement[][] {
  const out: Placement[][] = [];
  for (let r = 0; r < BOARD_SIZE; r++) {
    for (let c = 0; c < BOARD_SIZE; c++) {
      if (grid.has(`${r},${c}`)) continue;
      out.push([{ row: r, col: c, letter: Math.floor(rnd() * LETTERS), blank: false }]);
    }
  }
  for (let k = 0; k < 300; k++) {
    const horiz = rnd() < 0.5;
    const n = 2 + Math.floor(rnd() * 3);
    const row = 2 + Math.floor(rnd() * 11);
    const col = 2 + Math.floor(rnd() * 11);
    const tiles: Placement[] = [];
    let step = 0;
    for (let i = 0; i < n; i++) {
      for (let guard = 0; guard < 6; guard++) {
        const r = horiz ? row : row + step;
        const c = horiz ? col + step : col;
        step++;
        if (r >= BOARD_SIZE || c >= BOARD_SIZE) break;
        if (!grid.has(`${r},${c}`)) {
          tiles.push({ row: r, col: c, letter: Math.floor(rnd() * LETTERS), blank: false });
          break;
        }
      }
    }
    if (tiles.length >= 2) out.push(tiles);
  }
  return out;
}

// compare walks `seeds` random positions under one rule and dictionary, returning how many legal
// plays were checked and a description of every disagreement found.
function compare(
  multipleWords: boolean,
  dict: Dict,
  seeds: number,
): { checked: number; failures: string[] } {
  const rs = ruleset(!multipleWords);
  let checked = 0;
  const failures: string[] = [];

  for (let seed = 1; seed <= seeds; seed++) {
    const rnd = rng(seed);
    const grid = position(rnd);
    if (grid.size === 0) continue;
    const eb = engineBoardOf(grid);
    const cb = clientBoardOf(grid);

    for (const tiles of candidates(grid, rnd)) {
      const dir = playDirection(eb, rs, dict, tiles);
      const res = validatePlay(eb, rs, dict, dir, tiles);
      if (!res.legal || !res.move) continue;
      checked++;

      const wantDir: Dir = res.move.dir === Horizontal ? 'H' : 'V';
      const want = engineCells(res.move);
      const geom = formedGeometry(cb, tiles, multipleWords, wantDir);
      const got = geom?.cells ?? new Set<string>();
      const sameCells = want.size === got.size && [...want].every((k) => got.has(k));
      if (sameCells && geom?.main.dir === wantDir) continue;

      if (failures.length < 5) {
        const at = tiles.map((t) => `${t.row},${t.col}`).join(' ');
        failures.push(
          `seed ${seed}: staged [${at}] — engine ${wantDir} ${[...want].sort().join(' ')}` +
            ` / highlight ${geom?.main.dir ?? 'none'} ${[...got].sort().join(' ')}`,
        );
      }
    }
  }
  return { checked, failures };
}

describe('formedGeometry matches the engine port', () => {
  for (const multipleWords of [false, true]) {
    for (const [dictName, dict] of [
      ['every run a word', acceptAll],
      ['half the runs words', acceptHalf],
    ] as const) {
      const rule = multipleWords ? 'multiple words per turn' : 'one word per turn';
      it(`highlights exactly the engine's words (${rule}, ${dictName})`, () => {
        const { checked, failures } = compare(multipleWords, dict, 40);
        expect(checked).toBeGreaterThan(500); // the sweep must actually find legal plays
        expect(failures).toEqual([]);
      });
    }
  }
});
