import { describe, expect, it } from 'vitest';
import { DEFAULT_TIMINGS, basePoints, speedBonus, windowFor, type Position } from '../src/index';
import { Game, boardSetup, mcQuestion } from './helpers';

const W = DEFAULT_TIMINGS.windowMs;
const G = DEFAULT_TIMINGS.graceMs;
const bonus = (elapsed: number, windowMs = W): number => speedBonus({ openedAt: 1_000, receivedAt: 1_000 + elapsed, windowMs, graceMs: G });

describe('speedBonus', () => {
  it('is the full 50 inside the latency grace', () => {
    expect(bonus(0)).toBe(50);
    expect(bonus(1)).toBe(50);
    expect(bonus(G)).toBe(50);
    expect(bonus(-200)).toBe(50); // clamped, never above 50
  });

  it('is exactly 0 at close and after', () => {
    expect(bonus(W)).toBe(0);
    expect(bonus(W + 1)).toBe(0);
    expect(bonus(W * 3)).toBe(0);
  });

  it('decreases linearly between grace and close (midpoint → 25)', () => {
    expect(bonus(G + (W - G) / 2)).toBe(25);
    expect(bonus(G + (W - G) / 4)).toBe(38); // 37.5 rounds half up
    expect(bonus(G + (3 * (W - G)) / 4)).toBe(13); // 12.5 → 13
    let prev = 51;
    for (let e = 0; e <= W; e += 250) {
      const b = bonus(e);
      expect(b).toBeLessThanOrEqual(prev);
      prev = b;
    }
  });

  it('uses the long window for difficult / wildcard questions', () => {
    const DW = DEFAULT_TIMINGS.difficultWindowMs;
    expect(bonus(G + (DW - G) / 2, DW)).toBe(25);
    expect(bonus(DW, DW)).toBe(0);
  });

  it('handles a degenerate grace ≥ window config', () => {
    expect(speedBonus({ openedAt: 0, receivedAt: 500, windowMs: 500, graceMs: 750 })).toBe(50);
    expect(speedBonus({ openedAt: 0, receivedAt: 501, windowMs: 500, graceMs: 750 })).toBe(0);
  });
});

describe('basePoints / windowFor', () => {
  it('standard 100, difficult 150', () => {
    expect(basePoints('STANDARD')).toBe(100);
    expect(basePoints('DIFFICULT')).toBe(150);
  });
  it('15 s standard, 20 s difficult and wildcard', () => {
    expect(windowFor(mcQuestion('a', 'x'), false, DEFAULT_TIMINGS)).toBe(15_000);
    expect(windowFor(mcQuestion('a', 'x', { difficulty: 'DIFFICULT' }), false, DEFAULT_TIMINGS)).toBe(DEFAULT_TIMINGS.difficultWindowMs);
    expect(windowFor(mcQuestion('a', 'x'), true, DEFAULT_TIMINGS)).toBe(DEFAULT_TIMINGS.difficultWindowMs);
  });
});

