/*
 * Copyright 2017 The boardgame.io Authors
 *
 * Use of this source code is governed by a MIT-style
 * license that can be found in the LICENSE file or at
 * https://opensource.org/licenses/MIT.
 */

import { INVALID_MOVE } from './constants';
import { CreateGameReducer } from './reducer';
import { InitializeGame } from './initialize';
import {
  makeMove,
  gameEvent,
  sync,
  update,
  reset,
  undo,
  redo,
} from './action-creators';
import { error } from '../core/logger';
import { Ctx, Game, State, SyncInfo } from '../types';

jest.mock('../core/logger', () => ({
  info: jest.fn(),
  error: jest.fn(),
}));

const game: Game = {
  moves: {
    A: G => G,
    B: () => ({ moved: true }),
    C: () => ({ victory: true }),
  },
  endIf: (G, ctx) => (G.victory ? ctx.currentPlayer : undefined),
};
const reducer = CreateGameReducer({ game });
const initialState = InitializeGame({ game });

test('_stateID is incremented', () => {
  let state = initialState;
  state = reducer(state, makeMove('A'));
  expect(state._stateID).toBe(1);
  state = reducer(state, gameEvent('endTurn'));
  expect(state._stateID).toBe(2);
});

test('move returns INVALID_MOVE', () => {
  const game = {
    moves: {
      A: () => INVALID_MOVE,
    },
  };
  const reducer = CreateGameReducer({ game });
  let state = reducer(initialState, makeMove('A'));
  expect(error).toBeCalledWith('invalid move: A args: undefined');
  expect(state._stateID).toBe(0);
});

test('makeMove', () => {
  let state = initialState;
  expect(state._stateID).toBe(0);

  state = reducer(state, makeMove('unknown'));
  expect(state._stateID).toBe(0);
  expect(state.G).not.toMatchObject({ moved: true });
  expect(error).toBeCalledWith('disallowed move: unknown');

  state = reducer(state, makeMove('A'));
  expect(state._stateID).toBe(1);
  expect(state.G).not.toMatchObject({ moved: true });

  state = reducer(state, makeMove('B'));
  expect(state._stateID).toBe(2);
  expect(state.G).toMatchObject({ moved: true });

  state.ctx.gameover = true;

  state = reducer(state, makeMove('B'));
  expect(state._stateID).toBe(2);
  expect(error).toBeCalledWith('cannot make move after game end');

  state = reducer(state, gameEvent('endTurn'));
  expect(state._stateID).toBe(2);
  expect(error).toBeCalledWith('cannot call event after game end');
});

test('disable move by invalid playerIDs', () => {
  let state = initialState;
  expect(state._stateID).toBe(0);

  // playerID="1" cannot move right now.
  state = reducer(state, makeMove('A', null, '1'));
  expect(state._stateID).toBe(0);

  // playerID="1" cannot call events right now.
  state = reducer(state, gameEvent('endTurn', null, '1'));
  expect(state._stateID).toBe(0);

  // playerID="0" can move.
  state = reducer(state, makeMove('A', null, '0'));
  expect(state._stateID).toBe(1);

  // playerID=undefined can always move.
  state = reducer(state, makeMove('A'));
  expect(state._stateID).toBe(2);
});

test('sync', () => {
  const state = reducer(
    undefined,
    sync({ state: { G: 'restored' } } as SyncInfo)
  );
  expect(state).toEqual({ G: 'restored' });
});

test('update', () => {
  const state = reducer(undefined, update({ G: 'restored' } as State, []));
  expect(state).toEqual({ G: 'restored' });
});

test('reset', () => {
  let state = reducer(initialState, makeMove('A'));
  expect(state).not.toEqual(initialState);
  state = reducer(state, reset(initialState));
  expect(state).toEqual(initialState);
});

test('victory', () => {
  let state = reducer(initialState, makeMove('A'));
  state = reducer(state, gameEvent('endTurn'));
  expect(state.ctx.gameover).toEqual(undefined);
  state = reducer(state, makeMove('B'));
  state = reducer(state, gameEvent('endTurn'));
  expect(state.ctx.gameover).toEqual(undefined);
  state = reducer(state, makeMove('C'));
  expect(state.ctx.gameover).toEqual('0');
});

test('endTurn', () => {
  {
    let state = reducer(initialState, gameEvent('endTurn'));
    expect(state.ctx.turn).toBe(2);
  }

  {
    const reducer = CreateGameReducer({ game, isClient: true });
    let state = reducer(initialState, gameEvent('endTurn'));
    expect(state.ctx.turn).toBe(1);
  }
});

