import { describe, expect, it } from 'vitest';
import {
  LINES,
  SYMMETRIES,
  baseLayout,
  baseLayoutRowFor,
  bestWildcardPosition,
  completedLines,
  dealBoard,
  isFullBoard,
  positionClass,
  swapTiles,
  symmetryFor,
  type TileState,
} from '../src/index';
import { CATS } from './helpers';

const KEYS = CATS.map((c) => c.key);
const lineKey = (cells: readonly number[]): string => [...cells].sort((a, b) => a - b).join(',');
const LINE_SET = new Set(LINES.map(lineKey));

function tilesFrom(activated: readonly number[]): TileState[] {
  return Array.from({ length: 9 }, (_, position) => ({
    position,
    category: KEYS[position] as string,
    activated: activated.includes(position),
    source: null,
    questionId: null,
  }));
}

describe('SYMMETRIES', () => {
  it('has the 8 D4 symmetries with the agreed keys, order and permutations', () => {
    expect(SYMMETRIES.map((s) => s.key)).toEqual(['identity', 'rot90', 'rot180', 'rot270', 'mirror-lr', 'mirror-tb', 'transpose', 'anti-transpose']);
    const maps: ((r: number, c: number) => [number, number])[] = [
      (r, c) => [r, c],
      (r, c) => [c, 2 - r],
      (r, c) => [2 - r, 2 - c],
      (r, c) => [2 - c, r],
      (r, c) => [r, 2 - c],
      (r, c) => [2 - r, c],
      (r, c) => [c, r],
      (r, c) => [2 - c, 2 - r],
    ];
    SYMMETRIES.forEach((sym, i) => {
      for (let p = 0; p < 9; p++) {
        const [r, c] = (maps[i] as (r: number, c: number) => [number, number])(Math.floor(p / 3), p % 3);
        expect(sym.permutation[p]).toBe(r * 3 + c);
      }
    });
    expect(SYMMETRIES[1]!.permutation).toEqual([2, 5, 8, 1, 4, 7, 0, 3, 6]);
  });

  it('every permutation is a bijection and the set is closed under composition (a group)', () => {
    const keyOf = (perm: readonly number[]): string => perm.join(',');
    const all = new Set(SYMMETRIES.map((s) => keyOf(s.permutation)));
    expect(all.size).toBe(8);
    for (const a of SYMMETRIES) {
      expect(new Set(a.permutation).size).toBe(9);
      for (const b of SYMMETRIES) {
        const composed = a.permutation.map((p) => b.permutation[p] as number);
        expect(all.has(keyOf(composed))).toBe(true);
      }
    }
  });

  it('maps corners→corners, edges→edges, center→center and lines→lines', () => {
    for (const sym of SYMMETRIES) {
      for (let p = 0; p < 9; p++) expect(positionClass(sym.permutation[p] as number)).toBe(positionClass(p));
      for (const line of LINES) expect(LINE_SET.has(lineKey(line.map((p) => sym.permutation[p] as number)))).toBe(true);
    }
  });

  it('positionClass', () => {
    expect([0, 2, 6, 8].map(positionClass)).toEqual(['corner', 'corner', 'corner', 'corner']);
    expect([1, 3, 5, 7].map(positionClass)).toEqual(['edge', 'edge', 'edge', 'edge']);
    expect(positionClass(4)).toBe('center');
  });
});

describe('fairness (a): every dealt board has all 9 categories exactly once', () => {
  it('holds for all 9 base rows × 8 symmetries', () => {
    for (let row = 0; row < 9; row++) {
      for (let sym = 0; sym < 8; sym++) {
        const board = dealBoard(CATS, row, sym);
        expect(board.tiles.map((t) => t.position)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]);
        expect([...board.tiles.map((t) => t.category)].sort()).toEqual([...KEYS].sort());
        expect(board).toMatchObject({ symmetry: sym, baseLayoutRow: row, lines: [], fullBoard: false });
        expect(board.tiles.every((t) => !t.activated && t.source === null && t.questionId === null)).toBe(true);
      }
    }
  });
});

