/**
 * The default 50-minute championship played end to end by bots through reduce(), with power-ups,
 * Final Rush finalists and predictions. Every accepted command is followed by leak checks on all
 * views and a consistency check that `available` power-ups match what reduce() would accept.
 */
import { beforeAll, describe, expect, it } from 'vitest';
import {
  DEFAULT_CHAMPIONSHIP_PLAN,
  botDecision,
  hashString,
  hostView,
  privateView,
  publicView,
  reduce,
  windowFor,
  type DomainEvent,
  type GameState,
  type PowerUpType,
} from '../src/index';
import { CATS, Game, MUSIC_CATS, makePack } from './helpers';

const BOTS = Array.from({ length: 14 }, (_, i) => `bot${i}`);
const SKILL: Record<string, number> = Object.fromEntries(BOTS.map((id, i) => [id, 0.15 + (i % 7) * 0.12]));
const TEAM_OF = (i: number): { id: string; name: string } | null =>
  i < 9 ? { id: `T${Math.floor(i / 3) + 1}`, name: `Table ${Math.floor(i / 3) + 1}` } : i === 9 ? { id: 'T4', name: 'Lone Wolf' } : null;

const hasAnswerKey = (v: unknown): boolean => JSON.stringify(v).includes('"correctOptionId"');

function checkViews(g: Game, consistency: boolean): void {
  const s = g.state;
  const r = s.round;
  const revealed = r?.phase === 'REVEALED';
  const pub = publicView(s);
  if (!revealed) {
    expect(pub.reveal).toBeNull();
    expect(hasAnswerKey(pub)).toBe(false);
    expect(hasAnswerKey(hostView(s))).toBe(false);
  } else {
    expect(pub.reveal!.correctOptionId).toBe(r.questions[r.qIndex]!.correctOptionId);
  }
  if (r?.phase === 'PENDING') expect([pub.question!.prompt, pub.question!.options]).toEqual([null, null]);
  for (const id of BOTS) {
    const me = privateView(s, id)!;
    if (!revealed) {
      expect(me.lastResult).toBeNull();
      expect(hasAnswerKey(me)).toBe(false);
    }
    expect(me.powerUps.tokens + me.powerUps.used.length).toBeLessThanOrEqual(3);
    if (!consistency || !me.inRound) continue;
    for (const type of ['DOUBLE_DOWN', 'SECOND_CHANCE', 'CATEGORY_SWAP'] as PowerUpType[]) {
      const free = r?.boards[id]?.tiles.filter((t) => !t.activated).map((t) => t.position) ?? [];
      const res = reduce(s, {
        type: 'USE_POWERUP',
        now: g.now,
        playerId: id,
        powerUp: type,
        ...(type === 'CATEGORY_SWAP' && free.length >= 2 ? { from: free[0]!, to: free[1]! } : {}),
        idempotencyKey: `probe:${g.commands.length}:${type}`,
      });
      expect(res.reply.ok, `${id} ${type} in ${r?.phase} q${r?.qIndex}: ${res.reply.error?.code}`).toBe(me.powerUps.available.includes(type));
    }
  }
}

