/**
 * Spec 3.2 / ARCHITECTURE 2.1 fairness (c) and (d), played through full reduce() games.
 */
import { describe, expect, it } from 'vitest';
import {
  DEFAULT_TIMINGS,
  CATEGORY_COUNT,
  POINTS,
  SYMMETRIES,
  baseLayout,
  completedLines,
  createRng,
  dealBoard,
  isFullBoard,
  type Id,
} from '../src/index';
import { CATS, Game, boardSetup, makePack, pointsByPlayer, wrongOption, type AnswerDecision, type AnswerStrategy } from './helpers';

const PLAYERS = ['p0', 'p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7'];

describe('fairness (c): a perfect player', () => {
  it('reaches a full board with all 8 lines and the exact total', () => {
    const g = Game.create();
    g.start();
    g.join('ace');
    // Mixed difficulty: q0, q3, q6 and the wildcard are DIFFICULT (150).
    g.startRound(boardSetup('r1', { tweak: (i) => (i % 3 === 0 ? { difficulty: 'DIFFICULT' } : {}) }));
    g.strategy = ({ question }) => ({ optionId: question.correctOptionId, delayMs: 500 });
    g.runUntil((x) => x.state.round === null);
    const totals = g.state.totals['ace']!;
    const base = 6 * POINTS.CORRECT_STANDARD + 4 * POINTS.CORRECT_DIFFICULT;
    const speed = 10 * POINTS.SPEED_BONUS_MAX; // every answer inside the 750 ms grace
    expect(totals).toMatchObject({ correct: 10, answered: 10, lines: 8, fullBoards: 1 });
    expect(totals.points).toBe(base + speed + 8 * POINTS.LINE + POINTS.FULL_BOARD);
    expect(totals.points).toBe(4200);
    expect(pointsByPlayer(g.events)['ace']).toBe(totals.points);
    // The board filled on the 9th category question; the wildcard had nothing left to activate.
    expect(g.eventsOf('TileActivated')).toHaveLength(9);
    expect(g.eventsOf('ScoreAwarded').filter((e) => e.kind === 'LINE').map((e) => e.refId).sort()).toEqual(
      Array.from({ length: 8 }, (_, i) => `LINE:r1:line:${i}`),
    );
    expect(g.eventsOf('ScoreAwarded').filter((e) => e.kind === 'FULL_BOARD')).toEqual([
      expect.objectContaining({ refId: 'FULL_BOARD:r1:full', points: 500 }),
    ]);
    // Tokens: one per line, capped at 3.
    expect(g.state.wallets['ace']!.tokens).toBe(3);
    expect(g.eventsOf('PowerUpGranted')).toHaveLength(3);
    expect(g.state.completedRounds[0]!.podium).toEqual([{ playerId: 'ace', nickname: 'ace', points: 4200 }]);
  });

  it('can also fill the board with 9 of 10 by using the wildcard on the missed tile', () => {
    const g = Game.create();
    g.start();
    g.join('pat');
    g.startRound(boardSetup('r1'));
    g.strategy = ({ question, qIndex, state }) => {
      if (qIndex === 4) return { optionId: wrongOption(question), delayMs: 500 };
      if (qIndex === 9) {
        const missed = state.round!.boards['pat']!.tiles.find((t) => !t.activated)!.position;
        return { optionId: question.correctOptionId, delayMs: 500, wildcardPosition: missed };
      }
      return { optionId: question.correctOptionId, delayMs: 500 };
    };
    g.runUntil((x) => x.state.round === null);
    expect(g.state.totals['pat']).toMatchObject({ correct: 9, lines: 8, fullBoards: 1 });
    expect(g.eventsOf('TileActivated').at(-1)).toMatchObject({ source: 'WILDCARD' });
  });
});

