/*
 * Copyright 2018 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 { InitializeGame } from '../core/initialize';
import { CreateGameReducer } from '../core/reducer';
import { ProcessGameConfig } from '../core/game';
import { UNDO, REDO, MAKE_MOVE } from '../core/action-types';
import { createStore } from 'redux';
import * as logging from '../core/logger';
import {
  SyncInfo,
  FilteredMetadata,
  Game,
  Server,
  State,
  ActionShape,
  CredentialedActionShape,
  LogEntry,
  PlayerID,
} from '../types';
import * as StorageAPI from '../server/db/base';

export const getPlayerMetadata = (
  gameMetadata: Server.GameMetadata,
  playerID: PlayerID
) => {
  if (gameMetadata && gameMetadata.players) {
    return gameMetadata.players[playerID];
  }
};

function IsSynchronous(
  storageAPI: StorageAPI.Sync | StorageAPI.Async
): storageAPI is StorageAPI.Sync {
  return storageAPI.type() === StorageAPI.Type.SYNC;
}

/**
 * Redact the log.
 *
 * @param {Array} log - The game log (or deltalog).
 * @param {String} playerID - The playerID that this log is
 *                            to be sent to.
 */
export function redactLog(log: LogEntry[], playerID: PlayerID) {
  if (log === undefined) {
    return log;
  }

  return log.map(logEvent => {
    // filter for all other players and spectators.
    if (playerID !== null && +playerID === +logEvent.action.payload.playerID) {
      return logEvent;
    }

    if (logEvent.redact !== true) {
      return logEvent;
    }

    const payload = {
      ...logEvent.action.payload,
      args: null,
    };
    const filteredEvent = {
      ...logEvent,
      action: { ...logEvent.action, payload },
    };

    /* eslint-disable-next-line no-unused-vars */
    const { redact, ...remaining } = filteredEvent;
    return remaining;
  });
}

/**
 * Verifies that the game has metadata and is using credentials.
 */
export const doesGameRequireAuthentication = (
  gameMetadata?: Server.GameMetadata
) => {
  if (!gameMetadata) return false;
  const { players } = gameMetadata as Server.GameMetadata;
  const hasCredentials = Object.keys(players).some(key => {
    return !!(players[key] && players[key].credentials);
  });
  return hasCredentials;
};

/**
 * Verifies that the move came from a player with the correct credentials.
 */
export const isActionFromAuthenticPlayer = (
  actionCredentials: string,
  playerMetadata?: Server.PlayerMetadata
) => {
  if (!actionCredentials) return false;
  if (!playerMetadata) return false;
  return actionCredentials === playerMetadata.credentials;
};

/**
 * Remove player credentials from action payload
 */
const stripCredentialsFromAction = (action: CredentialedActionShape.Any) => {
  // eslint-disable-next-line no-unused-vars
  const { credentials, ...payload } = action.payload;
  return { ...action, payload };
};

export type AuthFn = (
  actionCredentials: string,
  playerMetadata: Server.PlayerMetadata
) => boolean | Promise<boolean>;

type CallbackFn = (arg: {
  state: State;
  gameID: string;
  action?: ActionShape.Any | CredentialedActionShape.Any;
}) => void;

type TransportData =
  | {
      type: 'update';
      args: [string, State, LogEntry[]];
    }
  | {
      type: 'sync';
      args: [string, SyncInfo];
    };

export interface TransportAPI {
  send: (playerData: { playerID: PlayerID } & TransportData) => void;
  sendAll: (makePlayerData: (playerID: PlayerID) => TransportData) => void;
}

/**
 * Master
 *
 * Class that runs the game and maintains the authoritative state.
 * It uses the transportAPI to communicate with clients and the
 * storageAPI to communicate with the database.
 */
export class Master {
  game: ReturnType<typeof ProcessGameConfig>;
  storageAPI: StorageAPI.Sync | StorageAPI.Async;
  transportAPI: TransportAPI;
  subscribeCallback: CallbackFn;
  auth: null | AuthFn;
  shouldAuth: typeof doesGameRequireAuthentication;

  constructor(
    game: Game,
    storageAPI: StorageAPI.Sync | StorageAPI.Async,
    transportAPI: TransportAPI,
    auth?: AuthFn | boolean
  ) {
    this.game = ProcessGameConfig(game);
    this.storageAPI = storageAPI;
    this.transportAPI = transportAPI;
    this.auth = null;
    this.subscribeCallback = () => {};
    this.shouldAuth = () => false;

    if (auth === true) {
      this.auth = isActionFromAuthenticPlayer;
      this.shouldAuth = doesGameRequireAuthentication;
    } else if (typeof auth === 'function') {
      this.auth = auth;
      this.shouldAuth = () => true;
    }
  }

  subscribe(fn: CallbackFn) {
    this.subscribeCallback = fn;
  }