test('light client when multiplayer=true', () => {
  const game: Game = {
    moves: { A: () => ({ win: true }) },
    endIf: G => G.win,
  };

  {
    const reducer = CreateGameReducer({ game });
    let state = InitializeGame({ game });
    expect(state.ctx.gameover).toBe(undefined);
    state = reducer(state, makeMove('A'));
    expect(state.ctx.gameover).toBe(true);
  }

  {
    const reducer = CreateGameReducer({ game, isClient: true });
    let state = InitializeGame({ game });
    expect(state.ctx.gameover).toBe(undefined);
    state = reducer(state, makeMove('A'));
    expect(state.ctx.gameover).toBe(undefined);
  }
});

test('disable optimistic updates', () => {
  const game = {
    moves: {
      A: {
        move: () => ({ A: true }),
        client: false,
      },
    },
  };

  {
    const reducer = CreateGameReducer({ game });
    let state = InitializeGame({ game });
    expect(state.G).not.toMatchObject({ A: true });
    state = reducer(state, makeMove('A'));
    expect(state.G).toMatchObject({ A: true });
  }

  {
    const reducer = CreateGameReducer({ game, isClient: true });
    let state = InitializeGame({ game });
    expect(state.G).not.toMatchObject({ A: true });
    state = reducer(state, makeMove('A'));
    expect(state.G).not.toMatchObject({ A: true });
  }
});

test('numPlayers', () => {
  const numPlayers = 4;
  const state = InitializeGame({ game, numPlayers });
  expect(state.ctx.numPlayers).toBe(4);
});

test('deltalog', () => {
  let state = initialState;

  const actionA = makeMove('A');
  const actionB = makeMove('B');
  const actionC = gameEvent('endTurn');

  state = reducer(state, actionA);
  expect(state.deltalog).toEqual([
    {
      action: actionA,
      _stateID: 0,
      phase: null,
      turn: 1,
    },
  ]);
  state = reducer(state, actionB);
  expect(state.deltalog).toEqual([
    {
      action: actionB,
      _stateID: 1,
      phase: null,
      turn: 1,
    },
  ]);
  state = reducer(state, actionC);
  expect(state.deltalog).toEqual([
    {
      action: actionC,
      _stateID: 2,
      phase: null,
      turn: 1,
    },
  ]);
});

describe('Events API', () => {
  const fn = (G: any, ctx: Ctx) => (ctx.events ? {} : { error: true });

  const game: Game = {
    setup: () => ({}),
    phases: { A: {} },
    turn: {
      onBegin: fn,
      onEnd: fn,
      onMove: fn,
    },
  };

  const reducer = CreateGameReducer({ game });
  let state = InitializeGame({ game });

  test('is attached at the beginning', () => {
    expect(state.G).not.toEqual({ error: true });
  });

  test('is attached at the end of turns', () => {
    state = reducer(state, gameEvent('endTurn'));
    expect(state.G).not.toEqual({ error: true });
  });

  test('is attached at the end of phases', () => {
    state = reducer(state, gameEvent('endPhase'));
    expect(state.G).not.toEqual({ error: true });
  });
});

describe('Random inside setup()', () => {
  const game1: Game = {
    seed: 'seed1',
    setup: ctx => ({ n: ctx.random.D6() }),
  };

  const game2: Game = {
    seed: 'seed2',
    setup: ctx => ({ n: ctx.random.D6() }),
  };

  const game3: Game = {
    seed: 'seed2',
    setup: ctx => ({ n: ctx.random.D6() }),
  };

  test('setting seed', () => {
    const state1 = InitializeGame({ game: game1 });
    const state2 = InitializeGame({ game: game2 });
    const state3 = InitializeGame({ game: game3 });

    expect(state1.G.n).not.toBe(state2.G.n);
    expect(state2.G.n).toBe(state3.G.n);
  });
});

test('undo / redo', () => {
  const game: Game = {
    seed: 0,
    moves: {
      move: (G, ctx, arg) => ({ ...G, [arg]: true }),
      roll: (G, ctx) => {
        G.roll = ctx.random.D6();
      },
    },
  };

  const reducer = CreateGameReducer({ game });

  let state = InitializeGame({ game });

  state = reducer(state, makeMove('move', 'A'));
  expect(state.G).toMatchObject({ A: true });

  state = reducer(state, makeMove('move', 'B'));
  expect(state.G).toMatchObject({ A: true, B: true });
  expect(state._undo[1].ctx.events).toBeUndefined();
  expect(state._undo[1].ctx.random).toBeUndefined();

  state = reducer(state, undo());
  expect(state.G).toMatchObject({ A: true });

  state = reducer(state, redo());
  expect(state.G).toMatchObject({ A: true, B: true });

  state = reducer(state, redo());
  expect(state.G).toMatchObject({ A: true, B: true });

  state = reducer(state, undo());
  expect(state.G).toMatchObject({ A: true });

  state = reducer(state, undo());
  state = reducer(state, undo());
  state = reducer(state, undo());
  expect(state.G).toEqual({});

  state = reducer(state, redo());
  state = reducer(state, makeMove('move', 'C'));
  expect(state.G).toMatchObject({ A: true, C: true });

  state = reducer(state, undo());
  expect(state.G).toMatchObject({ A: true });

  state = reducer(state, redo());
  expect(state.G).toMatchObject({ A: true, C: true });

  state = reducer(state, undo());
  state = reducer(state, undo());
  state = reducer(state, makeMove('roll'));
  expect(state.G).toMatchObject({ roll: 4 });

  state = reducer(state, undo());
  expect(state.G).toEqual({});
  state = reducer(state, redo());
  expect(state.G).toMatchObject({ roll: 4 });

  state = reducer(state, gameEvent('endTurn'));
  state = reducer(state, undo());
  expect(state.G).toMatchObject({ roll: 4 });
});