describe('fairness (d): identical answers → identical scores on every board', () => {
  it('exhaustively: for every base row, any set of answered categories completes the same lines count on all 8 symmetries', () => {
    for (let row = 0; row < CATEGORY_COUNT; row++) {
      const base = baseLayout(row, CATS);
      for (let mask = 0; mask < 512; mask++) {
        const answered = new Set(CATS.filter((_, i) => mask & (1 << i)).map((c) => c.key));
        const results = SYMMETRIES.map((_, s) => {
          const board = dealBoard(CATS, row, s);
          for (const t of board.tiles) t.activated = answered.has(t.category);
          return `${completedLines(board.tiles).length}:${isFullBoard(board.tiles)}`;
        });
        expect(new Set(results).size, `row ${row} mask ${mask} base ${base.join()}`).toBe(1);
      }
    }
  });

  /** One shared answer pattern per (round, question); wildcard picks are given in base-layout cells. */
  function patternStrategy(seed: string): AnswerStrategy {
    const rng = createRng(seed);
    const patterns = new Map<string, { kind: 'correct' | 'wrong' | 'skip'; delayMs: number; wildcardBase: number | null }>();
    return ({ round, question, qIndex, playerId, state }): AnswerDecision | null => {
      const key = `${round.id}:${qIndex}`;
      let pat = patterns.get(key);
      if (!pat) {
        const roll = rng();
        const windowMs = qIndex === round.wildcardIndex || question.difficulty === 'DIFFICULT' ? DEFAULT_TIMINGS.difficultWindowMs : DEFAULT_TIMINGS.windowMs;
        pat = {
          kind: roll < 0.62 ? 'correct' : roll < 0.85 ? 'wrong' : 'skip',
          // Occasionally past the close → TOO_LATE for everybody alike.
          delayMs: Math.floor(rng() * (windowMs + 600)),
          wildcardBase: rng() < 0.5 ? null : Math.floor(rng() * 9),
        };
        patterns.set(key, pat);
      }
      if (pat.kind === 'skip') return null;
      const board = state.round!.boards[playerId]!;
      const isWildcard = qIndex === round.wildcardIndex;
      return {
        optionId: pat.kind === 'correct' ? question.correctOptionId : wrongOption(question),
        delayMs: pat.delayMs,
        // The same structural cell on each board: base cell mapped through the player's symmetry.
        wildcardPosition: isWildcard && pat.wildcardBase !== null ? (SYMMETRIES[board.symmetry]!.permutation[pat.wildcardBase] as number) : null,
      };
    };
  }

  const SEEDS = ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot'];

  it.each(SEEDS)('seed %s: 8 players on all 8 symmetries across all 9 base rows score identically', (seed) => {
    const g = Game.create({ sessionId: `fair-${seed}` });
    g.packs['originals-v1'] = makePack(CATS, 12, 'fair');
    g.strategy = patternStrategy(seed);
    g.start();
    for (const p of PLAYERS) g.join(p);
    g.runUntil((x) => x.state.completedRounds.length === 9);

    const rounds = g.eventsOf('RoundStarted');
    expect(rounds).toHaveLength(9);
    expect(new Set(rounds.map((r) => r.baseLayoutRow)).size).toBe(9);
    for (const r of rounds) {
      const dealt = g.eventsOf('BoardDealt').filter((b) => b.roundId === r.roundId);
      expect(new Set(dealt.map((b) => b.symmetry)).size).toBe(8);
      const points = pointsByPlayer(g.events, r.roundId);
      const values = PLAYERS.map((p) => points[p] ?? 0);
      expect(new Set(values).size, `round ${r.index} points ${values.join()}`).toBe(1);
      const lines = PLAYERS.map((p) => g.eventsOf('ScoreAwarded').filter((e) => e.roundId === r.roundId && e.playerId === p && e.kind === 'LINE').length);
      expect(new Set(lines).size).toBe(1);
      const wild = PLAYERS.map((p) => g.eventsOf('TileActivated').filter((e) => e.roundId === r.roundId && e.playerId === p && e.source === 'WILDCARD').length);
      expect(new Set(wild).size).toBe(1);
    }
    const totals = PLAYERS.map((p) => g.state.totals[p]!);
    for (const t of totals) expect(t).toEqual(totals[0]);
    const wallets = PLAYERS.map((p) => ({ tokens: g.state.wallets[p]!.tokens, grants: g.state.wallets[p]!.grants.length }));
    for (const w of wallets) expect(w).toEqual(wallets[0]);
    // Sanity: the pattern exercised lines, wildcard activations and late answers.
    expect(totals[0]!.lines).toBeGreaterThan(0);
    expect(g.eventsOf('TileActivated').some((e) => e.source === 'WILDCARD')).toBe(true);
  });

  it('explicit wildcard picks really are honoured (not just auto-picked) and stay fair', () => {
    const g = Game.create({ sessionId: 'fair-explicit' });
    g.start();
    for (const p of PLAYERS) g.join(p);
    g.startRound(boardSetup('r1'));
    // Answer only q0 (category at base cell row-dependent) correctly, then pick base cell 8 with the wildcard.
    const baseRow = g.round.baseLayoutRow;
    const base = baseLayout(baseRow, CATS);
    g.strategy = ({ question, qIndex, playerId, state }) => {
      if (qIndex === 9) {
        const sym = state.round!.boards[playerId]!.symmetry;
        return { optionId: question.correctOptionId, delayMs: 1_000, wildcardPosition: SYMMETRIES[sym]!.permutation[8] as number };
      }
      return qIndex === 0 ? { optionId: question.correctOptionId, delayMs: 1_000 } : null;
    };
    g.runUntil((x) => x.state.round === null);
    const wild = g.eventsOf('TileActivated').filter((e) => e.source === 'WILDCARD');
    expect(wild).toHaveLength(8);
    for (const e of wild) {
      const sym = g.eventsOf('BoardDealt').find((b) => b.playerId === e.playerId)!.symmetry;
      expect(e.position).toBe(SYMMETRIES[sym]!.permutation[8]);
    }
    const points = pointsByPlayer(g.events);
    expect(new Set(PLAYERS.map((p) => points[p])).size).toBe(1);
    expect(base).toHaveLength(9);
  });

  it('players who join later (other symmetries) with the same answers also tie', () => {
    const ids: Id[] = [];
    const g = Game.create({ sessionId: 'fair-late' });
    g.start();
    for (let i = 0; i < 11; i++) {
      ids.push(`x${i}`);
      g.join(`x${i}`);
    }
    g.startRound(boardSetup('r1'));
    g.strategy = ({ question, qIndex }) => (qIndex % 2 === 0 ? { optionId: question.correctOptionId, delayMs: 2_000 + qIndex * 100 } : null);
    g.runUntil((x) => x.state.round === null);
    const points = pointsByPlayer(g.events);
    expect(new Set(ids.map((p) => points[p])).size).toBe(1);
  });
});
