const logger = require('../logger');
const {
  UnusableError,
  RoomNotRunningError,
  RoomIsRunningError,
  RoomLockedError,
  HHMNotLoadedError,
} = require('../errors');
const EventEmitter = require('events');
const RoomOpener = require('./components/RoomOpener');
const RepositoryController = require('./components/RepositoryController');
const PluginController = require('./components/PluginController');
const RoleController = require('./components/RoleController');
const RoomErrorHandler = require('./components/RoomErrorHandler');
const RoomConsoleHandler = require('./components/RoomConsoleHandler');
const { stringify } = require('../utils');

/**
 * Event argument object that gets sent from the browser when a room event happens.
 *
 * The `handlerName` can be one of the following:
 * `onPlayerJoin`
 * `onPlayerLeave`
 * `onTeamVictory`
 * `onPlayerChat`
 * `onTeamGoal`
 * `onGameStart`
 * `onGameStop`
 * `onPlayerAdminChange`
 * `onPlayerTeamChange`
 * `onPlayerKicked`
 * `onGamePause`
 * `onGameUnpause`
 * `onPositionsReset`
 * or
 * `onStadiumChange`
 *
 * See the
 * [roomObject documentation](https://github.com/haxball/haxball-issues/wiki/Headless-Host#roomobject)
 * to find out what kind of arguments to expect.
 *
 * @typedef {Object} RoomEventArgs
 * @property {string} handlerName - Name of the haxball room event handler
 *    function that got triggered.
 * @property {Array.<any>} args - Arguments that the event handler function
 *    received.
 */

/**
 * Data object sent from the browser context.
 *
 * Used internally to communicate with the headless browser.
 *
 * Follows the flux standard action form loosely.
 * See (https://github.com/redux-utilities/flux-standard-action).
 *
 * @typedef {Object} BrowserAction
 * @property {string} type - The type of data identifies to the consumer the
 *    nature of the data that was sent.
 * @property {any} [payload] - The optional payload property MAY be any type
 *    of value.
 * @property {boolean} [error] - The optional error property MAY be set to true
 *    if the data represents an error.
 */

/**
 * Represents a file.
 *
 * @typedef {Object} File
 * @property {string} name - Files name.
 * @property {string} content - UTF-8 encoded contents of the file.
 */

/**
 * Emitted when the browser tab gets closed.
 * Renders this RoomController unusable.
 * @event RoomController#page-closed
 * @param {RoomController} room - Instance of RoomController that was
 *    controlling the page.
 */

/**
 * Emitted when the browser tab crashes.
 * Renders this RoomController unusable.
 * @event RoomController#page-crash
 * @param {Error} error - The error that was thrown.
 */

/**
 * Emitted when some script throws an error in the browsers tab.
 * @event RoomController#page-error
 * @param {Error} error - The error that was thrown.
 */

/**
 * Emitted when a browser tab logs an error to the console.
 * @event RoomController#error-logged
 * @param {string} message - The logged error message.
 */

/**
 * Emitted when a browser tab logs a warning to the console.
 * @event RoomController#warning-logged
 * @param {string} message - The logged warning message.
 */

/**
 * Emitted when a browser tab logs to console.
 * @event RoomController#info-logged
 * @param {string} message - The logged message.
 */

/**
 * Emitted when {@link RoomController#openRoom} has been called.
 * @event RoomController#open-room-start
 * @param {Error|UnusableError|RoomIsRunningError|RoomLockedError} [error] - If error happened when starting to open room.
 * @param {object} config - Config object given as argument to
 *    {@link RoomController#openRoom}
 */

/**
 * Emitted when {@link RoomController#openRoom} has finished and the room
 * is running.
 * e.g.
 * ```js
 * room.on('open-room-stop', (err, roomInfo)=> {
 *   if (err) {
 *     console.log('Room did not open.', err);
 *   } else {
 *     console.log('Room was opened', roomInfo);
 *   }
 * });
 * room.openRoom(config);
 * ```
 * @event RoomController#open-room-stop
 * @param {Error|ConnectionError|TimeoutError|InvalidTokenError} [error]
 *    - If error happened when opening the room.
 * @param {object} roomInfo - Information about the room.
 */