function playChampionship(observe: boolean): Game {
  const g = Game.create({ sessionId: 'champ-1', eventId: 'champ-event', mode: 'CHAMPIONSHIP' });
  g.packs['originals-v1'] = makePack(CATS, 12, 'orig');
  g.packs['music-mayhem-v1'] = makePack(MUSIC_CATS, 4, 'mus');
  BOTS.forEach((id, i) => g.join(id, `Bot ${i}`, TEAM_OF(i), true));
  g.strategy = ({ question, round, qIndex, playerId, state }) => {
    const windowMs = windowFor(question, qIndex === round.wildcardIndex, state.settings.timings);
    const d = botDecision({ seed: `${round.seed}:${playerId}`, question, skill: SKILL[playerId]!, windowMs });
    return { optionId: d.optionId, delayMs: d.delayMs };
  };
  g.hooks.push((game, ev) => {
    const s = game.state;
    const can = (id: string, type: PowerUpType): boolean => privateView(s, id)?.powerUps.available.includes(type) ?? false;
    if (ev.type === 'RoundStarted' || ev.type === 'QuestionPending') {
      for (const id of BOTS) {
        if (SKILL[id]! >= 0.6 && can(id, 'DOUBLE_DOWN')) {
          game.schedule(game.now + 50, (now) => ({ type: 'USE_POWERUP', now, playerId: id, powerUp: 'DOUBLE_DOWN', idempotencyKey: `dd:${id}` }));
        } else if (can(id, 'CATEGORY_SWAP')) {
          game.schedule(game.now + 80, (now) => {
            const free = game.state.round?.boards[id]?.tiles.filter((t) => !t.activated).map((t) => t.position) ?? [];
            return { type: 'USE_POWERUP', now, playerId: id, powerUp: 'CATEGORY_SWAP', from: free[0] ?? 0, to: free[free.length - 1] ?? 1, idempotencyKey: `cs:${id}` };
          });
        }
      }
    }
    if (ev.type === 'QuestionOpened') {
      for (const id of BOTS) {
        if (SKILL[id]! < 0.6 && can(id, 'SECOND_CHANCE')) {
          game.schedule(ev.openedAt + 300, (now) => ({ type: 'USE_POWERUP', now, playerId: id, powerUp: 'SECOND_CHANCE', idempotencyKey: `sc:${id}` }));
        }
      }
    }
    if (ev.type === 'RoundStarted' && ev.kind === 'FINAL_RUSH') {
      const finalists = s.round!.finalists!;
      for (const id of BOTS) {
        if (finalists.includes(id)) continue;
        const pick = finalists[hashString(id) % finalists.length]!;
        game.schedule(game.now + 500, (now) => ({ type: 'PREDICT', now, playerId: id, finalistId: pick, idempotencyKey: `pr:${id}` }));
      }
    }
  });
  if (observe) {
    g.observe = (game) => {
      const last = game.results[game.results.length - 1]!;
      const phaseChange = last.events.some((e) => ['RoundStarted', 'QuestionPending', 'QuestionOpened', 'QuestionRevealed'].includes(e.type));
      checkViews(game, phaseChange);
    };
  }
  g.start();
  g.runUntil((x) => x.state.status === 'COMPLETED');
  return g;
}