test('disable undo / redo', () => {
  const game: Game = {
    seed: 0,
    disableUndo: true,
    moves: {
      move: (G, ctx, arg) => ({ ...G, [arg]: true }),
    },
  };

  const reducer = CreateGameReducer({ game });

  let state = InitializeGame({ game });

  state = reducer(state, makeMove('move', 'A'));
  expect(state.G).toMatchObject({ A: true });
  expect(state._undo).toEqual([]);
  expect(state._redo).toEqual([]);

  state = reducer(state, makeMove('move', 'B'));
  expect(state.G).toMatchObject({ A: true, B: true });
  expect(state._undo).toEqual([]);
  expect(state._redo).toEqual([]);

  state = reducer(state, undo());
  expect(state.G).toMatchObject({ A: true, B: true });
  expect(state._undo).toEqual([]);
  expect(state._redo).toEqual([]);

  state = reducer(state, undo());
  expect(state.G).toMatchObject({ A: true, B: true });
  expect(state._undo).toEqual([]);
  expect(state._redo).toEqual([]);

  state = reducer(state, redo());
  expect(state.G).toMatchObject({ A: true, B: true });
  expect(state._undo).toEqual([]);
  expect(state._redo).toEqual([]);
});

describe('undo stack', () => {
  const game: Game = {
    moves: {
      basic: () => {},
      endTurn: (_, ctx) => {
        ctx.events.endTurn();
      },
    },
  };

  const reducer = CreateGameReducer({ game });
  let state = InitializeGame({ game });

  test('contains initial state at start of game', () => {
    expect(state._undo).toHaveLength(1);
    expect(state._undo[0].ctx).toEqual(state.ctx);
  });

  test('grows when a move is made', () => {
    state = reducer(state, makeMove('basic'));
    expect(state._undo).toHaveLength(2);
    expect(state._undo[1].moveType).toBe('basic');
    expect(state._undo[1].ctx).toEqual(state.ctx);
  });

  test('shrinks when a move is undone', () => {
    state = reducer(state, undo());
    expect(state._undo).toHaveLength(1);
    expect(state._undo[0].ctx).toEqual(state.ctx);
  });

  test('grows when a move is redone', () => {
    state = reducer(state, redo());
    expect(state._undo).toHaveLength(2);
    expect(state._undo[1].moveType).toBe('basic');
    expect(state._undo[1].ctx).toEqual(state.ctx);
  });

  test('is reset when a turn ends', () => {
    state = reducer(state, makeMove('endTurn'));
    expect(state._undo).toHaveLength(1);
    expect(state._undo[0].ctx).toEqual(state.ctx);
    expect(state._undo[0].moveType).toBeUndefined();
  });
});

describe('redo stack', () => {
  const game: Game = {
    moves: {
      basic: () => {},
      endTurn: (_, ctx) => {
        ctx.events.endTurn();
      },
    },
  };

  const reducer = CreateGameReducer({ game });
  let state = InitializeGame({ game });

  test('is empty at start of game', () => {
    expect(state._redo).toHaveLength(0);
  });

  test('grows when a move is undone', () => {
    state = reducer(state, makeMove('basic'));
    state = reducer(state, undo());
    expect(state._redo).toHaveLength(1);
    expect(state._redo[0].moveType).toBe('basic');
  });

  test('shrinks when a move is redone', () => {
    state = reducer(state, redo());
    expect(state._redo).toHaveLength(0);
  });

  test('is reset when a move is made', () => {
    state = reducer(state, makeMove('basic'));
    state = reducer(state, undo());
    state = reducer(state, undo());
    expect(state._redo).toHaveLength(2);
    state = reducer(state, makeMove('basic'));
    expect(state._redo).toHaveLength(0);
  });

  test('is reset when a turn ends', () => {
    state = reducer(state, makeMove('basic'));
    state = reducer(state, undo());
    expect(state._redo).toHaveLength(1);
    state = reducer(state, makeMove('endTurn'));
    expect(state._redo).toHaveLength(0);
  });
});

