import { describe, expect, it } from 'vitest';
import {
  compareStandings,
  createSession,
  rankPlayers,
  teamGroups,
  teamStandings,
  type GameState,
  type PlayerTotals,
  type RoundPlanItem,
} from '../src/index';
import { Game, tableWarsSetup, wrongOption, type AnswerDecision } from './helpers';

function stateWith(
  players: { id: string; totals?: Partial<PlayerTotals>; teamId?: string | null; status?: 'ACTIVE' | 'KICKED' | 'LEFT' }[],
  teams: string[] = [],
  mergeSolos = false,
): GameState {
  const s = createSession({ sessionId: 's', eventId: 'e', venueId: 'v', roomId: 'r', mode: 'ALWAYS_ON', settings: { mergeSolos } });
  teams.forEach((id, i) => (s.teams[id] = { id, name: `Team ${id}`, createdAt: i }));
  players.forEach((p, i) => {
    s.players[p.id] = { id: p.id, nickname: p.id.toUpperCase(), teamId: p.teamId ?? null, joinedAt: i, joinIndex: i, status: p.status ?? 'ACTIVE' };
    s.totals[p.id] = { points: 0, correct: 0, answered: 0, correctTimeMs: 0, lastCorrectAt: null, lines: 0, fullBoards: 0, ...p.totals };
  });
  return s;
}

describe('tie-breaks', () => {
  const base = { points: 500, correct: 5, correctTimeMs: 10_000, lastCorrectAt: 1_000, joinIndex: 0 };

  it('1: higher points', () => {
    expect(compareStandings({ ...base, points: 400, joinIndex: 0 }, { ...base, points: 500, joinIndex: 1 })).toBeGreaterThan(0);
  });
  it('2: more correct answers', () => {
    expect(compareStandings({ ...base, correct: 4 }, { ...base, correct: 5, joinIndex: 1 })).toBeGreaterThan(0);
  });
  it('3: lower cumulative correct time', () => {
    expect(compareStandings({ ...base, correctTimeMs: 9_000, joinIndex: 3 }, { ...base, correctTimeMs: 9_001, joinIndex: 0 })).toBeLessThan(0);
  });
  it('4: earlier last correct answer, null last', () => {
    expect(compareStandings({ ...base, lastCorrectAt: 999, joinIndex: 5 }, { ...base, joinIndex: 0 })).toBeLessThan(0);
    expect(compareStandings({ ...base, lastCorrectAt: null }, { ...base, joinIndex: 9 })).toBeGreaterThan(0);
    expect(compareStandings({ ...base, lastCorrectAt: 5 }, { ...base, lastCorrectAt: null, joinIndex: 9 })).toBeLessThan(0);
  });
  it('5: lower joinIndex as the final deterministic fallback', () => {
    expect(compareStandings({ ...base, joinIndex: 1 }, { ...base, joinIndex: 2 })).toBeLessThan(0);
  });

  it('rankPlayers applies every level and gives unique ranks', () => {
    const s = stateWith([
      { id: 'tie-join-b', totals: { points: 100 } },
      { id: 'pts', totals: { points: 900 } },
      { id: 'correct', totals: { points: 600, correct: 3, correctTimeMs: 9_999, lastCorrectAt: 50 } },
      { id: 'time', totals: { points: 600, correct: 2, correctTimeMs: 1_000, lastCorrectAt: 50 } },
      { id: 'last-early', totals: { points: 600, correct: 2, correctTimeMs: 2_000, lastCorrectAt: 10 } },
      { id: 'last-late', totals: { points: 600, correct: 2, correctTimeMs: 2_000, lastCorrectAt: 20 } },
      { id: 'tie-join-c', totals: { points: 100 } },
    ]);
    const ranked = rankPlayers(s);
    expect(ranked.map((r) => r.playerId)).toEqual(['pts', 'correct', 'time', 'last-early', 'last-late', 'tie-join-b', 'tie-join-c']);
    expect(ranked.map((r) => r.rank)).toEqual([1, 2, 3, 4, 5, 6, 7]);
    expect(ranked[1]).toEqual({
      rank: 2,
      playerId: 'correct',
      nickname: 'CORRECT',
      teamId: null,
      points: 600,
      correct: 3,
      correctTimeMs: 9_999,
      lastCorrectAt: 50,
      lines: 0,
    });
  });

  it('KICKED players are removed, LEFT players keep their place', () => {
    const s = stateWith([
      { id: 'a', totals: { points: 300 }, status: 'KICKED' },
      { id: 'b', totals: { points: 200 }, status: 'LEFT' },
      { id: 'c', totals: { points: 100 } },
    ]);
    expect(rankPlayers(s).map((r) => [r.playerId, r.rank])).toEqual([
      ['b', 1],
      ['c', 2],
    ]);
  });
});

