import { describe, expect, it } from 'vitest';
import { POINTS, needsRound, privateView, reduce, type GameState, type PowerUpType, type RoundPlanItem } from '../src/index';
import { Game, boardSetup, deepFreeze, tableWarsSetup, tfQuestion, wrongOption } from './helpers';

/**
 * Round 1: 'rich' answers everything (3 tokens, 4000 points), 'poor' nothing. Returns at round 2 INTRO.
 * With `tableWars` the session is a championship whose second round is Table Wars.
 */
function richGame(opts: { r2?: Parameters<typeof boardSetup>[1]; settings?: { powerUpsEnabled?: boolean }; tableWars?: boolean } = {}): Game {
  const plan: RoundPlanItem[] = [
    { kind: 'RUSH', packKey: 'test', label: 'The Rush', segment: 0 },
    { kind: 'TABLE_WARS', packKey: 'test', label: 'Table Wars', segment: 1 },
  ];
  const g = Game.create(opts.tableWars ? { mode: 'CHAMPIONSHIP', plan, settings: opts.settings ?? {} } : { settings: opts.settings ?? {} });
  g.start();
  g.join('rich');
  g.join('poor');
  g.startRound(boardSetup('r1'));
  g.strategy = ({ question, playerId }) => (playerId === 'rich' ? { optionId: question.correctOptionId, delayMs: 500 } : null);
  g.runUntil((x) => x.state.round === null);
  g.strategy = null;
  g.runUntil((x) => needsRound(x.state));
  g.startRound(opts.tableWars ? tableWarsSetup('tw') : boardSetup('r2', opts.r2));
  return g;
}

function use(g: Game, playerId: string, powerUp: PowerUpType, extra: { questionId?: string; from?: number; to?: number; key?: string } = {}) {
  return g.send({
    type: 'USE_POWERUP',
    now: g.now,
    playerId,
    powerUp,
    ...(extra.questionId !== undefined ? { questionId: extra.questionId } : {}),
    ...(extra.from !== undefined ? { from: extra.from } : {}),
    ...(extra.to !== undefined ? { to: extra.to } : {}),
    idempotencyKey: extra.key ?? `${playerId}:${powerUp}`,
  });
}

const available = (g: Game, id: string) => privateView(g.state, id)!.powerUps.available;

describe('tokens', () => {
  it('a perfect round grants 3 tokens; nothing without tokens', () => {
    const g = richGame();
    expect(g.state.wallets['rich']).toMatchObject({ tokens: 3, used: [] });
    expect(use(g, 'poor', 'DOUBLE_DOWN').reply.error?.code).toBe('NO_TOKEN');
    expect(available(g, 'poor')).toEqual([]);
  });

  it('tokens + used never exceed 3, across rounds', () => {
    const g = richGame();
    expect(use(g, 'rich', 'DOUBLE_DOWN').reply.ok).toBe(true);
    expect(g.state.wallets['rich']).toMatchObject({ tokens: 2, used: ['DOUBLE_DOWN'] });
    g.strategy = ({ question, playerId }) => (playerId === 'rich' ? { optionId: question.correctOptionId, delayMs: 500 } : null);
    g.runUntil((x) => x.state.round === null);
    // 8 more lines, but the cap (2 tokens + 1 used = 3) allows no new grant.
    expect(g.state.wallets['rich']).toMatchObject({ tokens: 2, used: ['DOUBLE_DOWN'] });
    expect(g.eventsOf('PowerUpGranted').filter((e) => e.roundId === 'r2')).toEqual([]);
  });

  it('powerUpsEnabled=false: no tokens are granted and use is refused', () => {
    const g = richGame({ settings: { powerUpsEnabled: false } });
    expect(g.state.wallets['rich']!.tokens).toBe(0);
    expect(g.eventsOf('PowerUpGranted')).toEqual([]);
    expect(use(g, 'rich', 'DOUBLE_DOWN').reply.error?.code).toBe('POWERUPS_DISABLED');
    expect(privateView(g.state, 'rich')!.powerUps).toMatchObject({ enabled: false, available: [] });
  });
});

