import { describe, expect, it } from 'vitest';
import { advanceCached, applyGameOver, applyMoveDelta, applyOpponentJoined, seedInitialState, type MoveDelta } from './gamedelta';
import type { CachedGame } from './gamecache';
import type { GameView, MoveRecord, PushEvent, StateView } from './model';

function gameView(moveCount: number, over = false): GameView {
  return {
    id: 'g1',
    variant: 'scrabble_en',
    dictVersion: 'v1',
    vsAi: false,
    unreadChat: false,
    unreadMessages: false,
    kind: 0,
    status: over ? 'finished' : 'active',
    players: 2,
    toMove: 1,
    turnTimeoutSecs: 300,
    multipleWordsPerTurn: true,
    noRepeatWords: false,
    moveCount,
    endReason: over ? 'standard' : '',
    lastActivityUnix: 0,
    seats: [],
  };
}

function move(player: number): MoveRecord {
  return { player, action: 'play', dir: 'H', mainRow: 7, mainCol: 7, tiles: [], words: ['AB'], count: 0, score: 10, total: 10 };
}

function cache(moveCount: number, seat = 0, over = false): CachedGame {
  const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 };
  return { view, moves: [] };
}

function delta(moveCount: number, player: number, bagLen = 47): MoveDelta {
  return { move: move(player), game: gameView(moveCount), bagLen };
}

describe('seedInitialState', () => {
  it('wraps an initial view with an empty journal', () => {
    const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2 };
    expect(seedInitialState(view)).toEqual({ view, moves: [] });
  });
});

describe('applyMoveDelta', () => {
  it('ignores a delta for a game not in the cache', () => {
    expect(applyMoveDelta(undefined, delta(1, 1))).toEqual({ refetch: false });
  });

  it('refetches when the payload carries no delta (older peer / dropped payload)', () => {
    expect(applyMoveDelta(cache(3), { bagLen: 0 })).toEqual({ refetch: true });
  });

  it('is a no-op for an already-applied move count (idempotent / own echo)', () => {
    expect(applyMoveDelta(cache(3), delta(3, 1))).toEqual({ refetch: false });
    expect(applyMoveDelta(cache(3), delta(2, 1))).toEqual({ refetch: false });
  });

  it('refetches on a gap (an intermediate move was missed)', () => {
    expect(applyMoveDelta(cache(3), delta(5, 1))).toEqual({ refetch: true });
  });

  it("refetches the actor's own move (the new rack is not in the delta)", () => {
    // seat 0 is this device; the move's player is 0 -> our own move, our rack changed.
    expect(applyMoveDelta(cache(3, 0), delta(4, 0))).toEqual({ refetch: true });
  });

  it("applies an opponent's next move, preserving the rack and appending the move", () => {
    const before = cache(3, 0);
    const res = applyMoveDelta(before, delta(4, 1, 45));
    expect(res.refetch).toBe(false);
    expect(res.cache?.view.game.moveCount).toBe(4);
    expect(res.cache?.view.bagLen).toBe(45);
    expect(res.cache?.view.rack).toEqual(['a', 'b']); // unchanged by an opponent move
    expect(res.cache?.view.seat).toBe(0);
    expect(res.cache?.moves).toHaveLength(1);
    expect(res.cache?.moves[0].player).toBe(1);
    expect(before.moves).toHaveLength(0); // input not mutated
  });
});

describe('applyGameOver', () => {
  it('ignores a finished game not in the cache', () => {
    expect(applyGameOver(undefined, gameView(10, true))).toEqual({ refetch: false });
  });

  it('refetches a missing final summary only when the game is not already finished', () => {
    expect(applyGameOver(cache(10, 0, false), undefined)).toEqual({ refetch: true });
    expect(applyGameOver(cache(10, 0, true), undefined)).toEqual({ refetch: false });
  });

  it('refetches when the cached board is behind the final move count', () => {
    expect(applyGameOver(cache(9), gameView(10, true))).toEqual({ refetch: true });
  });

  it('settles the final summary when the board is current', () => {
    const res = applyGameOver(cache(10), gameView(10, true));
    expect(res.refetch).toBe(false);
    expect(res.cache?.view.game.status).toBe('finished');
    expect(res.cache?.view.game.moveCount).toBe(10);
  });
});