describe('fairness (b): positional balance across the 72-template set', () => {
  it('each category occupies each cell exactly 8 times', () => {
    const counts = new Map<string, number>();
    for (let row = 0; row < 9; row++) {
      for (let sym = 0; sym < 8; sym++) {
        for (const tile of dealBoard(CATS, row, sym).tiles) {
          const k = `${tile.category}@${tile.position}`;
          counts.set(k, (counts.get(k) ?? 0) + 1);
        }
      }
    }
    expect(counts.size).toBe(81);
    for (const n of counts.values()) expect(n).toBe(8);
  });

  it('so each category is in a corner 32×, an edge 32× and the center 8× (4:4:1, same as the board)', () => {
    for (const key of KEYS) {
      const byClass = { corner: 0, edge: 0, center: 0 };
      for (let row = 0; row < 9; row++) for (let sym = 0; sym < 8; sym++) byClass[positionClass(dealBoard(CATS, row, sym).tiles.find((t) => t.category === key)!.position)]++;
      expect(byClass).toEqual({ corner: 32, edge: 32, center: 8 });
    }
  });

  it('base layout row r is the cyclic Latin square row; player board = base moved by the symmetry', () => {
    expect(baseLayout(0, CATS)).toEqual(KEYS);
    expect(baseLayout(1, CATS)).toEqual([...KEYS.slice(1), KEYS[0]]);
    expect(baseLayout(10, KEYS)).toEqual(baseLayout(1, KEYS));
    for (let row = 0; row < 9; row++) {
      const base = baseLayout(row, CATS);
      for (let s = 0; s < 8; s++) {
        const board = dealBoard(CATS, row, s);
        base.forEach((cat, cell) => expect(board.tiles[SYMMETRIES[s]!.permutation[cell]!]!.category).toBe(cat));
      }
    }
    expect(() => baseLayout(0, CATS.slice(0, 8))).toThrow(/exactly 9/);
  });

  it('symmetry and base-row assignment rotate through every value', () => {
    expect(Array.from({ length: 8 }, (_, j) => symmetryFor(j, 0))).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
    expect(symmetryFor(0, 3)).toBe(3);
    expect(symmetryFor(7, 1)).toBe(0);
    const rows = Array.from({ length: 9 }, (_, i) => baseLayoutRowFor('session-x', i));
    expect(new Set(rows).size).toBe(9);
    expect(rows.every((r) => r >= 0 && r < 9)).toBe(true);
    expect(baseLayoutRowFor('session-x', 1)).toBe((baseLayoutRowFor('session-x', 0) + 1) % 9);
  });
});

describe('lines, full board, wildcard auto-pick, swaps', () => {
  it('completedLines / isFullBoard', () => {
    expect(completedLines(tilesFrom([]))).toEqual([]);
    expect(completedLines(tilesFrom([0, 1, 2]))).toEqual([0]);
    expect(completedLines(tilesFrom([0, 4, 8, 2, 6]))).toEqual([6, 7]);
    expect(completedLines(tilesFrom([0, 1, 2, 3, 4, 5, 6, 7, 8]))).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
    expect(isFullBoard(tilesFrom([0, 1, 2, 3, 4, 5, 6, 7]))).toBe(false);
    expect(isFullBoard(tilesFrom([0, 1, 2, 3, 4, 5, 6, 7, 8]))).toBe(true);
  });

  it('bestWildcardPosition picks the most new lines, ties → lowest index, null when full', () => {
    expect(bestWildcardPosition(tilesFrom([]))).toBe(0);
    expect(bestWildcardPosition(tilesFrom([0, 1]))).toBe(2); // completes row 0
    // 4 completes both diagonals (0,8 and 2,6) → 2 lines beats 1 line anywhere else.
    expect(bestWildcardPosition(tilesFrom([0, 8, 2, 6, 1]))).toBe(4);
    // Two cells each complete one line → lowest index wins.
    expect(bestWildcardPosition(tilesFrom([0, 1, 6, 7]))).toBe(2);
    expect(bestWildcardPosition(tilesFrom([0, 1, 2, 3, 4, 5, 6, 7, 8]))).toBeNull();
    expect(bestWildcardPosition(tilesFrom([0, 1, 2, 3, 4, 5, 6, 7]))).toBe(8);
  });

  it('the auto-pick line gain is symmetry-invariant', () => {
    for (let mask = 0; mask < 512; mask++) {
      const base = [0, 1, 2, 3, 4, 5, 6, 7, 8].filter((p) => mask & (1 << p));
      const gains = SYMMETRIES.map((sym) => {
        const tiles = tilesFrom(base.map((p) => sym.permutation[p] as number));
        const pick = bestWildcardPosition(tiles);
        if (pick === null) return -1;
        const before = completedLines(tiles).length;
        tiles[pick]!.activated = true;
        return completedLines(tiles).length - before;
      });
      expect(new Set(gains).size).toBe(1);
    }
  });

  it('swapTiles exchanges contents but keeps positions, returning new tiles', () => {
    const tiles = tilesFrom([]);
    const out = swapTiles(tiles, 0, 8);
    expect(out[0]).toMatchObject({ position: 0, category: KEYS[8] });
    expect(out[8]).toMatchObject({ position: 8, category: KEYS[0] });
    expect(tiles[0]!.category).toBe(KEYS[0]);
    expect(() => swapTiles(tiles, 0, 9)).toThrow();
  });
});