describe('default championship, end to end', () => {
  let g: Game;
  beforeAll(() => {
    g = playChampionship(true);
  }, 120_000);
  const ev = <T extends DomainEvent['type']>(type: T) => g.eventsOf(type);

  it('plays the 9-round plan in order and completes automatically', () => {
    expect(g.state.completedRounds.map((r) => r.kind)).toEqual(DEFAULT_CHAMPIONSHIP_PLAN.map((p) => p.kind));
    expect(g.state.completedRounds.map((r) => r.label)).toEqual(DEFAULT_CHAMPIONSHIP_PLAN.map((p) => p.label));
    expect(g.state.planIndex).toBe(9);
    expect(g.events.at(-1)!.type).toBe('SessionCompleted');
    expect(ev('SessionCompleted')).toHaveLength(1);
    expect(ev('SessionCompleted')[0]!.cancelledRoundId).toBeUndefined();
    expect(ev('SessionCompleted')[0]!.at).toBe(ev('RoundCompleted').at(-1)!.at);
    expect(g.state.round).toBeNull();
    const duration = g.state.completedAt! - g.state.startedAt!;
    expect(duration).toBeGreaterThan(25 * 60_000);
    expect(duration).toBeLessThan(46 * 60_000); // + 5 min lobby ≈ 50 min show
  });

  it('uses round breaks inside a segment and segment breaks between segments', () => {
    const completed = ev('RoundCompleted');
    const due = ev('RoundDue');
    expect(due).toHaveLength(9);
    for (let i = 1; i < 9; i++) {
      const gap = due[i]!.at - completed[i - 1]!.at;
      const sameSegment = DEFAULT_CHAMPIONSHIP_PLAN[i]!.segment === DEFAULT_CHAMPIONSHIP_PLAN[i - 1]!.segment;
      expect(gap).toBe(sameSegment ? 20_000 : 30_000);
    }
  });

  it('every player’s total equals the sum of their ScoreAwarded events, with unique refIds', () => {
    const sums: Record<string, number> = {};
    const refs = new Set<string>();
    for (const e of ev('ScoreAwarded')) {
      sums[e.playerId] = (sums[e.playerId] ?? 0) + e.points;
      const key = `${e.playerId}|${e.kind}|${e.refId}`;
      expect(refs.has(key)).toBe(false);
      refs.add(key);
      expect(e.refId.startsWith(`${e.kind}:`)).toBe(true);
    }
    for (const id of BOTS) {
      expect(g.state.totals[id]!.points).toBe(sums[id] ?? 0);
      expect(g.state.totals[id]!.points).toBeGreaterThanOrEqual(0);
    }
    const standings = ev('SessionCompleted')[0]!.standings;
    expect(standings.map((s) => s.rank)).toEqual(BOTS.map((_, i) => i + 1));
  });

  it('exercised boards, lines, table challenges, and all three power-ups within the event cap', () => {
    const kinds = new Set(ev('ScoreAwarded').map((e) => e.kind));
    expect([...kinds].sort()).toEqual(['CORRECT', 'DOUBLE_DOWN', 'LINE', 'SPEED_BONUS', 'TABLE_CHALLENGE'].concat(kinds.has('FULL_BOARD') ? ['FULL_BOARD'] : []).sort());
    const used = new Set(ev('PowerUpUsed').map((e) => e.powerUp));
    expect(used).toEqual(new Set(['DOUBLE_DOWN', 'SECOND_CHANCE', 'CATEGORY_SWAP']));
    for (const id of BOTS) {
      const w = g.state.wallets[id]!;
      expect(w.tokens + w.used.length).toBeLessThanOrEqual(3);
      expect(new Set(w.used).size).toBe(w.used.length);
    }
    // Solo players never win table challenges when mergeSolos=false.
    for (const e of ev('QuestionRevealed')) for (const w of e.result.challengeWinners ?? []) expect(['T1', 'T2', 'T3']).toContain(w);
    // Only board rounds deal boards.
    const boardRounds = ev('RoundStarted').filter((r) => ['RUSH', 'MUSIC_MAYHEM', 'POWER_ROUND'].includes(r.kind)).map((r) => r.roundId);
    expect(new Set(ev('BoardDealt').map((b) => b.roundId))).toEqual(new Set(boardRounds));
    // Only expected refusals: a bot answering with an option its own Second Chance removed.
    expect(new Set(g.rejections.map((r) => r.code))).toEqual(new Set(g.rejections.length ? ['INVALID_OPTION'] : []));
  });

  it('Final Rush: top 5 by session standings play; everyone else predicts; predictions do not score', () => {
    const before = ev('RoundCompleted')[7]!.standings; // standings when the Final Rush started
    const fr = ev('RoundStarted')[8]!;
    expect(fr.kind).toBe('FINAL_RUSH');
    const top5 = before.slice(0, 5).map((s) => s.playerId);
    expect([...fr.participants].sort()).toEqual([...top5].sort());
    const frAnswers = ev('AnswerAccepted').filter((a) => a.roundId === fr.roundId);
    expect(frAnswers.length).toBeGreaterThan(0);
    expect(frAnswers.every((a) => top5.includes(a.playerId))).toBe(true);
    const predictions = ev('PredictionMade');
    expect(predictions).toHaveLength(BOTS.length - 5);
    expect(predictions.every((p) => top5.includes(p.finalistId) && !top5.includes(p.playerId))).toBe(true);
    const frScores = ev('ScoreAwarded').filter((e) => e.roundId === fr.roundId);
    expect(frScores.every((e) => top5.includes(e.playerId))).toBe(true);
  });

  it('replaying the command log reproduces the identical state and events (determinism)', () => {
    let state: GameState = g.initial;
    const events: DomainEvent[] = [];
    for (const c of g.commands) {
      const r = reduce(state, c);
      state = r.state;
      events.push(...r.events);
    }
    expect(state).toEqual(g.state);
    expect(events).toEqual(g.events);
  });

  it('playing the whole championship again from scratch gives deep-equal results', { timeout: 60_000 }, () => {
    const again = playChampionship(false);
    expect(again.commands).toEqual(g.commands);
    expect(again.events).toEqual(g.events);
    expect(again.state).toEqual(g.state);
  });
});