describe('scoring through reduce()', () => {
  function game(players = ['p1', 'p2']): Game {
    const g = Game.create();
    g.start();
    for (const p of players) g.join(p);
    return g;
  }

  it('difficult correct answer = 150 + speed bonus; refIds are KIND-prefixed; wrong answers score nothing', () => {
    const g = game();
    g.startRound(boardSetup('r1', { tweak: (i) => (i === 0 ? { difficulty: 'DIFFICULT' } : {}) }));
    g.advanceTo('OPEN', 0);
    const openedAt = g.round.openedAt!;
    g.at(openedAt + G);
    g.answer('p1', 'a');
    g.at(openedAt + G + 1);
    g.answer('p2', 'b');
    const res = g.tick(g.round.phaseEndsAt);
    const scores = res.events.filter((e) => e.type === 'ScoreAwarded');
    expect(scores).toEqual([
      { type: 'ScoreAwarded', at: g.now, roundId: 'r1', playerId: 'p1', kind: 'CORRECT', refId: 'CORRECT:r1:r1-q0', points: 150 },
      { type: 'ScoreAwarded', at: g.now, roundId: 'r1', playerId: 'p1', kind: 'SPEED_BONUS', refId: 'SPEED_BONUS:r1:r1-q0', points: 50 },
    ]);
    expect(res.events.filter((e) => e.type === 'AnswerGraded')).toEqual([
      expect.objectContaining({ playerId: 'p1', correct: true, points: 150, speedBonus: 50 }),
      expect.objectContaining({ playerId: 'p2', correct: false, points: 0, speedBonus: 0 }),
    ]);
    expect(g.state.totals['p1']).toMatchObject({ points: 200, correct: 1, answered: 1, correctTimeMs: G, lastCorrectAt: openedAt + G });
    expect(g.state.totals['p2']).toMatchObject({ points: 0, correct: 0, answered: 1, correctTimeMs: 0, lastCorrectAt: null });
  });

  it('scores are invisible until reveal: totals do not move at answer time or lock', () => {
    const g = game(['p1']);
    g.startRound(boardSetup('r1'));
    g.advanceTo('OPEN', 0);
    g.later(1_000);
    g.answer('p1', 'a');
    expect(g.round.phase).toBe('LOCKED');
    expect(g.state.totals['p1']!.points).toBe(0);
    expect(g.state.totals['p1']!.answered).toBe(0);
    g.tick(g.round.phaseEndsAt);
    expect(g.state.totals['p1']!.points).toBeGreaterThan(0);
  });

  it('awards each line and the full board once per round, tokens one per line up to the cap', () => {
    const g = game(['p1']);
    g.startRound(boardSetup('r1'));
    // Fill row 0 first (one line), then everything.
    const order: string[] = [];
    g.strategy = ({ question, state }) => {
      const tile = state.round!.boards['p1']!.tiles.find((t) => t.category === question.category)!;
      order.push(`${question.id}@${tile.position}`);
      return { optionId: question.correctOptionId, delayMs: 3_000 };
    };
    g.runUntil((x) => x.state.round === null);
    const lineAwards = g.eventsOf('ScoreAwarded').filter((e) => e.kind === 'LINE');
    expect(lineAwards).toHaveLength(8);
    expect(new Set(lineAwards.map((e) => e.refId)).size).toBe(8);
    expect(g.eventsOf('ScoreAwarded').filter((e) => e.kind === 'FULL_BOARD')).toHaveLength(1);
    expect(g.eventsOf('PowerUpGranted').map((e) => e.sourceRef)).toHaveLength(3);
    expect(g.state.wallets['p1']!.grants).toEqual(g.eventsOf('PowerUpGranted').map((e) => e.sourceRef));
    // Every (kind, refId) is unique for the player — the db's ScoreEvent constraint can never trip.
    const refs = g.eventsOf('ScoreAwarded').map((e) => `${e.playerId}:${e.kind}:${e.refId}`);
    expect(new Set(refs).size).toBe(refs.length);
    expect(order).toHaveLength(10);
  });

  it('wildcard: explicit pick activates that tile; an invalid or occupied pick falls back to the best tile', () => {
    for (const [pick, expectAuto] of [
      [7, false],
      [42, true],
      [null, true],
    ] as [Position | null, boolean][]) {
      const g = game(['p1']);
      g.startRound(boardSetup('r1'));
      g.strategy = ({ question, qIndex }) => {
        if (qIndex === 9) return { optionId: question.correctOptionId, delayMs: 1_000, wildcardPosition: pick };
        return null;
      };
      g.runUntil((x) => x.state.round === null);
      const wild = g.eventsOf('TileActivated');
      expect(wild).toHaveLength(1);
      expect(wild[0]).toMatchObject({ source: 'WILDCARD', position: expectAuto ? 0 : 7 });
      expect(g.eventsOf('AnswerAccepted')[0]!.wildcardPosition).toBe(pick === 7 ? 7 : null);
    }
  });

  it('wildcardPosition is ignored on non-wildcard questions', () => {
    const g = game(['p1']);
    g.startRound(boardSetup('r1'));
    g.advanceTo('OPEN', 0);
    g.later(100);
    g.answer('p1', 'a', { wildcardPosition: 8 });
    expect(g.eventsOf('AnswerAccepted')[0]!.wildcardPosition).toBeNull();
    g.tick(g.round.phaseEndsAt);
    const tile = g.eventsOf('TileActivated')[0]!;
    expect(g.round.boards['p1']!.tiles[tile.position]!.category).toBe('pop');
  });
});