  /**
   * Called on each move / event made by the client.
   * Computes the new value of the game state and returns it
   * along with a deltalog.
   */
  async onUpdate(
    credAction: CredentialedActionShape.Any,
    stateID: number,
    gameID: string,
    playerID: string
  ) {
    let isActionAuthentic;
    let metadata: Server.GameMetadata | undefined;
    const credentials = credAction.payload.credentials;
    if (IsSynchronous(this.storageAPI)) {
      ({ metadata } = this.storageAPI.fetch(gameID, { metadata: true }));
      const playerMetadata = getPlayerMetadata(metadata, playerID);
      isActionAuthentic = this.shouldAuth(metadata)
        ? this.auth(credentials, playerMetadata)
        : true;
    } else {
      ({ metadata } = await this.storageAPI.fetch(gameID, {
        metadata: true,
      }));
      const playerMetadata = getPlayerMetadata(metadata, playerID);
      isActionAuthentic = this.shouldAuth(metadata)
        ? await this.auth(credentials, playerMetadata)
        : true;
    }
    if (!isActionAuthentic) {
      return { error: 'unauthorized action' };
    }

    let action = stripCredentialsFromAction(credAction);
    const key = gameID;

    let state: State;
    let result: StorageAPI.FetchResult<{ state: true }>;
    if (IsSynchronous(this.storageAPI)) {
      result = this.storageAPI.fetch(key, { state: true });
    } else {
      result = await this.storageAPI.fetch(key, { state: true });
    }
    state = result.state;

    if (state === undefined) {
      logging.error(`game not found, gameID=[${key}]`);
      return { error: 'game not found' };
    }

    if (state.ctx.gameover !== undefined) {
      logging.error(`game over - gameID=[${key}]`);
      return;
    }

    const reducer = CreateGameReducer({
      game: this.game,
    });
    const store = createStore(reducer, state);

    // Only allow UNDO / REDO if there is exactly one player
    // that can make moves right now and the person doing the
    // action is that player.
    if (action.type == UNDO || action.type == REDO) {
      if (
        state.ctx.currentPlayer !== playerID ||
        state.ctx.activePlayers !== null
      ) {
        logging.error(`playerID=[${playerID}] cannot undo / redo right now`);
        return;
      }
    }

    // Check whether the player is active.
    if (!this.game.flow.isPlayerActive(state.G, state.ctx, playerID)) {
      logging.error(`player not active - playerID=[${playerID}]`);
      return;
    }

    // Check whether the player is allowed to make the move.
    if (
      action.type == MAKE_MOVE &&
      !this.game.flow.getMove(state.ctx, action.payload.type, playerID)
    ) {
      logging.error(
        `move not processed - canPlayerMakeMove=false, playerID=[${playerID}]`
      );
      return;
    }

    if (state._stateID !== stateID) {
      logging.error(
        `invalid stateID, was=[${stateID}], expected=[${state._stateID}]`
      );
      return;
    }

    // Update server's version of the store.
    store.dispatch(action);
    state = store.getState();

    this.subscribeCallback({
      state,
      action,
      gameID,
    });

    this.transportAPI.sendAll((playerID: string) => {
      const filteredState = {
        ...state,
        G: this.game.playerView(state.G, state.ctx, playerID),
        deltalog: undefined,
        _undo: [],
        _redo: [],
      };

      const log = redactLog(state.deltalog, playerID);

      return {
        type: 'update',
        args: [gameID, filteredState, log],
      };
    });

    const { deltalog, ...stateWithoutDeltalog } = state;

    let newMetadata: Server.GameMetadata | undefined;
    if (
      metadata &&
      !('gameover' in metadata) &&
      state.ctx.gameover !== undefined
    ) {
      newMetadata = {
        ...metadata,
        gameover: state.ctx.gameover,
      };
    }

    if (IsSynchronous(this.storageAPI)) {
      this.storageAPI.setState(key, stateWithoutDeltalog, deltalog);
      if (newMetadata) this.storageAPI.setMetadata(key, newMetadata);
    } else {
      const writes = [
        this.storageAPI.setState(key, stateWithoutDeltalog, deltalog),
      ];
      if (newMetadata) {
        writes.push(this.storageAPI.setMetadata(key, newMetadata));
      }
      await Promise.all(writes);
    }
  }

  /**
   * Called when the client connects / reconnects.
   * Returns the latest game state and the entire log.
   */
  async onSync(gameID: string, playerID: string, numPlayers: number) {
    const key = gameID;

    let state: State;
    let initialState: State;
    let log: LogEntry[];
    let gameMetadata: Server.GameMetadata;
    let filteredMetadata: FilteredMetadata;
    let result: StorageAPI.FetchResult<{
      state: true;
      metadata: true;
      log: true;
      initialState: true;
    }>;

    if (IsSynchronous(this.storageAPI)) {
      result = this.storageAPI.fetch(key, {
        state: true,
        metadata: true,
        log: true,
        initialState: true,
      });
    } else {
      result = await this.storageAPI.fetch(key, {
        state: true,
        metadata: true,
        log: true,
        initialState: true,
      });
    }

    state = result.state;
    initialState = result.initialState;
    log = result.log;
    gameMetadata = result.metadata;

    if (gameMetadata) {
      filteredMetadata = Object.values(gameMetadata.players).map(player => {
        const { credentials, ...filteredData } = player;
        return filteredData;
      });
    }

    // If the game doesn't exist, then create one on demand.
    // TODO: Move this out of the sync call.
    if (state === undefined) {
      initialState = state = InitializeGame({ game: this.game, numPlayers });

      this.subscribeCallback({
        state,
        gameID,
      });

      if (IsSynchronous(this.storageAPI)) {
        this.storageAPI.setState(key, state);
      } else {
        await this.storageAPI.setState(key, state);
      }
    }

    const filteredState = {
      ...state,
      G: this.game.playerView(state.G, state.ctx, playerID),
      deltalog: undefined,
      _undo: [],
      _redo: [],
    };

    log = redactLog(log, playerID);

    const syncInfo: SyncInfo = {
      state: filteredState,
      log,
      filteredMetadata,
      initialState,
    };

    this.transportAPI.send({
      playerID,
      type: 'sync',
      args: [gameID, syncInfo],
    });

    return;
  }
}