describe('Double Down', () => {
  it('in INTRO targets Q1; correct → +200 with refId DOUBLE_DOWN:<round>:<question>', () => {
    const g = richGame();
    expect(available(g, 'rich')).toEqual(['DOUBLE_DOWN', 'CATEGORY_SWAP']);
    const res = use(g, 'rich', 'DOUBLE_DOWN');
    expect(res.reply).toEqual({ ok: true });
    expect(res.events).toEqual([
      { type: 'PowerUpUsed', at: g.now, roundId: 'r2', playerId: 'rich', powerUp: 'DOUBLE_DOWN', questionId: 'r2-q0', payload: {}, idempotencyKey: 'rich:DOUBLE_DOWN' },
    ]);
    expect(privateView(g.state, 'rich')!.powerUps.doubleDownOn).toBe('r2-q0');
    const before = g.state.totals['rich']!.points;
    g.advanceTo('OPEN', 0);
    g.later(500);
    g.answer('rich', 'a');
    g.advanceTo('REVEALED', 0);
    const dd = g.eventsOf('ScoreAwarded').filter((e) => e.kind === 'DOUBLE_DOWN');
    expect(dd).toEqual([expect.objectContaining({ playerId: 'rich', refId: 'DOUBLE_DOWN:r2:r2-q0', points: POINTS.DOUBLE_DOWN_WIN })]);
    expect(g.state.totals['rich']!.points - before).toBe(100 + 50 + 200);
    expect(privateView(g.state, 'rich')!.lastResult).toMatchObject({ doubleDown: 'WON', correct: true });
    expect(privateView(g.state, 'rich')!.powerUps.doubleDownOn).toBeNull();
  });

  it('wrong answer → −100', () => {
    const g = richGame();
    g.advanceTo('PENDING', 0);
    expect(use(g, 'rich', 'DOUBLE_DOWN', { questionId: 'r2-q0' }).reply.ok).toBe(true);
    g.advanceTo('OPEN', 0);
    g.later(500);
    g.answer('rich', 'b');
    g.advanceTo('REVEALED', 0);
    expect(g.eventsOf('ScoreAwarded').filter((e) => e.roundId === 'r2')).toEqual([expect.objectContaining({ kind: 'DOUBLE_DOWN', points: -100 })]);
    expect(privateView(g.state, 'rich')!.lastResult).toMatchObject({ doubleDown: 'LOST', answered: true, correct: false });
  });

  it('no answer → −100', () => {
    const g = richGame();
    g.advanceTo('REVEALED', 0);
    expect(use(g, 'rich', 'DOUBLE_DOWN').events[0]).toMatchObject({ questionId: 'r2-q1' }); // REVEALED → next question
    const before = g.state.totals['rich']!.points;
    g.advanceTo('REVEALED', 1);
    expect(g.state.totals['rich']!.points - before).toBe(-100);
    expect(privateView(g.state, 'rich')!.lastResult).toMatchObject({ doubleDown: 'LOST', answered: false });
  });

  it('phase rules: not while OPEN/LOCKED, not after the last question, not in ROUND_OVER; question must be the next one', () => {
    const g = richGame();
    expect(use(g, 'rich', 'DOUBLE_DOWN', { questionId: 'r2-q5' }).reply.error?.code).toBe('WRONG_QUESTION');
    g.advanceTo('OPEN', 0);
    expect(use(g, 'rich', 'DOUBLE_DOWN').reply.error?.code).toBe('WRONG_PHASE');
    expect(available(g, 'rich')).not.toContain('DOUBLE_DOWN');
    g.advanceTo('LOCKED', 0);
    expect(use(g, 'rich', 'DOUBLE_DOWN').reply.error?.code).toBe('WRONG_PHASE');
    g.advanceTo('PENDING', 9);
    expect(available(g, 'rich')).toContain('DOUBLE_DOWN'); // wildcard still upcoming
    g.advanceTo('REVEALED', 9);
    expect(use(g, 'rich', 'DOUBLE_DOWN').reply.error?.code).toBe('WRONG_PHASE');
    expect(available(g, 'rich')).toEqual([]);
    g.advanceTo('ROUND_OVER');
    expect(use(g, 'rich', 'DOUBLE_DOWN').reply.error?.code).toBe('WRONG_PHASE');
  });

  it('needs at least 100 points', () => {
    const g = richGame();
    const s: GameState = structuredClone(g.state);
    s.wallets['poor']!.tokens = 1;
    s.totals['poor']!.points = 99;
    const res = reduce(deepFreeze(s), { type: 'USE_POWERUP', now: g.now, playerId: 'poor', powerUp: 'DOUBLE_DOWN', idempotencyKey: 'x' });
    expect(res.reply.error?.code).toBe('INSUFFICIENT_BALANCE');
    const s2: GameState = structuredClone(s);
    s2.totals['poor']!.points = 100;
    expect(reduce(s2, { type: 'USE_POWERUP', now: g.now, playerId: 'poor', powerUp: 'DOUBLE_DOWN', idempotencyKey: 'x' }).reply.ok).toBe(true);
  });

  it('one use per type per event, even in a later round; idempotent replays', () => {
    const g = richGame();
    expect(use(g, 'rich', 'DOUBLE_DOWN', { key: 'dd-1' }).reply.ok).toBe(true);
    const state = g.state;
    const replay = use(g, 'rich', 'DOUBLE_DOWN', { key: 'dd-1' });
    expect(replay.reply).toEqual({ ok: true, duplicate: true });
    expect(replay.events).toEqual([]);
    expect(g.state).toBe(state);
    expect(use(g, 'rich', 'DOUBLE_DOWN', { key: 'dd-2' }).reply.error?.code).toBe('POWERUP_ALREADY_USED');
    g.runUntil((x) => needsRound(x.state));
    g.startRound(boardSetup('r3'));
    expect(use(g, 'rich', 'DOUBLE_DOWN', { key: 'dd-3' }).reply.error?.code).toBe('POWERUP_ALREADY_USED');
    expect(available(g, 'rich')).toEqual(['CATEGORY_SWAP']);
  });

  it('works in Table Wars too, and a kicked player’s pending Double Down is dropped', () => {
    const g = richGame({ tableWars: true });
    g.join('kicky');
    const s: GameState = structuredClone(g.state);
    s.wallets['kicky']!.tokens = 1;
    s.totals['kicky']!.points = 500;
    g.state = deepFreeze(s);
    expect(g.round.kind).toBe('TABLE_WARS');
    expect(use(g, 'rich', 'DOUBLE_DOWN').reply.ok).toBe(true);
    expect(use(g, 'kicky', 'DOUBLE_DOWN').reply.ok).toBe(true);
    g.must({ type: 'KICK_PLAYER', now: g.now, playerId: 'kicky' });
    expect(g.round.doubleDowns['tw-q0']).toEqual(['rich']);
  });
});