describe('deleted seat marks', () => {
  // The live events carry no deleted mark (only the REST-backed views resolve it), so a delta must
  // not clear the mark the cache already holds — otherwise the social controls aimed at a deleted
  // opponent reappear until the next cold load.
  function cacheWithDeletedSeat(moveCount: number): CachedGame {
    const c = cache(moveCount);
    c.view.game.seats = [
      { seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false },
      { seat: 1, accountId: 'gone', displayName: '[Deleted]', score: 0, hintsUsed: 0, isWinner: false, deleted: true },
    ];
    return c;
  }

  function eventSeats(): GameView['seats'] {
    return [
      { seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false },
      { seat: 1, accountId: 'gone', displayName: '[Deleted]', score: 12, hintsUsed: 0, isWinner: false },
    ];
  }

  it('keeps a deleted seat marked across a move delta', () => {
    const d = delta(4, 1);
    d.game = { ...gameView(4), seats: eventSeats() };
    const res = applyMoveDelta(cacheWithDeletedSeat(3), d);
    expect(res.cache?.view.game.seats.map((s) => !!s.deleted)).toEqual([false, true]);
    expect(res.cache?.view.game.seats[1].score).toBe(12); // the event's own fields still win
  });

  it('keeps a deleted seat marked across the final summary', () => {
    const final: GameView = { ...gameView(3, true), seats: eventSeats() };
    const res = applyGameOver(cacheWithDeletedSeat(3), final);
    expect(res.cache?.view.game.seats.map((s) => !!s.deleted)).toEqual([false, true]);
  });

  it('leaves the event seats untouched when no cached seat is deleted', () => {
    const final: GameView = { ...gameView(3, true), seats: eventSeats() };
    const res = applyGameOver(cache(3), final);
    expect(res.cache?.view.game.seats).toBe(final.seats);
  });
});

function movedEvent(moveCount: number, player: number, bagLen = 45): PushEvent {
  return { kind: 'opponent_moved', gameId: 'g1', move: move(player), game: gameView(moveCount), bagLen };
}

function gameOverEvent(moveCount: number): PushEvent {
  return { kind: 'game_over', gameId: 'g1', result: 'win', scoreLine: '10:0', game: gameView(moveCount, true) };
}

describe('advanceCached', () => {
  it("advances the cache from an opponent's move for a game not on screen", () => {
    const res = advanceCached(cache(3, 0), movedEvent(4, 1, 42));
    expect(res?.view.game.moveCount).toBe(4);
    expect(res?.view.bagLen).toBe(42);
    expect(res?.moves).toHaveLength(1);
  });

  it('returns undefined on a gap, so the cache is left for the next open to cold-load', () => {
    expect(advanceCached(cache(3, 0), movedEvent(6, 1))).toBeUndefined();
  });

  it('returns undefined for an already-applied move (idempotent re-delivery / own echo)', () => {
    expect(advanceCached(cache(4, 0), movedEvent(4, 1))).toBeUndefined();
  });

  it('settles a game_over event when the cached board is current', () => {
    const res = advanceCached(cache(10, 0), gameOverEvent(10));
    expect(res?.view.game.status).toBe('finished');
  });

  it('returns undefined for game_over when the cached board is behind the final count', () => {
    expect(advanceCached(cache(9, 0), gameOverEvent(10))).toBeUndefined();
  });

  it('returns undefined when nothing is cached for the game', () => {
    expect(advanceCached(undefined, movedEvent(1, 1))).toBeUndefined();
  });

  it('ignores event kinds that do not advance a game board', () => {
    const yourTurn: PushEvent = { kind: 'your_turn', gameId: 'g1', deadlineUnix: 0, opponentName: 'Ann', moveCount: 4 };
    expect(advanceCached(cache(3, 0), yourTurn)).toBeUndefined();
  });
});

describe('applyOpponentJoined', () => {
  // joinedState is the refreshed StateView an opponent_joined event carries: the open game is now
  // active and both seats are filled.
  function joinedState(): StateView {
    const game: GameView = { ...gameView(0), status: 'active', players: 2, seats: [
      { seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false },
      { seat: 1, accountId: 'opp', displayName: 'Opp', score: 0, hintsUsed: 0, isWinner: false },
    ] };
    return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0 };
  }

  it("adopts the joined seats and status while preserving the cached rack and moves", () => {
    // The cached open game is still "searching": empty seats, status open, the starter's own rack.
    const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }, moves: [move(0)] };
    const res = applyOpponentJoined(cached, joinedState());
    expect(res?.view.game.status).toBe('active');
    expect(res?.view.game.seats).toHaveLength(2);
    expect(res?.view.rack).toEqual(['a', 'b']); // the starter's rack is untouched by the join
    expect(res?.view.game.moveCount).toBe(2); // the cached count/board is preserved
    expect(res?.moves).toHaveLength(1);
  });

  it('returns undefined when nothing is cached for the game', () => {
    expect(applyOpponentJoined(undefined, joinedState())).toBeUndefined();
  });
});