/**
 * Emitted when {@link RoomController#closeRoom} has been called.
 * @param {UnusableError} [error] - If the room is at unusable state.
 * @event RoomController#close-room-start
 */

/**
 * Emitted when {@link RoomController#closeRoom} has finished.
 * @param {Error} [error] - If error happened during closeRoom.
 * @event RoomController#close-room-stop
 */

/**
 * Emitted when supported HaxBall roomObject event happens.
 * @event RoomController#room-event
 * @param {RoomEventArgs} roomEventArgs - Event arguments.
 */

/**
 * Emitted when a plugin is loaded.
 * @event RoomController#plugin-loaded
 * @param {PluginData} pluginData - Information about the plugin.
 */

/**
 * Emitted when a plugin is removed.
 * @event RoomController#plugin-removed
 * @param {PluginData} pluginData - Information about the plugin.
 */

/**
 * Emitted when a plugin is enabled.
 * @event RoomController#plugin-enabled
 * @param {PluginData} pluginData - Information about the plugin.
 */

/**
 * Emitted when a plugin is disabled.
 * @event RoomController#plugin-disabled
 * @param {PluginData} pluginData - Information about the plugin.
 */

/**
 * RoomController provides an interface to communicate with
 * [HaxBall roomObject]{@link https://github.com/haxball/haxball-issues/wiki/Headless-Host#roomconfigobject}
 * and
 * [Haxball Headless Manager (HHM)]{@link https://github.com/saviola777/haxball-headless-manager}.
 *
 * Each RoomController controls one tab in the headless browser.
 *
 * You can create new RoomController instances with the
 * [Haxroomie#addRoom]{@link Haxroomie#addRoom} factory method.
 *
 * The API provides a Promise ready way to call the methods or optionally
 * you can listen to the events each method fires.
 */
class RoomController extends EventEmitter {
  /**
   * Constructs a new RoomController object.
   *
   * @param {object} options - Options.
   * @param {object} options.id - ID for the room.
   * @param {object} options.page - Puppeteer.Page object to control.
   * @param {number} [options.timeout=30] - Max time to wait in seconds for the
   *    room to open.
   * @param {string} [options.hhmVersion] - Version of Haxball Headless
   *    Manager to use.
   * @param {File} [hhm] - Haxball Headless Manager source.
   */
  constructor(options) {
    super();

    this.validateArguments(options);

    this.id = options.id;
    this.page = options.page;
    this.timeout = options.timeout || 30;

    this._hhmVersion = options.hhmVersion;
    this._defaultRepoVersion = options.defaultRepoVersion;
    this._hhm = options.hhm;

    this._usable = true;
    this._hhmLoaded = false;
    this._roomInfo = null;
    this._openRoomLock = false;

    this.roomOpener = new RoomOpener({
      id: this.id,
      page: this.page,
      onBrowserAction: (data) => this.onBrowserAction(data),
      timeout: this.timeout,
    });

    this._repositories = new RepositoryController({
      page: this.page,
      defaultRepoVersion: this._defaultRepoVersion,
    });
    this._plugins = new PluginController({ page: this.page });
    this._roles = new RoleController({
      page: this.page,
      plugins: this._plugins,
    });
    this._errorHandler = new RoomErrorHandler({
      page: this.page,
      setRoomState: this.setRoomState.bind(this),
      emit: this.emit.bind(this),
      roomId: this.id,
    });
    this._consoleHandler = new RoomConsoleHandler({
      page: this.page,
      emit: this.emit.bind(this),
      roomId: this.id,
    });

    this.page.on('close', () => {
      this.emit(`page-closed`, this);
      this._usable = false;
    });
  }

  get [Symbol.toStringTag]() {
    return 'RoomController';
  }

  /**
   * Is the room running.
   * @type boolean
   * @default false
   */
  get running() {
    return this._roomInfo ? true : false;
  }