describe('Second Chance', () => {
  it('removes one incorrect option (deterministically), returns it, and blocks answering it', () => {
    const g = richGame();
    g.advanceTo('OPEN', 0);
    expect(available(g, 'rich')).toEqual(['SECOND_CHANCE']);
    const res = use(g, 'rich', 'SECOND_CHANCE');
    const removed = res.reply.data?.removedOptionId;
    expect(removed).toBeDefined();
    expect(removed).not.toBe(g.question.correctOptionId);
    expect(g.question.options.map((o) => o.id)).toContain(removed);
    expect(res.events[0]).toMatchObject({ type: 'PowerUpUsed', powerUp: 'SECOND_CHANCE', questionId: 'r2-q0', payload: { removedOptionId: removed } });
    expect(privateView(g.state, 'rich')!.powerUps.removedOptionId).toBe(removed);
    // Replay: same reply data, no change.
    expect(use(g, 'rich', 'SECOND_CHANCE').reply).toEqual({ ok: true, duplicate: true, data: { removedOptionId: removed } });
    // The same seed/question/player always removes the same option.
    const again = richGame();
    again.advanceTo('OPEN', 0);
    expect(use(again, 'rich', 'SECOND_CHANCE').reply.data?.removedOptionId).toBe(removed);
    expect(g.answer('rich', removed!).reply.error?.code).toBe('INVALID_OPTION');
    expect(g.answer('rich', 'a').reply.ok).toBe(true);
  });

  it('only while OPEN, before answering, on multiple choice with 3+ options', () => {
    const g = richGame({ r2: { tweak: (i) => (i === 1 ? tfQuestion('tf', 'hist') : {}) } });
    expect(use(g, 'rich', 'SECOND_CHANCE').reply.error?.code).toBe('WRONG_PHASE');
    g.advanceTo('OPEN', 0);
    g.later(100);
    g.answer('rich', 'a');
    g.advanceTo('PENDING', 1);
    expect(use(g, 'rich', 'SECOND_CHANCE').reply.error?.code).toBe('WRONG_PHASE');
    g.advanceTo('OPEN', 1);
    expect(g.question.type).toBe('TRUE_FALSE');
    expect(use(g, 'rich', 'SECOND_CHANCE').reply.error?.code).toBe('POWERUP_NOT_APPLICABLE');
    expect(available(g, 'rich')).toEqual([]);
    g.advanceTo('OPEN', 2);
    g.later(100);
    g.answer('poor', 'a');
    g.answer('rich', 'b');
    // Locked early (both answered) → wrong phase; before lock it would be ALREADY_ANSWERED.
    expect(use(g, 'rich', 'SECOND_CHANCE').reply.error?.code).toBe('WRONG_PHASE');
  });

  it('is refused after answering while the question is still open', () => {
    const g = richGame();
    g.advanceTo('OPEN', 0);
    g.later(100);
    g.answer('rich', 'a');
    expect(g.round.phase).toBe('OPEN'); // poor has not answered
    expect(use(g, 'rich', 'SECOND_CHANCE').reply.error?.code).toBe('ALREADY_ANSWERED');
    // The target question is checked before the answered state.
    expect(use(g, 'rich', 'SECOND_CHANCE', { questionId: 'r2-q3' }).reply.error?.code).toBe('WRONG_QUESTION');
  });

  it('rejects a mismatched question id', () => {
    const g = richGame();
    g.advanceTo('OPEN', 0);
    expect(use(g, 'rich', 'SECOND_CHANCE', { questionId: 'r2-q1' }).reply.error?.code).toBe('WRONG_QUESTION');
  });
});