describe('team standings', () => {
  const players = [
    { id: 'a1', teamId: 'A', totals: { points: 300, answered: 3 } },
    { id: 'a2', teamId: 'A', totals: { points: 100, answered: 2 } },
    { id: 'a3', teamId: 'A', totals: { points: 0, answered: 0 } }, // never answered → excluded from the mean
    { id: 'b1', teamId: 'B', totals: { points: 100, answered: 1 } },
    { id: 'b2', teamId: 'B', totals: { points: 0, answered: 1 } },
    { id: 'b3', teamId: 'B', totals: { points: 0, answered: 1 } },
    { id: 'lone', teamId: 'L', totals: { points: 250, answered: 2 } }, // team of one → solo
    { id: 'free', totals: { points: 400, answered: 4 } }, // no team → solo:free
    { id: 'gone', teamId: 'B', totals: { points: 9_999, answered: 9 }, status: 'KICKED' as const },
  ];

  it('mergeSolos=false: teams board (2+ members) and a separate solo board', () => {
    const { teams, solos } = teamStandings(stateWith(players, ['A', 'B', 'L', 'EMPTY']));
    expect(teams).toEqual([
      { rank: 1, teamId: 'A', name: 'Team A', score: 200, members: 3, activeMembers: 2, isSolo: false },
      { rank: 2, teamId: 'B', name: 'Team B', score: 33.3, members: 3, activeMembers: 3, isSolo: false },
    ]);
    expect(solos).toEqual([
      { rank: 1, teamId: 'solo:free', name: 'FREE', score: 400, members: 1, activeMembers: 1, isSolo: true },
      { rank: 2, teamId: 'L', name: 'Team L', score: 250, members: 1, activeMembers: 1, isSolo: true },
    ]);
  });

  it('mergeSolos=true: everyone on one board', () => {
    const { teams, solos } = teamStandings(stateWith(players, ['A', 'B', 'L'], true));
    expect(solos).toBeNull();
    expect(teams.map((t) => [t.teamId, t.score, t.isSolo, t.rank])).toEqual([
      ['solo:free', 400, true, 1],
      ['L', 250, true, 2],
      ['A', 200, false, 3],
      ['B', 33.3, false, 4],
    ]);
  });

  it('a team whose members never answered scores 0; equal scores fall back to the best member rank', () => {
    const s = stateWith(
      [
        { id: 'x1', teamId: 'X', totals: { points: 100, answered: 1, correct: 1 } },
        { id: 'x2', teamId: 'X', totals: { points: 100, answered: 1, correct: 0 } },
        { id: 'y1', teamId: 'Y', totals: { points: 100, answered: 1, correct: 2 } },
        { id: 'y2', teamId: 'Y', totals: { points: 100, answered: 1 } },
        { id: 'z1', teamId: 'Z' },
        { id: 'z2', teamId: 'Z' },
      ],
      ['X', 'Y', 'Z'],
    );
    expect(teamStandings(s).teams.map((t) => [t.teamId, t.score, t.activeMembers])).toEqual([
      ['Y', 100, 2],
      ['X', 100, 2],
      ['Z', 0, 0],
    ]);
  });

  it('teamGroups drops empty teams and treats teams that shrank to one as solo', () => {
    const s = stateWith(
      [
        { id: 'p1', teamId: 'T' },
        { id: 'p2', teamId: 'T', status: 'KICKED' },
      ],
      ['T', 'EMPTY'],
    );
    expect(teamGroups(s)).toEqual([{ id: 'T', name: 'Team T', memberIds: ['p1'], isSolo: true }]);
  });
});

