/*
 * 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 * as ActionCreators from '../../core/action-creators';
import io from 'socket.io-client';
import { Transport, TransportOpts, MetadataCallback } from './transport';
import {
  CredentialedActionShape,
  LogEntry,
  PlayerID,
  State,
  SyncInfo,
} from '../../types';

interface SocketIOOpts {
  server?: string;
  socketOpts?;
}

type SocketIOTransportOpts = TransportOpts &
  SocketIOOpts & {
    socket?;
  };

/**
 * SocketIO
 *
 * Transport interface that interacts with the Master via socket.io.
 */
export class SocketIOTransport extends Transport {
  server: string;
  socket;
  socketOpts;
  callback: () => void;
  gameMetadataCallback: MetadataCallback;

  /**
   * Creates a new Mutiplayer instance.
   * @param {object} socket - Override for unit tests.
   * @param {object} socketOpts - Options to pass to socket.io.
   * @param {string} gameID - The game ID to connect to.
   * @param {string} playerID - The player ID associated with this client.
   * @param {string} gameName - The game type (the `name` field in `Game`).
   * @param {string} numPlayers - The number of players.
   * @param {string} server - The game server in the form of 'hostname:port'. Defaults to the server serving the client if not provided.
   */
  constructor({
    socket,
    socketOpts,
    store,
    gameID,
    playerID,
    gameName,
    numPlayers,
    server,
  }: SocketIOTransportOpts = {}) {
    super({ store, gameName, playerID, gameID, numPlayers });

    this.server = server;
    this.socket = socket;
    this.socketOpts = socketOpts;
    this.isConnected = false;
    this.callback = () => {};
    this.gameMetadataCallback = () => {};
  }

  /**
   * Called when an action that has to be relayed to the
   * game master is made.
   */
  onAction(state: State, action: CredentialedActionShape.Any) {
    this.socket.emit(
      'update',
      action,
      state._stateID,
      this.gameID,
      this.playerID
    );
  }

  /**
   * Connect to the server.
   */
  connect() {
    if (!this.socket) {
      if (this.server) {
        let server = this.server;
        if (server.search(/^https?:\/\//) == -1) {
          server = 'http://' + this.server;
        }
        if (server.substr(-1) != '/') {
          // add trailing slash if not already present
          server = server + '/';
        }
        this.socket = io(server + this.gameName, this.socketOpts);
      } else {
        this.socket = io('/' + this.gameName, this.socketOpts);
      }
    }

    // Called when another player makes a move and the
    // master broadcasts the update to other clients (including
    // this one).
    this.socket.on(
      'update',
      (gameID: string, state: State, deltalog: LogEntry[]) => {
        const currentState = this.store.getState();

        if (gameID == this.gameID && state._stateID >= currentState._stateID) {
          const action = ActionCreators.update(state, deltalog);
          this.store.dispatch(action);
        }
      }
    );

    // Called when the client first connects to the master
    // and requests the current game state.
    this.socket.on('sync', (gameID: string, syncInfo: SyncInfo) => {
      if (gameID == this.gameID) {
        const action = ActionCreators.sync(syncInfo);
        this.gameMetadataCallback(syncInfo.filteredMetadata);
        this.store.dispatch(action);
      }
    });

    // Keep track of connection status.
    this.socket.on('connect', () => {
      // Initial sync to get game state.
      this.socket.emit('sync', this.gameID, this.playerID, this.numPlayers);
      this.isConnected = true;
      this.callback();
    });
    this.socket.on('disconnect', () => {
      this.isConnected = false;
      this.callback();
    });
  }

  /**
   * Disconnect from the server.
   */
  disconnect() {
    this.socket.close();
    this.socket = null;
    this.isConnected = false;
    this.callback();
  }

  /**
   * Subscribe to connection state changes.
   */
  subscribe(fn: () => void) {
    this.callback = fn;
  }

  subscribeGameMetadata(fn: MetadataCallback) {
    this.gameMetadataCallback = fn;
  }

  /**
   * Updates the game id.
   * @param {string} id - The new game id.
   */
  updateGameID(id: string) {
    this.gameID = id;

    const action = ActionCreators.reset(null);
    this.store.dispatch(action);

    if (this.socket) {
      this.socket.emit('sync', this.gameID, this.playerID, this.numPlayers);
    }
  }

  /**
   * Updates the player associated with this client.
   * @param {string} id - The new player id.
   */
  updatePlayerID(id: PlayerID) {
    this.playerID = id;

    const action = ActionCreators.reset(null);
    this.store.dispatch(action);

    if (this.socket) {
      this.socket.emit('sync', this.gameID, this.playerID, this.numPlayers);
    }
  }
}

export function SocketIO({ server, socketOpts }: SocketIOOpts = {}) {
  return (transportOpts: SocketIOTransportOpts) =>
    new SocketIOTransport({
      server,
      socketOpts,
      ...transportOpts,
    });
}