  /**
   * Is Haxball Headless Manager loaded.
   * @type boolean
   * @default false
   */
  get hhmLoaded() {
    return this._hhmLoaded;
  }

  /**
   * Is the instance still usable.
   * @type boolean
   * @default true
   */
  get usable() {
    return this._usable;
  }

  /**
   * If room is running, contains its data (like e.g. `roomInfo.roomLink`).
   * If not running, then this is `null`. Returns a copy of the original
   * object.
   * @type object
   * @default null
   */
  get roomInfo() {
    return JSON.parse(JSON.stringify(this._roomInfo));
  }

  /**
   * If opening of the room is in process, then this will be `true`.
   * @type boolean
   * @default false
   */
  get openRoomLock() {
    return this._openRoomLock;
  }

  /**
   * Object that can be used to control and get information about plugins.
   *
   * **Requires the room to be running!**
   *
   * @type PluginController
   */
  get plugins() {
    if (!this.usable) throw new UnusableError();
    if (!this.running) throw new RoomNotRunningError();
    return this._plugins;
  }

  /**
   * Object that can be used to control and get information about repositories.
   *
   * **Requires the HHM library to be loaded!**
   *
   * To load HHM you can use the [init()]{@link RoomController#init} method or
   * open the room with [openRoom()]{@link RoomController#openRoom}.
   *
   * @type RepositoryController
   */
  get repositories() {
    if (!this.usable) throw new UnusableError();
    if (!this._hhmLoaded) throw new HHMNotLoadedError();
    return this._repositories;
  }

  /**
   * Object that can be used to control and get information about roles.
   *
   * **Requires the room to be running and sav/roles plugin to be loaded
   * and enabled!**
   *
   * @type RoleController
   */
  get roles() {
    if (!this.usable) throw new UnusableError();
    if (!this.running) throw new RoomNotRunningError();
    return this._roles;
  }
  /**
   * Validates the arguments for the constructor.
   *
   * @param {object} options - argument object for the constructor
   * @private
   */
  validateArguments(options) {
    if (!options) {
      throw new Error('Missing required argument: options');
    }
    if (!options.id && options.id !== 0) {
      throw new Error('Missing required argument: options.id');
    }
    if (!options.page)
      throw new Error('Missing required argument: options.page');
  }

  /**
   * Sets a property in this RoomController.
   *
   * Passing this to the composite objects allow them to modify the state
   * of the RoomController.
   * @param {string} property - Property to set.
   * @param {any} value - Value for the property.
   * @private
   */
  setRoomState(property, value) {
    this[property] = value;
  }

  /**
   * This function gets called when the browser wants to send data to the
   * main context.
   *
   * @param {BrowserAction} action - Event arguments.
   * @emits RoomController#room-event
   * @emits RoomController#
   * @private
   */
  async onBrowserAction(action) {
    switch (action.type) {
      case 'HHM_EVENT':
        this.handleHhmEvent;
        break;
      case 'ROOM_EVENT':
        this.emit('room-event', action.payload);
        break;
    }
  }

  /**
   * Handles the HHM_EVENT action type sent from browser context.
   *
   * @emits RoomController#plugin-loaded
   * @emits RoomController#plugin-removed
   * @emits RoomController#plugin-enabled
   * @emits RoomController#plugin-disabled
   * @param {BrowserAction} action - Data sent from browser.
   * @private
   */
  handleHhmEvent(action) {
    switch (action.payload.eventType) {
      case `pluginLoaded`:
        this.emit('plugin-loaded', action.payload.pluginData);
        break;
      case `pluginRemoved`:
        this.emit('plugin-removed', action.payload.pluginData);
        break;
      case `pluginEnabled`:
        this.emit('plugin-enabled', action.payload.pluginData);
        break;
      case `pluginDisabled`:
        this.emit('plugin-disabled', action.payload.pluginData);
        break;
    }
  }