describe('Category Swap', () => {
  it('swaps two unactivated tiles between questions; the category set is unchanged and activation follows the swap', () => {
    const g = richGame();
    const board = g.round.boards['rich']!;
    const q0Tile = board.tiles.find((t) => t.category === 'pop')!.position;
    const other = q0Tile === 0 ? 8 : 0;
    const res = use(g, 'rich', 'CATEGORY_SWAP', { from: q0Tile, to: other });
    expect(res.reply).toEqual({ ok: true });
    expect(res.events[0]).toMatchObject({ powerUp: 'CATEGORY_SWAP', questionId: null, payload: { from: q0Tile, to: other } });
    const after = g.round.boards['rich']!;
    expect(after.tiles[other]!.category).toBe('pop');
    expect(after.tiles.map((t) => t.position)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]);
    expect(after.tiles.map((t) => t.category).sort()).toEqual(board.tiles.map((t) => t.category).sort());
    g.advanceTo('OPEN', 0);
    g.later(100);
    g.answer('rich', 'a');
    g.advanceTo('REVEALED', 0);
    expect(g.eventsOf('TileActivated').at(-1)).toMatchObject({ playerId: 'rich', position: other });
  });

  it('refuses activated tiles, identical or out-of-range tiles, open questions and boardless rounds', () => {
    const g = richGame();
    g.advanceTo('OPEN', 0);
    expect(use(g, 'rich', 'CATEGORY_SWAP', { from: 0, to: 1 }).reply.error?.code).toBe('WRONG_PHASE');
    g.later(100);
    g.answer('rich', 'a');
    g.advanceTo('REVEALED', 0);
    const activated = g.round.boards['rich']!.tiles.find((t) => t.activated)!.position;
    const free = g.round.boards['rich']!.tiles.filter((t) => !t.activated).map((t) => t.position);
    expect(use(g, 'rich', 'CATEGORY_SWAP', { from: activated, to: free[0]! }).reply.error?.code).toBe('INVALID_TILE');
    expect(use(g, 'rich', 'CATEGORY_SWAP', { from: free[0]!, to: free[0]! }).reply.error?.code).toBe('INVALID_TILE');
    expect(use(g, 'rich', 'CATEGORY_SWAP', { from: free[0]!, to: 9 }).reply.error?.code).toBe('INVALID_TILE');
    expect(use(g, 'rich', 'CATEGORY_SWAP').reply.error?.code).toBe('INVALID_TILE');
    expect(use(g, 'rich', 'CATEGORY_SWAP', { from: free[0]!, to: free[1]! }).reply.ok).toBe(true);
    // Table Wars has no board.
    const tw = richGame({ tableWars: true });
    expect(use(tw, 'rich', 'CATEGORY_SWAP', { from: 0, to: 1 }).reply.error?.code).toBe('POWERUP_NOT_APPLICABLE');
    expect(available(tw, 'rich')).toEqual(['DOUBLE_DOWN']);
  });
});

describe('participation rules for power-ups', () => {
  it('non-participants (joined after the window) cannot use power-ups', () => {
    const g = richGame();
    g.advanceTo('OPEN', 2);
    const s: GameState = structuredClone(g.state);
    // Simulate a token-holding player who is waiting for the next round.
    g.state = deepFreeze(s);
    g.join('waiter');
    const s2: GameState = structuredClone(g.state);
    s2.wallets['waiter']!.tokens = 1;
    s2.totals['waiter']!.points = 300;
    g.state = deepFreeze(s2);
    g.advanceTo('REVEALED', 2);
    expect(use(g, 'waiter', 'DOUBLE_DOWN').reply.error?.code).toBe('NOT_PARTICIPANT');
    expect(available(g, 'waiter')).toEqual([]);
  });

  it('every refusal leaves the state untouched', () => {
    const g = richGame();
    g.advanceTo('OPEN', 0);
    const before = g.state;
    const res = use(g, 'rich', 'CATEGORY_SWAP', { from: 0, to: 1 });
    expect(res.reply.ok).toBe(false);
    expect(res.state).toBe(before);
    expect(wrongOption(g.question)).not.toBe(g.question.correctOptionId);
  });
});