describe('Table Wars (normalized team challenges)', () => {
  const PLAN: RoundPlanItem[] = [{ kind: 'TABLE_WARS', packKey: 'test', label: 'Table Wars', segment: 0 }];

  function tableWars(mergeSolos = false): Game {
    const g = Game.create({ mode: 'CHAMPIONSHIP', plan: PLAN, settings: { mergeSolos } });
    g.join('a1', 'a1', { id: 'A', name: 'Aces' });
    g.join('a2', 'a2', { id: 'A', name: 'Aces' });
    for (let i = 1; i <= 5; i++) g.join(`b${i}`, `b${i}`, { id: 'B', name: 'Bees' });
    g.join('c1', 'c1', { id: 'C', name: 'Cees' });
    g.join('c2', 'c2', { id: 'C', name: 'Cees' });
    g.join('solo');
    g.start();
    g.startRound(tableWarsSetup('tw'));
    return g;
  }

  /** Per question: who answers correctly and when (ms after open); everyone else answers wrong at 4 s. */
  const PLAYS: Record<number, Record<string, number>> = {
    // A (2/2) and B (5/5) both perfect with the same mean time → both win.
    0: { a1: 2_000, a2: 4_000, b1: 1_000, b2: 2_000, b3: 3_000, b4: 4_000, b5: 5_000, c1: 1_000, solo: 500 },
    // Equal accuracy, A faster on average → only A.
    1: { a1: 1_000, a2: 1_000, b1: 3_000, b2: 3_000, b3: 3_000, b4: 3_000, b5: 3_000 },
    // Nobody right.
    2: {},
    // A 1/2 = 0.5 vs B 3/5 = 0.6 → B.
    3: { a1: 1_000, b1: 9_000, b2: 9_000, b3: 9_000 },
  };

  function play(g: Game): void {
    g.strategy = ({ question, qIndex, playerId }): AnswerDecision | null => {
      const plays = PLAYS[qIndex];
      if (!plays) return null;
      const at = plays[playerId];
      return at !== undefined ? { optionId: question.correctOptionId, delayMs: at } : { optionId: wrongOption(question), delayMs: 4_000 };
    };
  }

  it('2-person and 5-person teams at equal accuracy and time both win; every participating member gets +200', () => {
    const g = tableWars();
    play(g);
    g.advanceTo('REVEALED', 0);
    const result = g.round.results['tw-q0']!;
    expect(result.challengeWinners).toEqual(['A', 'B']);
    const challenge = g.eventsOf('ScoreAwarded').filter((e) => e.kind === 'TABLE_CHALLENGE');
    expect(challenge.map((e) => e.playerId)).toEqual(['a1', 'a2', 'b1', 'b2', 'b3', 'b4', 'b5']);
    expect(challenge.every((e) => e.points === 200 && e.refId === 'TABLE_CHALLENGE:tw:tw-q0')).toBe(true);
    // Normalized: both team means rose by exactly 200 on top of their answer points.
    const teams = teamStandings(g.state).teams;
    const answerPoints = (ids: string[]) => ids.reduce((sum, id) => sum + g.round.answers['tw-q0']![id]!.points! + g.round.answers['tw-q0']![id]!.speedBonus!, 0) / ids.length;
    expect(teams.find((t) => t.teamId === 'A')!.score).toBeCloseTo(answerPoints(['a1', 'a2']) + 200, 1);
    expect(teams.find((t) => t.teamId === 'B')!.score).toBeCloseTo(answerPoints(['b1', 'b2', 'b3', 'b4', 'b5']) + 200, 1);
    // Solo players are not eligible when mergeSolos=false.
    expect(challenge.some((e) => e.playerId === 'solo')).toBe(false);
  });

  it('time breaks accuracy ties; no correct answers → no winners; higher accuracy beats a bigger headcount', () => {
    const g = tableWars();
    play(g);
    g.advanceTo('REVEALED', 1);
    expect(g.round.results['tw-q1']!.challengeWinners).toEqual(['A']);
    g.advanceTo('REVEALED', 2);
    expect(g.round.results['tw-q2']!.challengeWinners).toEqual([]);
    g.advanceTo('REVEALED', 3);
    expect(g.round.results['tw-q3']!.challengeWinners).toEqual(['B']);
    // Winners' members all got the award, including b4/b5 who answered wrong.
    expect(g.eventsOf('ScoreAwarded').filter((e) => e.refId === 'TABLE_CHALLENGE:tw:tw-q3').map((e) => e.playerId)).toEqual(['b1', 'b2', 'b3', 'b4', 'b5']);
  });

  it('mergeSolos=true: solo players compete as one-person tables', () => {
    const g = tableWars(true);
    play(g);
    g.advanceTo('REVEALED', 0);
    // solo: 1/1 at 500 ms beats A and B (1.0 accuracy, 3 s mean).
    expect(g.round.results['tw-q0']!.challengeWinners).toEqual(['solo:solo']);
  });

  it('members who LEFT do not count in the denominator', () => {
    const g = tableWars();
    g.must({ type: 'LEAVE', now: g.now, playerId: 'c2' });
    g.strategy = ({ question, qIndex, playerId }) => (qIndex === 0 && playerId === 'c1' ? { optionId: question.correctOptionId, delayMs: 1_000 } : null);
    g.advanceTo('REVEALED', 0);
    // C is 1/1 among active members; nobody else is correct.
    expect(g.round.results['tw-q0']!.challengeWinners).toEqual(['C']);
    expect(g.eventsOf('ScoreAwarded').filter((e) => e.kind === 'TABLE_CHALLENGE').map((e) => e.playerId)).toEqual(['c1']);
  });
});