describe('undo / redo with stages', () => {
  const game: Game = {
    setup: () => ({ A: false, B: false, C: false }),
    turn: {
      activePlayers: { currentPlayer: 'start' },
      stages: {
        start: {
          moves: {
            moveA: {
              move: (G, ctx, moveAisReversible) => {
                ctx.events.setStage('A');
                return { ...G, moveAisReversible, A: true };
              },
              undoable: G => G.moveAisReversible > 0,
            },
          },
        },
        A: {
          moves: {
            moveB: {
              move: (G, ctx) => {
                ctx.events.setStage('B');
                return { ...G, B: true };
              },
              undoable: false,
            },
          },
        },
        B: {
          moves: {
            moveC: {
              move: (G, ctx) => {
                ctx.events.setStage('C');
                return { ...G, C: true };
              },
              undoable: true,
            },
          },
        },
        C: {
          moves: {},
        },
      },
    },
  };

  const reducer = CreateGameReducer({ game });

  let state = InitializeGame({ game });

  test('moveA sets state & moves player to stage A (undoable)', () => {
    state = reducer(state, makeMove('moveA', true, '0'));
    expect(state.G).toMatchObject({
      moveAisReversible: true,
      A: true,
      B: false,
      C: false,
    });
    expect(state.ctx.activePlayers['0']).toBe('A');
  });

  test('undo undoes last move (moveA)', () => {
    state = reducer(state, undo('0'));
    expect(state.G).toMatchObject({
      A: false,
      B: false,
      C: false,
    });
    expect(state.ctx.activePlayers['0']).toBe('start');
  });

  test('redo redoes moveA', () => {
    state = reducer(state, redo('0'));
    expect(state.G).toMatchObject({
      moveAisReversible: true,
      A: true,
      B: false,
      C: false,
    });
    expect(state.ctx.activePlayers['0']).toBe('A');
  });

  test('undo undoes last move after redo (moveA)', () => {
    state = reducer(state, undo('0'));
    expect(state.G).toMatchObject({
      A: false,
      B: false,
      C: false,
    });
    expect(state.ctx.activePlayers['0']).toBe('start');
  });

  test('moveA sets state & moves player to stage A (not undoable)', () => {
    state = reducer(state, makeMove('moveA', false, '0'));
    expect(state.G).toMatchObject({
      moveAisReversible: false,
      A: true,
      B: false,
      C: false,
    });
    expect(state.ctx.activePlayers['0']).toBe('A');
  });

  test('moveB sets state & moves player to stage B', () => {
    state = reducer(state, makeMove('moveB', [], '0'));
    expect(state.G).toMatchObject({
      moveAisReversible: false,
      A: true,
      B: true,
      C: false,
    });
    expect(state.ctx.activePlayers['0']).toBe('B');
  });

  test('undo doesn’t undo last move if not undoable (moveB)', () => {
    state = reducer(state, undo('0'));
    expect(state.G).toMatchObject({
      moveAisReversible: false,
      A: true,
      B: true,
      C: false,
    });
    expect(state.ctx.activePlayers['0']).toBe('B');
  });

  test('moveC sets state & moves player to stage C', () => {
    state = reducer(state, makeMove('moveC', [], '0'));
    expect(state.G).toMatchObject({
      moveAisReversible: false,
      A: true,
      B: true,
      C: true,
    });
    expect(state.ctx.activePlayers['0']).toBe('C');
  });

  test('undo undoes last move (moveC)', () => {
    state = reducer(state, undo('0'));
    expect(state.G).toMatchObject({
      moveAisReversible: false,
      A: true,
      B: true,
      C: false,
    });
    expect(state.ctx.activePlayers['0']).toBe('B');
  });

  test('redo redoes moveC', () => {
    state = reducer(state, redo('0'));
    expect(state.G).toMatchObject({
      moveAisReversible: false,
      A: true,
      B: true,
      C: true,
    });
    expect(state.ctx.activePlayers['0']).toBe('C');
  });

  test('undo undoes last move after redo (moveC)', () => {
    state = reducer(state, undo('0'));
    expect(state.G).toMatchObject({
      moveAisReversible: false,
      A: true,
      B: true,
      C: false,
    });
    expect(state.ctx.activePlayers['0']).toBe('B');
  });

  test('undo doesn’t undo last move if not undoable after undo/redo', () => {
    state = reducer(state, undo('0'));
    expect(state.G).toMatchObject({
      moveAisReversible: false,
      A: true,
      B: true,
      C: false,
    });
    expect(state.ctx.activePlayers['0']).toBe('B');
  });
});
