# ao_host.js

    'use strict'

    const PI = require('p-iteration')
    const _isFunction = require('lodash/isFunction')
    const _isEmpty = require('lodash/isEmpty')
    const _includes = require('lodash/includes')
    const _get = require('lodash/get')
    const debug = require('debug')('bfx:hf:algo:ao-host')

    const WsAdapter = require('./ws_adapter')
    const AsyncEventEmitter = require('./async_event_emitter')
    const onMinimumSizeError = require('./host/events/minimum_size_error')
    const onInsufficientBalanceError = require('./host/events/insufficient_balance')
    const onSubmitAllOrders = require('./host/events/submit_all_orders')
    const onCancelAllOrders = require('./host/events/cancel_all_orders')
    const onUpdateState = require('./host/events/update_state')
    const onAssignChannel = require('./host/events/assign_channel')
    const onNotify = require('./host/events/notify')
    const onStop = require('./host/events/stop')
    const withAOUpdate = require('./host/with_ao_update')
    const bindWS2Bus = require('./host/ws2/bind_bus')
    const initAO = require('./host/init_ao')
    const genHelpers = require('./host/gen_helpers')

    /**
     * @typedef {object} EventMetaInformation
     * @property {object} chanFilter - source channel filter
     * @property {string} chanFilter.symbol - source channel symbol
     */

    /**
     * @typedef {object} AOUIDefinition
     * @property {string} label - name of the order to be shown to the user
     * @property {string} id - internal algorithmic order ID
     * @property {string} [uiIcon] - CSS classname of the icon to show
     * @property {string} [customHelp] - documentation
     * @property {number} connectionTimeout - how long to wait before considering
     *   the HF disconnected
     * @property {number} actionTimeout - how long to wait for action confirmatio
     *   before considering the HF disconnected
     * @property {object} [header] - rendered at the top of the form
     * @property {string} [header.component] - component to use for the header
     * @property {string[]} [header.fields] - array of field names to render in
     *   header
     * @property {object[]} sections - the layout definition itself
     * @property {string} sections[].title - rendered above the section
     * @property {string} sections[].name - unique internal ID for the section
     * @property {string[][]} sections[].rows - array of rows of field IDs to
     *   render in the section, two per row.
     * @property {object} fields - field definitions, key'd by ID
     * @property {string[]} actions - array of action names, maximum 2
     */

    /**
     * The AOHost class provides a wrapper around the algo order system, and
     * manages lifetime events/order execution. Internally it hosts a Manager
     * instance from bfx-api-node-core for communication with the Bitfinex API, and
     * listens for websocket stream events in order to update order state/trigger
     * algo order events.
     *
     * Execution is handled by an event system, with events being triggered by
     * Bitfinex API websocket stream payloads, and the algo orders themselves.
     *
     * To start/stop algo orders, `gid = startAO(id, args)` and `stopAO(gid)`
     * methods are provided, with the generated group ID (`gid`) being the same as
     * that used for all atomic orders created by the individual algo orders.
     */
    class AOHost extends AsyncEventEmitter {
      /**
       * @param {object} [args] - arguments
       * @param {object} [args.db] - optional
       * @param {string} [args.wsURL] - wss://api.bitfinex.com/ws/2
       * @param {string} [args.restURL] - https://api.bitfinex.com
       * @param {object} [args.agent] - optional proxy agent
       * @param {object[]} [args.aos] - algo orders to manage
       * @param {number} [args.dms] - dead man switch, active 4 (default)
       */
      constructor (args = {}) {
        super()

        const { aos, wsSettings } = args

        this.aos = aos
        this.adapter = new WsAdapter(wsSettings)

        this.instances = {}

        this.onAOStart = this.onAOStart.bind(this)
        this.onAOStop = this.onAOStop.bind(this)
        this.onAOPersist = this.onAOPersist.bind(this)
        this.loadAO = this.loadAO.bind(this)
        this.triggerAOEvent = this.triggerAOEvent.bind(this)
        this.triggerGlobalEvent = this.triggerGlobalEvent.bind(this)
        this.triggerOrderEvent = this.triggerOrderEvent.bind(this)

        this.adapter.on('meta:error', this.onMetaError.bind(this))
        this.adapter.on('data:ticker', this.onDataTicker.bind(this))
        this.adapter.on('data:trades', this.onDataTrades.bind(this))
        this.adapter.on('data:candles', this.onDataCandles.bind(this))
        this.adapter.on('data:book', this.onDataBook.bind(this))
        this.adapter.on('data:managed:book', this.onDataManagedBook.bind(this))
        this.adapter.on('data:managed:candles', this.onDataManagedCandles.bind(this))
        this.adapter.on('data:notification', this.onDataNotification.bind(this))
        this.adapter.on('meta:reload', this.onMetaReload.bind(this))
        this.adapter.on('meta:connection:update', this.onMetaConnectionUpdate.bind(this))

        this.on('ao:start', this.onAOStart)
        this.on('ao:stop', this.onAOStop)
        this.on('ao:persist', this.onAOPersist)

        bindWS2Bus(this)

        this.adapter.once('order:snapshot', (snapshot) => {
          this.orderSnapshot = snapshot
          this.emit('ready')
        })
      }

      /**
       * Get configured exchange adapter
       *
       * @returns {object} adapter
       */
      getAdapter () {
        return this.adapter
      }

      /**
       * Disconnect & reconnect the exchange adapter
       */
      reconnect () {
        this.adapter.reconnect()
      }

      /**
       * Close the exchange adapter connection.
       *
       * @returns {Promise} p - resolves on connection close
       */
      close () {
        return this.adapter.disconnect()
      }

      /**
       * @param {Error} error - error from incoming event
       * @private
       */
      onMetaError (error) {
        this.emit('error', error)
      }

      /**
       * Update internal connection when the adpater applies an update
       *
       * @param {number} i - connection ID
       * @param {object} c - new connection object
       * @private
       */
      onMetaConnectionUpdate (i, c) {
        Object.values(this.instances).forEach((instance = {}) => {
          const { connection } = instance.state
          const { id } = connection

          if (id === i) {
            connection.c = c
          }
        })
      }

      onMetaReload () {
        Object.values(this.instances).forEach((instance = {}) => {
          const { state = {} } = instance
          state.ev.removeAllListeners()
        })
        this.instances = {}
        this.emit('meta:reload')
      }

      /**
       * Opens a new socket connection on the internal adapter
       */
      connect () {
        this.adapter.connect()
      }

      /**
       * Fetch configured algorithmic orders
       *
       * @returns {Array} aos
       */
      getAOs () {
        return Object.values(this.aos)
      }

      /**
       * Returns the algo order definition identified by the provided ID
       *
       * @param {string} id - i.e. bfx-iceberg
       * @returns {object} aoDef
       */
      getAO (id) {
        return Object.values(this.aos).find(ao => ao.id === id)
      }

      /**
       * Returns the active AO instance state identified by the provided GID
       *
       * @param {string} gid - algo order group ID
       * @returns {object} state - algo order state
       */
      getAOInstance (gid) {
        return this.instances[gid]
      }

      /**
       * Returns an array of all running algo order instances
       *
       * @returns {object[]} aoInstances
       */
      getAOInstances () {
        return Object.values(this.instances)
      }

      /**
       * Propagates notifications
       *
       * @param {object} notification - model
       * @param {object} meta - routing information
       * @private
       */
      onDataNotification (notification, meta = {}) {
        /**
         * Triggered when a notification is received.
         *
         * @event AOHost~dataNotification
         * @param {Array[]} notification - incoming notification data
         * @param {EventMetaInformation} meta - source channel information
         */
        this.triggerGlobalEvent('data', 'notification', notification, meta)
        this.emit('notification', notification)
      }

      /**
       * Propagates tickers
       *
       * @param {object} ticker - model
       * @param {object} meta - routing information
       * @private
       */
      onDataTicker (ticker, meta = {}) {
        /**
         * Triggered when a ticker is received.
         *
         * @event AOHost~dataTicker
         * @param {Array[]} ticker - incoming ticker data
         * @param {EventMetaInformation} meta - source channel information
         */
        this.triggerGlobalEvent('data', 'ticker', ticker, meta)
      }

      /**
       * Propagates trades
       *
       * @param {object[]} trades - models
       * @param {object} meta - routing information
       * @private
       */
      onDataTrades (trades, meta = {}) {
        /**
         * Triggered when a trade snapshot or single trade is received
         *
         * @event AOHost~dataTrades
         * @param {Array[]} update - incoming snapshot or single trade
         * @param {EventMetaInformation} meta - source channel information
         */
        this.triggerGlobalEvent('data', 'trades', trades, meta)
      }

      /**
       * Propagates candles
       *
       * @param {object[]} candles - models
       * @param {object} meta - routing information
       * @private
       */
      onDataCandles (candles, meta = {}) {
        /**
         * Triggered when a candle snapshot or individual candle is received.
         *
         * @event AOHost~dataCandles
         * @param {Array[]} update - incoming snapshot or single candle
         * @param {EventMetaInformation} meta - source channel information
         */
        this.triggerGlobalEvent('data', 'candles', candles, meta)
      }

      /**
       * Propagates order books
       *
       * @param {object} update - partial or full order book snapshot
       * @param {object} meta - routing information
       * @private
       */
      onDataBook (update, meta = {}) {
        /**
         * Triggered when an order book update is received.
         *
         * @event AOHost~dataBook
         * @param {Array[]} update - incoming snapshot or price level
         * @param {EventMetaInformation} meta - source channel information
         */
        this.triggerGlobalEvent('data', 'book', update, meta)
      }

      /**
       * Propagates full managed order books
       *
       * @param {object} book - full managed order book
       * @param {object} meta - routing information
       * @private
       * @fires AOHost~dataManagedBook
       */
      onDataManagedBook (book, meta = {}) {
        /**
         * Triggered when an order book update is received, and an internally
         * managed order book instance is updated. The entire order book is passed
         * to the event listeners.
         *
         * @event AOHost~dataManagedBook
         * @param {object} book - full order boook
         * @param {EventMetaInformation} meta - source channel information
         */
        this.triggerGlobalEvent('data', 'managedBook', book, meta)
      }

      /**
       * Propagates full candle sets
       *
       * @param {object[]} candles - full candle set
       * @param {object} meta - routing information
       * @private
       */
      onDataManagedCandles (candles, meta = {}) {
        /**
         * Triggered when a candle update is received, and an internally managed
         * candle dataset is updated. The entire dataset is passed to the event
         * listeners.
         *
         * @event AOHost~dataManagedCandles
         * @param {object[]} candles - full dataset
         * @param {EventMetaInformation} meta - source channel information
         */
        this.triggerGlobalEvent('data', 'managedCandles', candles, meta)
      }

      /**
       * Loads and starts a single algo order, with the provided serialized state
       *
       * @param {string} id - algo order definition ID
       * @param {string} gid - algo order instance group ID
       * @param {object} loadedState - algo order instance state
       * @returns {string} gid
       * @private
       */
      async loadAO (id, gid, loadedState = {}) {
        const ao = this.getAO(id)

        if (!ao) {
          throw new Error(`unknown algo order: ${id}`)
        }

        const { meta = {} } = ao
        const { unserialize } = meta

        const state = _isFunction(unserialize)
          ? unserialize(loadedState)
          : { ...loadedState }

        state.id = id
        state.gid = gid
        state.channels = []
        state.orders = {}
        state.cancelledOrders = {}
        state.allOrders = {}
        state.ev = new AsyncEventEmitter()

        const h = genHelpers(state, this.adapter)

        /**
         * @typedef {object} AOInstance
         * @property {object} state - instance state used during execution
         * @property {number} state.id - ID of the instance
         * @property {number} state.gid - ID of the order group, attached to all
         *   orders
         * @property {Array} state.channels - subscribed channels and their filters
         * @property {object} state.orders - map of open orders key'd by client ID
         * @property {object} state.cancelledOrders - map of cancelled orders key'd
         *   by client ID
         * @property {object} state.allOrders - map of all orders ever created by
         *   the instance key'd by client ID
         * @property {AsyncEventEmitter} state.ev - internal event emitter
         * @property {module:Helpers} h - helpers bound to the instance
         */
        const inst = { state, h }

        await this.bootstrapAO(ao, inst)
        return this.emit('ao:loaded', gid)
      }

      /**
       * Creates and starts a new algo order instance, based on the AO def
       * identified by the supplied ID
       *
       * @param {string} id - algo order definition ID, i.e. bfx-iceberg
       * @param {object} args - algo order arguments/parameters
       * @param {Function} [gidCB] - callback to acquire GID prior to ao:start
       * @returns {string} gid - instance GID
       */
      async startAO (id, args = {}, gidCB) {
        const ao = this.getAO(id)

        if (!ao) {
          throw new Error(`unknown algo order: ${id}`)
        }

        const inst = initAO(this.adapter, ao, args)

        return this.bootstrapAO(ao, inst, gidCB)
      }

      /**
       * Prepares the provided algo order instance for execution, saves it
       * internally for execution tracking, and starts it. Hooks up event listeners
       * and executes `declareEvents` and `declareChannels` on the instance. Emits
       * the 'ao:start' event.
       *
       * @param {object} ao - base algo order definition
       * @param {object} instance - new algo order to be started
       * @param {Function} [gidCB] - callback to acquire GID prior to ao:start
       * @returns {string} gid - new instance GID
       * @private
       */
      async bootstrapAO (ao, instance = {}, gidCB) {
        const { state } = instance
        const { gid } = state

        state.connection = this.adapter.getConnection()

        this.instances[gid] = instance

        state.ev.on('channel:assign', onAssignChannel.bind(null, this))
        state.ev.on('state:update', onUpdateState.bind(null, this))
        state.ev.on('notify', onNotify.bind(null, this))
        state.ev.on('error:minimum_size', onMinimumSizeError.bind(null, this))
        state.ev.on('error:insufficient_balance', onInsufficientBalanceError.bind(null, this))
        state.ev.on('exec:order:submit:all', onSubmitAllOrders.bind(null, this))
        state.ev.on('exec:order:cancel:all', onCancelAllOrders.bind(null, this))
        state.ev.on('exec:stop', onStop.bind(null, this, gid))

        const { declareEvents, declareChannels } = ao.meta || {}

        if (_isFunction(declareEvents)) {
          await declareEvents(this.instances[gid], this)
        }

        if (_isFunction(declareChannels)) {
          await declareChannels(this.instances[gid], this)
        }

        // Cancel existing orders
        for (let i = 0; i < this.orderSnapshot.length; i += 1) {
          if (this.orderSnapshot[i].gid === +gid) {
            await this.adapter.cancelOrderWithDelay(
              state.connection, 0, this.orderSnapshot[i]
            )
          }
        }

        if (_isFunction(gidCB)) {
          await gidCB(gid)
        }

        await this.emit('ao:start', this.instances[gid])

        return gid
      }

      /**
       * Stops an algo order instance by GID
       *
       * @param {string} gid - algo order instance GID
       */
      async stopAO (gid) {
        const instance = this.instances[gid]

        if (!instance) {
          throw new Error(`unknown AO: ${gid}`)
        }

        await this.emit('ao:stop', instance)
      }

      /**
       * Triggers a 'self' event on an algo order instance with the provided
       * arguments
       *
       * @param {object} instance - algo order instance to operate on
       * @param {string} eventName - name of event to trigger
       * @param {...any} args - event arguments
       * @returns {Promise} p - resolves when all handlers complete
       * @private
       */
      onAOSelfEvent (instance, eventName, ...args) {
        return this.triggerAOEvent(instance, 'self', eventName, ...args)
      }

      /**
       * @returns {boolean} aosRunning - true if any algo order is currently running
       */
      aosRunning () {
        return Object.values(this.instances).find((instance) => {
          const { state } = instance
          const { active } = state
          return active
        })
      }

      /**
       * Handles init for an algo order instance; sets the 'active' flag, subscribes
       * to required channels, and triggers the life.start event.
       *
       * @param {object} instance - algo order instance that has started
       * @private
       */
      async onAOStart (instance = {}) {
        const { channels = [], gid, connection } = instance.state

        await withAOUpdate(this, gid, (instance = {}) => {
          const { state = {} } = instance

          return {
            ...state,
            active: true
          }
        })

        if (!_isEmpty(channels)) {
          channels.forEach(ch => {
            debug('subscribing to channel %j [AO gid %d]', ch, gid)

            this.adapter.subscribe(connection, ch.channel, ch.filter)
          })
        }

        /**
         * Triggered when an algorithmic order begins execution.
         *
         * @event AOHost~lifeStart
         */
        await this.triggerAOEvent(instance, 'life', 'start')
        await this.emit('ao:persist', gid)
      }

      /**
       * Handles algo order teardown; disables the 'active' state flag, unsubscribes
       * from channels, emits the life.stop event, and saves the AO instance.
       *
       * @param {object} instance - algo order instance to operate on
       * @private
       */
      async onAOStop (instance = {}) {
        const { h } = instance
        const { channels = [], gid, connection } = instance.state

        h.clearAllTimeouts()

        await withAOUpdate(this, gid, (instance = {}) => {
          const { state = {} } = instance

          return {
            ...state,
            active: false
          }
        })

        if (!_isEmpty(channels)) {
          channels.forEach(ch => {
            debug('unsubscribing from channel %s [AO gid %d]', ch.channel, gid)
            this.adapter.unsubscribe(connection, ch.channel, ch.filter)
          })
        }

        /**
         * Triggered when an algorithmic order ends execution.
         *
         * @event AOHost~lifeStop
         */
        await this.triggerAOEvent(instance, 'life', 'stop')
        await this.emit('ao:persist', gid)
      }

      /**
       * Serializes & saves an algo order instance state to the DB
       *
       * @param {string} gid - GID of algo order instance to persist
       * @private
       */
      async onAOPersist (gid) {
        const instance = this.instances[gid]

        if (!instance) {
          return
        }

        const { state = {} } = instance
        const { id } = state
        const ao = this.getAO(id)

        if (!ao) {
          throw new Error(`can\t persist unknown ao: ${id}`)
        }

        const { meta = {} } = ao
        const { serialize } = meta

        if (!serialize) {
          debug('can\t save AO %s [%s] due to missing serialize method', gid, id)
          return
        }

        await this.emit('ao:persist:db:update', {
          gid,
          algoID: id,
          state: JSON.stringify(serialize(state)),
          active: state.active
        })

        debug('saved AO %s', gid)
      }

      /**
       * Passes event to the AO instances that know the order
       *
       * @param {string} section - name of section to trigger event on
       * @param {string} eventName - name of event to trigger
       * @param {object} order - order instance to pass to event handler
       * @private
       */
      async triggerOrderEvent (section, eventName, order) {
        if (!this.adapter.orderEventsValid()) {
          return
        }

        const instances = Object.values(this.instances)

        await PI.forEach(instances, async (instance) => {
          const { state = {} } = instance
          const { orders = {}, allOrders = {}, cancelledOrders = {}, id, gid } = state
          const cids = Object.keys(allOrders)
          const cancelledCIds = Object.keys(cancelledOrders)
          const ocid = order.cid + ''

          // Note that we avoid triggering order_cancel for orders cancelled by us.
          // order_cancel is meant to trigger after a user UI interaction
          if (
            _includes(cids, ocid) && // tracked (known) order
            (
              (eventName !== 'order_cancel' && eventName !== 'order_error') ||
              !_includes(cancelledCIds, ocid) // or not canceled by us
            )
          ) {
            debug(
              'triggering order event %s:%s for AO %s [gid %s, o cid %s, %f @ %f %s]',
              section, eventName, id, gid, order.cid, order.amountOrig, order.price,
              order.status
            )

            if (orders[ocid]) {
              orders[ocid].updateFrom(order)
            }

            if (allOrders[ocid]) {
              allOrders[ocid].updateFrom(order)
            }

            if (cancelledOrders[ocid]) {
              cancelledOrders[ocid].updateFrom(order)
            }

            await this.triggerAOEvent(
              instance,
              section,
              eventName,
              allOrders[ocid]
            )
          }
        })
      }

      /**
       * Triggers an event with the supplied arguments on all active algo order
       * instances.
       *
       * @param {string} section - name of section to trigger event on
       * @param {string} eventName - name of event to trigger
       * @param  {...any} args - event arguments
       * @returns {Promise} p - resolves when all handlers complete
       * @private
       */
      async triggerGlobalEvent (section, eventName, ...args) {
        const instances = Object.values(this.instances)

        return PI.forEach(instances, async (instance) => (
          this.triggerAOEvent(instance, section, eventName, ...args)
        ))
      }

      /**
       * Triggers an event on an algo order instance
       *
       * @param {object} instance - algo order instance to operate on
       * @param {string} section - name of section to trigger event on
       * @param {string} eventName - name of event to trigger
       * @param  {...any} args - event arguments
       * @returns {Promise} p - resolves when all handlers complete
       * @private
       */
      async triggerAOEvent (instance, section, eventName, ...args) {
        const { state } = instance
        const { id, gid, ev } = state
        const ao = this.getAO(id)
        const sectionHandlers = (ao.events || {})[section]
        const handler = _get((sectionHandlers || {}), eventName)

        if (!_isFunction(handler)) {
          if (section === 'self') {
            debug('error: unknown handler %s:%s', section, eventName)
          }

          return
        }

        debug(
          'triggering %s:%s for AO %s [gid %s]',
          section, eventName, id, gid
        )

        await handler(instance, ...args)

        return ev.emit(`internal:${section}:${eventName}`, ...args)
      }
    }

    /**
     * How long orders are allowed to settle for before teardown in ms.
     *
     * @type {number}
     * @default 10000
     */
    AOHost.TEARDOWN_GRACE_PERIOD_MS = 1 * 1000

    module.exports = AOHost