  /**
   * Initializes the RoomController by navigating the page to the headless
   * HaxBall URL and loads the Haxball Headless Manager library.
   *
   * This enables the use of the [repositories]{@link RoomController#repositories}
   * object to get information about repositories before opening the room.
   *
   * **Note that calling [close]{@link RoomController#close} will undo this.**
   *
   * @param {object} [options] - Options.
   * @param {string} [options.hhmVersion] - Version of Haxball Headless
   *    Manager to load. By default a compatible version is used.
   * @param {File} [options.hhm] - Optionally load HHM source from a string.
   */
  async init(options = {}) {
    if (!this.usable) throw new UnusableError('Instance unusable!');

    const hhmVersion = options.hhmVersion || this._hhmVersion;
    const hhm = options.hhm || this._hhm;

    try {
      await this.roomOpener.initializePage({
        hhmVersion,
        hhm,
      });
    } catch (err) {
      this._hhmLoaded = false;
      throw err;
    }
    this._hhmLoaded = true;
  }

  /**
   * Opens a HaxBall room in a browser tab.
   *
   * On top of the documentated properties here, the config object can contain
   * any properties you want to use in your own HHM config file.
   *
   * The config object is
   * usable globally from within the HHM config as the `hrConfig` object.
   *
   * @param {object} config - Config object that contains the room information.
   * @param {string} config.token - Token to start the room with.
   *    Obtain one from <https://www.haxball.com/headlesstoken>.
   * @param {string} [config.roomName] - Room name.
   * @param {string} [config.playerName] - Host player name.
   * @param {int} [config.maxPlayers] - Max players.
   * @param {boolean} [config.public] - Should the room be public?
   * @param {object} [config.geo] - Geolocation override for the room.
   * @param {Array.<Repository>} [config.repositories] - Array of
   *    HHM plugin repositories.
   *
   *    e.g. To load a repository from GitHub:
   *    ```js
   *    repositories: [
   *      {
   *        type: 'github',
   *        repository: 'morko/hhm-sala-plugins',
   *        path: 'src', // optional (defaults to src)
   *        version: 'master', // optional (defaults to master)
   *        suffix: '.js', // optional (defaults to .js)
   *      }
   *    ],
   *    ```
   *
   *    See {@link Repository} for the types of repositories you can use.
   *
   * @param {object} [config.pluginConfig] - Haxball Headless Manager
   *    plugin config object. Passed to `HHM.config.plugins`.
   *
   *    See [Haxball Headless Manager](https://github.com/saviola777/haxball-headless-manager)
   *    This tells HHM which plugins to load from the available repositories.
   *    You can also give the initial config to plugins here.
   * @param {File} [config.roomScript] - Regular haxball
   *    headless script to load when starting the room.
   *
   *    Disables the non essential default plugins.
   * @param {File} [config.hhmConfig] - Configuration for the haxball
   *    headless manager (HHM).
   * @param {string} [config.defaultRepoVersion] - Version of saviola's
   *    plugin repository for Haxball Headless Manager to load. By default
   *    a compatible version is used. This can be overriden by adding the
   *    repository in the `repository` property.
   * @returns {object} - Config that the room was started with.
   *    The `roomLink` property is added to the config (contains URL to the
   *    room).
   *
   * @emits RoomController#open-room-start
   * @emits RoomController#open-room-stop
   *
   * @throws {TypeError} - Something is wrong with the arguments.
   * @throws {UnusableError} - The instance is not usable because the browser
   *    page crashed or closed.
   * @throws {RoomIsRunningError} - The room is already running.
   * @throws {RoomLockedError} - The room is already being opened.
   * @throws {ConnectionError} - Could not connect to HaxBall headless page.
   * @throws {TimeoutError} - Haxball Headless Manager took too much time to
   *    start.
   * @throws {InvalidTokenError} - The token is invalid or expired.
   */
  async openRoom(config) {
    logger.debug(`RoomController#openRoom: ${this.id}`);

    if (!this.usable) {
      let err = new UnusableError('Instance unusable!');
      this.emit(`open-room-start`, err);
      throw err;
    }
    if (this.running) {
      let err = new RoomIsRunningError(
        'The room is already running. Close it before opening again!'
      );
      this.emit(`open-room-start`, err);
      throw err;
    }
    if (this._openRoomLock) {
      let err = new RoomLockedError('Room is already being opened!');
      this.emit(`open-room-start`, err);
      throw err;
    }

    this.emit(`open-room-start`, null, config);
    this._openRoomLock = true;
    config.defaultRepoVersion =
      config.defaultRepoVersion || this._defaultRepoVersion;

    try {
      if (!this.hhmLoaded) await this.init();
      this._roomInfo = await this.roomOpener.open(config);
    } catch (err) {
      this._openRoomLock = false;
      if (process.env.NODE_ENV !== 'development') await this.closeRoom();
      this.emit(`open-room-stop`, err);
      throw err;
    }
    this._openRoomLock = false;
    this.emit(`open-room-stop`, null, this.roomInfo);
    return this._roomInfo;
  }

  /**
   * Closes the headless HaxBall room by navigating the page out of the
   * headless HaxBall URL.
   *
   * @emits RoomController#close-room
   *
   * @throws {UnusableError} - The instance is not usable because the browser
   *    page crashed or closed.
   */
  async closeRoom() {
    logger.debug(`RoomController#closeRoom`);
    if (!this.usable) {
      let err = new UnusableError('Instance unusable!');
      this.emit(`close-room-start`, err);
      throw err;
    }

    this.emit(`close-room-start`);

    try {
      await this.roomOpener.close();
    } catch (err) {
      this._usable = false;
      this._hhmLoaded = false;
      this._roomInfo = null;
      this.emit(`close-room-stop`, new UnusableError(err.msg));
      throw new UnusableError(err.msg);
    }
    this._hhmLoaded = false;
    this._roomInfo = null;
    this.emit(`close-room-stop`);
  }

  /**
   * Calls a function of the
   * [HaxBall roomObject](https://github.com/haxball/haxball-issues/wiki/Headless-Host#roomobject)
   * in the browsers context.
   *
   * @param {string} fn - Name of the haxball roomObject function.
   * @param {any} ...args - Arguments for the function.
   * @returns {Promise.<any>} - Return value of the called function.
   *
   * @throws {UnusableError} - The instance is not usable because the browser
   *    page crashed or closed.
   * @throws {RoomNotRunningError} - The room is not running.
   */
  async callRoom(fn, ...args) {
    logger.debug(
      `[${this.id}] RoomController#callRoom: ${stringify(fn)}, ${stringify(
        args
      )}`
    );

    if (!this.usable) {
      throw new UnusableError('Instance unusable!');
    }
    if (!this.running) {
      throw new RoomNotRunningError('Room is not running.');
    }
    if (!fn) {
      throw new TypeError('Missing required argument: fn');
    }

    let result = await this.page.evaluate(
      (fn, args) => {
        return window.haxroomie.callRoom(fn, ...args);
      },
      fn,
      args
    );
    if (result.error) {
      throw new Error(result.payload);
    }
    return result.payload.result;
  }

  /**
   * Wrapper for Puppeteers
   * [page.evaluate](https://github.com/GoogleChrome/puppeteer/blob/v1.18.0/docs/api.md#pageevaluatepagefunction-args).
   *
   * Evaluates the given code in the browser tab this instace is controlling.
   * You can access the HaxBall roomObject with `HHM.manager.room`.
   *
   * e.g.
   * ```js
   * room.eval('HHM.manager.room.getPlayerList()');
   * ```
   *
   * @param {string|function} pageFunction - JavaScript to evaluate.
   * @param {...Serializable|...JSHandle} [args] - Arguments to pass to `js`.
   * @returns {Promise.<Serializable>} -  Promise which resolves to the
   *    return value of pageFunction.
   */
  async eval(pageFunction, ...args) {
    return this.page.evaluate(pageFunction, ...args);
  }
}

module.exports = RoomController;