const _ = require('lodash/fp')
const crypto = require('crypto')
const pgp = require('pg-promise')()
const { getTimezoneOffset } = require('date-fns-tz')
const { millisecondsToMinutes } = require('date-fns/fp')

const BN = require('./bn')
const dbm = require('./postgresql_interface')
const db = require('./db')
const logger = require('./logger')
const logs = require('./logs')
const T = require('./time')
const configManager = require('./new-config-manager')
const ticker = require('./ticker')
const wallet = require('./wallet')
const walletScoring = require('./wallet-scoring')
const exchange = require('./exchange')
const sms = require('./sms')
const email = require('./email')
const cashOutHelper = require('./cash-out/cash-out-helper')
const machineLoader = require('./machine-loader')
const commissionMath = require('./commission-math')
const loyalty = require('./loyalty')
const transactionBatching = require('./tx-batching')

const { CASH_UNIT_CAPACITY, CASH_OUT_DISPENSE_READY, CONFIRMATION_CODE } = require('./constants')

const notifier = require('./notifier')

const { utils: coinUtils } = require('@lamassu/coins')

const mapValuesWithKey = _.mapValues.convert({
  cap: false
})

const TRADE_TTL = 2 * T.minutes
const STALE_TICKER = 3 * T.minutes
const STALE_BALANCE = 3 * T.minutes
const tradesQueues = {}

function plugins (settings, deviceId) {

  function internalBuildRates (tickers, withCommission = true) {
    const localeConfig = configManager.getLocale(deviceId, settings.config)
    const cryptoCodes = localeConfig.cryptoCurrencies

    const rates = {}

    cryptoCodes.forEach((cryptoCode, i) => {
      const rateRec = tickers[i]
      const commissions = configManager.getCommissions(cryptoCode, deviceId, settings.config)

      if (!rateRec) return

      const cashInCommission = new BN(1).plus(new BN(commissions.cashIn).div(100))

      const cashOutCommission = _.isNil(commissions.cashOut)
        ? undefined
        : new BN(1).plus(new BN(commissions.cashOut).div(100))

      if (Date.now() - rateRec.timestamp > STALE_TICKER) return logger.warn('Stale rate for ' + cryptoCode)
      const rate = rateRec.rates

      withCommission ? rates[cryptoCode] = {
        cashIn: rate.ask.times(cashInCommission).decimalPlaces(5),
        cashOut: cashOutCommission && rate.bid.div(cashOutCommission).decimalPlaces(5)
      } : rates[cryptoCode] = {
        cashIn: rate.ask.decimalPlaces(5),
        cashOut: rate.bid.decimalPlaces(5)
      }
    })
    return rates
  }

  function buildRatesNoCommission (tickers) {
    return internalBuildRates(tickers, false)
  }

  function buildRates (tickers) {
    return internalBuildRates(tickers, true)
  }

  function getNotificationConfig () {
    return configManager.getGlobalNotifications(settings.config)
  }

  function buildBalances (balanceRecs) {
    const localeConfig = configManager.getLocale(deviceId, settings.config)
    const cryptoCodes = localeConfig.cryptoCurrencies

    const balances = {}

    cryptoCodes.forEach((cryptoCode, i) => {
      const balanceRec = balanceRecs[i]
      if (!balanceRec) return logger.warn('No balance for ' + cryptoCode + ' yet')
      if (Date.now() - balanceRec.timestamp > STALE_BALANCE) return logger.warn('Stale balance for ' + cryptoCode)

      balances[cryptoCode] = balanceRec.balance
    })

    return balances
  }

  function isZeroConf (tx) {
    const walletSettings = configManager.getWalletSettings(tx.cryptoCode, settings.config)
    const zeroConfLimit = walletSettings.zeroConfLimit || 0
    return tx.fiat.lte(zeroConfLimit)
  }

  const accountProvisioned = (cashUnitType, cashUnits, redeemableTxs) => {
    const kons = (cashUnits, tx) => {
      // cash-out-helper sends 0 as fallback value, need to filter it out as there are no '0' denominations
      const cashUnitsBills = _.flow(
        _.get(['bills']),
        _.filter(it => _.includes(cashUnitType, it.name) && it.denomination > 0),
        _.zip(cashUnits),
      )(tx)

      const sameDenominations = ([cashUnit, bill]) => cashUnit?.denomination === bill?.denomination
      if (!_.every(sameDenominations, cashUnitsBills))
        throw new Error(`Denominations don't add up, ${cashUnitType}s were changed.`)

      return _.map(
        ([cashUnit, { provisioned }]) => _.set('count', cashUnit.count - provisioned, cashUnit),
        cashUnitsBills
      )
    }

    return _.reduce(kons, cashUnits, redeemableTxs)
  }

  function computeAvailableCassettes (cassettes, redeemableTxs) {
    if (_.isEmpty(redeemableTxs)) return cassettes
    cassettes = accountProvisioned('cassette', cassettes, redeemableTxs)
    if (_.some(({ count }) => count < 0, cassettes))
      throw new Error('Negative note count: %j', counts)
    return cassettes
  }

  function computeAvailableRecyclers (recyclers, redeemableTxs) {
    if (_.isEmpty(redeemableTxs)) return recyclers
    recyclers = accountProvisioned('recycler', recyclers, redeemableTxs)
    if (_.some(({ count }) => count < 0, recyclers))
      throw new Error('Negative note count: %j', counts)
    return recyclers
  }

  function buildAvailableCassettes (excludeTxId) {
    const cashOutConfig = configManager.getCashOut(deviceId, settings.config)

    if (!cashOutConfig.active) return Promise.resolve()

    return Promise.all([dbm.cassetteCounts(deviceId), cashOutHelper.redeemableTxs(deviceId, excludeTxId)])
      .then(([{ counts, numberOfCassettes }, redeemableTxs]) => {
        redeemableTxs = _.reject(_.matchesProperty('id', excludeTxId), redeemableTxs)

        const denominations = _.map(
          it => cashOutConfig[`cassette${it}`],
          _.range(1, numberOfCassettes+1)
        )
    
        if (counts.length !== denominations.length)
          throw new Error('Denominations and respective counts do not match!')

        const cassettes = _.map(
          it => ({
            name: `cassette${it + 1}`,
            denomination: parseInt(denominations[it], 10),
            count: parseInt(counts[it], 10)
          }),
          _.range(0, numberOfCassettes)
        )

        const virtualCassettes = denominations.length ? [Math.max(...denominations) * 2] : []

        try {
          return {
            cassettes: computeAvailableCassettes(cassettes, redeemableTxs),
            virtualCassettes
          }
        } catch (err) {
          logger.error(err)
          return {
            cassettes,
            virtualCassettes
          }
        }
      })
  }

  function buildAvailableRecyclers (excludeTxId) {
    const cashOutConfig = configManager.getCashOut(deviceId, settings.config)

    if (!cashOutConfig.active) return Promise.resolve()

    return Promise.all([dbm.recyclerCounts(deviceId), cashOutHelper.redeemableTxs(deviceId, excludeTxId)])
      .then(([{ counts, numberOfRecyclers }, redeemableTxs]) => {
        redeemableTxs = _.reject(_.matchesProperty('id', excludeTxId), redeemableTxs)

        const denominations = _.map(
          it => cashOutConfig[`recycler${it}`],
          _.range(1, numberOfRecyclers+1)
        )

        if (counts.length !== denominations.length)
          throw new Error('Denominations and respective counts do not match!')

        const recyclers = _.map(
          it => ({
            number: it + 1,
            name: `recycler${it + 1}`,
            denomination: parseInt(denominations[it], 10),
            count: parseInt(counts[it], 10)
          }),
          _.range(0, numberOfRecyclers)
        )

        const virtualRecyclers = denominations.length ? [Math.max(..._.flatten(denominations)) * 2] : []

        try {
          return {
            recyclers: computeAvailableRecyclers(recyclers, redeemableTxs),
            virtualRecyclers
          }
        } catch (err) {
          logger.error(err)
          return {
            recyclers,
            virtualRecyclers
          }
        }
      })
  }

  function buildAvailableUnits (excludeTxId) {
    return Promise.all([buildAvailableCassettes(excludeTxId), buildAvailableRecyclers(excludeTxId)])
      .then(([cassettes, recyclers]) => ({ cassettes: cassettes.cassettes, recyclers: recyclers.recyclers }))
  }

  function fetchCurrentConfigVersion () {
    const sql = `select id from user_config
  where type=$1
  and valid
  order by id desc
  limit 1`

    return db.one(sql, ['config'])
      .then(row => row.id)
  }

  function mapCoinSettings (coinParams) {
    const [ cryptoCode, cryptoNetwork ] = coinParams
    const commissions = configManager.getCommissions(cryptoCode, deviceId, settings.config)
    const minimumTx = new BN(commissions.minimumTx)
    const cashInFee = new BN(commissions.fixedFee)
    const cashInCommission = new BN(commissions.cashIn)
    const cashOutCommission = _.isNumber(commissions.cashOut) ? new BN(commissions.cashOut) : null
    const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode)
    const cryptoUnits = configManager.getCryptoUnits(cryptoCode, settings.config)

    return {
      cryptoCode,
      cryptoCodeDisplay: cryptoRec.cryptoCodeDisplay ?? cryptoCode,
      display: cryptoRec.display,
      isCashInOnly: Boolean(cryptoRec.isCashinOnly),
      minimumTx: BN.max(minimumTx, cashInFee),
      cashInFee,
      cashInCommission,
      cashOutCommission,
      cryptoNetwork,
      cryptoUnits
    }
  }

  function getTickerRates (fiatCode, cryptoCode) {
    return ticker.getRates(settings, fiatCode, cryptoCode)
  }

  function pollQueries () {
    const localeConfig = configManager.getLocale(deviceId, settings.config)
    const fiatCode = localeConfig.fiatCurrency
    const cryptoCodes = localeConfig.cryptoCurrencies

    const tickerPromises = cryptoCodes.map(c => getTickerRates(fiatCode, c))
    const balancePromises = cryptoCodes.map(c => fiatBalance(fiatCode, c))
    const networkPromises = cryptoCodes.map(c => wallet.cryptoNetwork(settings, c))
    const supportsBatchingPromise = cryptoCodes.map(c => wallet.supportsBatching(settings, c))

    return Promise.all([
      buildAvailableCassettes(),
      buildAvailableRecyclers(),
      fetchCurrentConfigVersion(),
      millisecondsToMinutes(getTimezoneOffset(localeConfig.timezone)),
      loyalty.getNumberOfAvailablePromoCodes(),
      Promise.all(supportsBatchingPromise),
      Promise.all(tickerPromises),
      Promise.all(balancePromises),
      Promise.all(networkPromises)
    ])
      .then(([
        cassettes,
        recyclers,
        configVersion,
        timezone,
        numberOfAvailablePromoCodes,
        batchableCoins,
        tickers,
        balances,
        networks
      ]) => {
        const coinsWithoutRate = _.flow(
          _.zip(cryptoCodes),
          _.map(mapCoinSettings)
        )(networks)

        const coins = _.flow(
          _.map(it => ({ batchable: it })),
          _.zipWith(
            _.assign,
            _.zipWith(_.assign, coinsWithoutRate, tickers)
          )
        )(batchableCoins)

        return {
          cassettes,
          recyclers: recyclers,
          rates: buildRates(tickers),
          balances: buildBalances(balances),
          coins,
          configVersion,
          areThereAvailablePromoCodes: numberOfAvailablePromoCodes > 0,
          timezone
        }
      })
  }

  function sendCoins (tx) {
    return wallet.supportsBatching(settings, tx.cryptoCode)
      .then(supportsBatching => {
        if (supportsBatching) {
          return transactionBatching.addTransactionToBatch(tx)
            .then(() => ({
              batched: true,
              sendPending: false,
              error: null,
              errorCode: null
            }))
        }
        return wallet.sendCoins(settings, tx)
      })
  }

  function recordPing (deviceTime, version, model) {
    const devices = {
      version,
      model,
      last_online: deviceTime
    }

    return Promise.all([
      db.none(`insert into machine_pings(device_id, device_time) values($1, $2) 
            ON CONFLICT (device_id) DO UPDATE SET device_time = $2, updated = now()`, [deviceId, deviceTime]),
      db.none(pgp.helpers.update(devices, null, 'devices') + 'WHERE device_id = ${deviceId}', {
        deviceId
      })
    ])
  }

  function pruneMachinesHeartbeat () {
    const sql = `DELETE FROM machine_network_heartbeat h
        USING (SELECT device_id, max(created) as lastEntry FROM machine_network_heartbeat GROUP BY device_id) d
        WHERE d.device_id = h.device_id AND h.created < d.lastEntry`
    db.none(sql)
  }

  function isHd (tx) {
    return wallet.isHd(settings, tx)
  }

  function getStatus (tx) {
    return wallet.getStatus(settings, tx, deviceId)
  }

  function newAddress (tx) {
    const info = {
      cryptoCode: tx.cryptoCode,
      label: 'TX ' + Date.now(),
      account: 'deposit',
      hdIndex: tx.hdIndex,
      cryptoAtoms: tx.cryptoAtoms,
      isLightning: tx.isLightning
    }
    return wallet.newAddress(settings, info, tx)
  }

  function fiatBalance (fiatCode, cryptoCode) {
    const commissions = configManager.getCommissions(cryptoCode, deviceId, settings.config)
    return Promise.all([
      getTickerRates(fiatCode, cryptoCode),
      wallet.balance(settings, cryptoCode)
    ])
      .then(([rates, balanceRec]) => {
        if (!rates || !balanceRec) return null

        const rawRate = rates.rates.ask
        const cashInCommission = new BN(1).minus(new BN(commissions.cashIn).div(100))
        const balance = balanceRec.balance

        if (!rawRate || !balance) return null

        const rate = rawRate.div(cashInCommission)

        const lowBalanceMargin = new BN(0.95)

        const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode)
        const unitScale = cryptoRec.unitScale
        const shiftedRate = rate.shiftedBy(-unitScale)
        const fiatTransferBalance = balance.times(shiftedRate).times(lowBalanceMargin)

        return {
          timestamp: balanceRec.timestamp,
          balance: fiatTransferBalance.integerValue(BN.ROUND_DOWN).toString()
        }
      })
  }

  function notifyConfirmation (tx) {
    logger.debug('notifyConfirmation')

    const phone = tx.phone

    const timestamp = `${(new Date()).toISOString().substring(11, 19)} UTC`
    return sms.getSms(CASH_OUT_DISPENSE_READY, phone, { timestamp })
      .then(smsObj => {
        const rec = {
          sms: smsObj
        }
    
        return sms.sendMessage(settings, rec)
          .then(() => {
            const sql = 'UPDATE cash_out_txs SET notified=$1 WHERE id=$2'
            const values = [true, tx.id]

            return db.none(sql, values)
          })
      })
  }

  function notifyOperator (tx, rec) {
    // notify operator about new transaction and add high volume txs to database
    return notifier.transactionNotify(tx, rec)
  }

  function clearOldLogs () {
    return logs.clearOldLogs()
      .catch(logger.error)
  }

  function pong () {
    return db.none(`UPDATE server_events SET created=now() WHERE event_type=$1;
       INSERT INTO server_events (event_type) SELECT $1
       WHERE NOT EXISTS (SELECT 1 FROM server_events WHERE event_type=$1);`, ['ping'])
      .catch(logger.error)
  }

  /*
   * Trader functions
   */

  function buy (rec, tx) {
    return buyAndSell(rec, true, tx)
  }

  function sell (rec) {
    return buyAndSell(rec, false)
  }

  function buyAndSell (rec, doBuy, tx) {
    const cryptoCode = rec.cryptoCode
    const fiatCode = rec.fiatCode
    const cryptoAtoms = doBuy ? commissionMath.fiatToCrypto(tx, rec, deviceId, settings.config) : rec.cryptoAtoms.negated()

    const market = [fiatCode, cryptoCode].join('')

    if (!exchange.active(settings, cryptoCode)) return

    const direction = doBuy ? 'cashIn' : 'cashOut'
    const internalTxId = tx ? tx.id : rec.id
    logger.debug('[%s] Pushing trade: %d', market, cryptoAtoms)
    if (!tradesQueues[market]) tradesQueues[market] = []
    tradesQueues[market].push({
      direction,
      internalTxId,
      fiatCode,
      cryptoAtoms,
      cryptoCode,
      timestamp: Date.now()
    })
  }

  function consolidateTrades (cryptoCode, fiatCode) {
    const market = [fiatCode, cryptoCode].join('')

    const marketTradesQueues = tradesQueues[market]
    if (!marketTradesQueues || marketTradesQueues.length === 0) return null

    logger.debug('[%s] tradesQueues size: %d', market, marketTradesQueues.length)
    logger.debug('[%s] tradesQueues head: %j', market, marketTradesQueues[0])

    const t1 = Date.now()

    const filtered = marketTradesQueues
      .filter(tradeEntry => {
        return t1 - tradeEntry.timestamp < TRADE_TTL
      })

    const filteredCount = marketTradesQueues.length - filtered.length

    if (filteredCount > 0) {
      tradesQueues[market] = filtered
      logger.debug('[%s] expired %d trades', market, filteredCount)
    }

    if (filtered.length === 0) return null

    const partitionByDirection = _.partition(({ direction }) => direction === 'cashIn')
    const [cashInTxs, cashOutTxs] = _.compose(partitionByDirection, _.uniqBy('internalTxId'))(filtered)

    const cryptoAtoms = filtered
      .reduce((prev, current) => prev.plus(current.cryptoAtoms), new BN(0))

    const timestamp = filtered.map(r => r.timestamp).reduce((acc, r) => Math.max(acc, r), 0)

    const consolidatedTrade = {
      cashInTxs,
      cashOutTxs,
      fiatCode,
      cryptoAtoms,
      cryptoCode,
      timestamp
    }

    tradesQueues[market] = []

    logger.debug('[%s] consolidated: %j', market, consolidatedTrade)
    return consolidatedTrade
  }

  function executeTrades () {
    return machineLoader.getMachines()
      .then(devices => {
        const deviceIds = devices.map(device => device.deviceId)
        const lists = deviceIds.map(deviceId => {
          const localeConfig = configManager.getLocale(deviceId, settings.config)
          const fiatCode = localeConfig.fiatCurrency
          const cryptoCodes = localeConfig.cryptoCurrencies

          return cryptoCodes.map(cryptoCode => ({
            fiatCode,
            cryptoCode
          }))
        })

        const tradesPromises = _.uniq(_.flatten(lists))
          .map(r => executeTradesForMarket(settings, r.fiatCode, r.cryptoCode))

        return Promise.all(tradesPromises)
      })
      .catch(logger.error)
  }

  function executeTradesForMarket (settings, fiatCode, cryptoCode) {
    if (!exchange.active(settings, cryptoCode)) return

    const market = [fiatCode, cryptoCode].join('')
    const tradeEntry = consolidateTrades(cryptoCode, fiatCode)

    if (tradeEntry === null || tradeEntry.cryptoAtoms.eq(0)) return

    return executeTradeForType(tradeEntry)
      .catch(err => {
        tradesQueues[market].push(tradeEntry)
        if (err.name === 'orderTooSmall') return logger.debug(err.message)
        logger.error(err)
      })
  }

  function executeTradeForType (_tradeEntry) {
    const expand = te => _.assign(te, {
      cryptoAtoms: te.cryptoAtoms.abs(),
      type: te.cryptoAtoms.gte(0) ? 'buy' : 'sell'
    })

    const tradeEntry = expand(_tradeEntry)
    const execute = tradeEntry.type === 'buy' ? exchange.buy : exchange.sell

    return recordTrade(tradeEntry)
      .then(newEntry => {
        tradeEntry.tradeId = newEntry.id
        return execute(settings, tradeEntry)
          .catch(err => {
            updateTradeEntry(tradeEntry, newEntry, err)
              .then(() => {
                logger.error(err)
                throw err
              })
          })
      })
  }

  function updateTradeEntry (tradeEntry, newEntry, err) {
    const data = mergeTradeEntryAndError(tradeEntry, err)
    const sql = pgp.helpers.update(data, ['error'], 'trades') + ` WHERE id = ${newEntry.id}`
    return db.none(sql)
  }

  function recordTradeAndTx (tradeId, { cashInTxs, cashOutTxs }, dbTx) {
    const columnSetCashIn = new pgp.helpers.ColumnSet(['tx_id', 'trade_id'], { table: 'cashin_tx_trades' })
    const columnSetCashOut = new pgp.helpers.ColumnSet(['tx_id', 'trade_id'], { table: 'cashout_tx_trades' })
    const mapToEntry = _.map(tx => ({ tx_id: tx.internalTxId, trade_id: tradeId }))
    const queries = []

    if (!_.isEmpty(cashInTxs)) {
      const query = pgp.helpers.insert(mapToEntry(cashInTxs), columnSetCashIn)
      queries.push(dbTx.none(query))
    }
    if (!_.isEmpty(cashOutTxs)) {
      const query = pgp.helpers.insert(mapToEntry(cashOutTxs), columnSetCashOut)
      queries.push(dbTx.none(query))
    }
    return Promise.all(queries)
  }

  function convertBigNumFields (obj) {
    const convert = (value, key) => _.includes(key, ['cryptoAtoms', 'fiat'])
      ? value.toString()
      : value

    const convertKey = key => _.includes(key, ['cryptoAtoms', 'fiat'])
      ? key + '#'
      : key

    return _.mapKeys(convertKey, mapValuesWithKey(convert, obj))
  }

  function mergeTradeEntryAndError (tradeEntry, error) {
    if (error && error.message) {
      return Object.assign({}, tradeEntry, {
        error: error.message.slice(0, 200)
      })
    }
    return tradeEntry
  }

  function recordTrade (_tradeEntry, error) {
    const massage = _.flow(
      mergeTradeEntryAndError,
      _.pick(['cryptoCode', 'cryptoAtoms', 'fiatCode', 'type', 'error']),
      convertBigNumFields,
      _.mapKeys(_.snakeCase)
    )
    const tradeEntry = massage(_tradeEntry, error)
    const sql = pgp.helpers.insert(tradeEntry, null, 'trades') + 'RETURNING *'
    return db.tx(t => {
      return t.oneOrNone(sql)
        .then(newTrade => {
          return recordTradeAndTx(newTrade.id, _tradeEntry, t)
          .then(() => newTrade)
        })
    })
  }

  function sendMessage (rec) {
    const notifications = configManager.getGlobalNotifications(settings.config)

    let promises = []
    if (notifications.email.active && rec.email) promises.push(email.sendMessage(settings, rec))
    if (notifications.sms.active && rec.sms) promises.push(sms.sendMessage(settings, rec))

    return Promise.all(promises)
  }

  function checkDevicesCashBalances (fiatCode, devices) {
    return _.map(device => checkDeviceCashBalances(fiatCode, device), devices)
  }

  function getCashUnitCapacity (model, device) {
    if (!CASH_UNIT_CAPACITY[model]) {
      return CASH_UNIT_CAPACITY.default[device]
    }
    return CASH_UNIT_CAPACITY[model][device]
  }

  function checkDeviceCashBalances (fiatCode, device) {
    const cashOutConfig = configManager.getCashOut(device.deviceId, settings.config)
    const denomination1 = cashOutConfig.cassette1
    const denomination2 = cashOutConfig.cassette2
    const denomination3 = cashOutConfig.cassette3
    const denomination4 = cashOutConfig.cassette4
    const denominationRecycler1 = cashOutConfig.recycler1
    const denominationRecycler2 = cashOutConfig.recycler2
    const denominationRecycler3 = cashOutConfig.recycler3
    const denominationRecycler4 = cashOutConfig.recycler4
    const denominationRecycler5 = cashOutConfig.recycler5
    const denominationRecycler6 = cashOutConfig.recycler6
    const cashOutEnabled = cashOutConfig.active
    const isUnitLow = (have, max, limit) => cashOutEnabled && ((have / max) * 100) < limit
    // const isUnitHigh = (have, max, limit) => cashOutEnabled && ((have / max) * 100) > limit

    // const isUnitOutOfBounds = (have, max, lowerBound, upperBound) => isUnitLow(have, max, lowerBound) || isUnitHigh(have, max, upperBound)

    const notifications = configManager.getNotifications(null, device.deviceId, settings.config)

    const machineName = device.name

    const cashInAlert = device.cashUnits.cashbox > notifications.cashInAlertThreshold
      ? {
        code: 'CASH_BOX_FULL',
        machineName,
        deviceId: device.deviceId,
        notes: device.cashUnits.cashbox
      }
      : null

    const cassette1Alert = device.numberOfCassettes >= 1 && isUnitLow(device.cashUnits.cassette1, getCashUnitCapacity(device.model, 'cassette'), notifications.fillingPercentageCassette1)
      ? {
        code: 'LOW_CASH_OUT',
        cassette: 1,
        machineName,
        deviceId: device.deviceId,
        notes: device.cashUnits.cassette1,
        denomination: denomination1,
        fiatCode
      }
      : null

    const cassette2Alert = device.numberOfCassettes >= 2 && isUnitLow(device.cashUnits.cassette2, getCashUnitCapacity(device.model, 'cassette'), notifications.fillingPercentageCassette2)
      ? {
        code: 'LOW_CASH_OUT',
        cassette: 2,
        machineName,
        deviceId: device.deviceId,
        notes: device.cashUnits.cassette2,
        denomination: denomination2,
        fiatCode
      }
      : null

    const cassette3Alert = device.numberOfCassettes >= 3 && isUnitLow(device.cashUnits.cassette3, getCashUnitCapacity(device.model, 'cassette'), notifications.fillingPercentageCassette3)
      ? {
        code: 'LOW_CASH_OUT',
        cassette: 3,
        machineName,
        deviceId: device.deviceId,
        notes: device.cashUnits.cassette3,
        denomination: denomination3,
        fiatCode
      }
      : null

    const cassette4Alert = device.numberOfCassettes >= 4 && isUnitLow(device.cashUnits.cassette4, getCashUnitCapacity(device.model, 'cassette'), notifications.fillingPercentageCassette4)
      ? {
        code: 'LOW_CASH_OUT',
        cassette: 4,
        machineName,
        deviceId: device.deviceId,
        notes: device.cashUnits.cassette4,
        denomination: denomination4,
        fiatCode
      }
      : null

    const recycler1Alert = device.numberOfRecyclers >= 1 && isUnitLow(device.cashUnits.recycler1, getCashUnitCapacity(device.model, 'recycler'), notifications.fillingPercentageRecycler1)
      ? {
        code: 'LOW_RECYCLER_STACKER',
        cassette: 4,
        machineName,
        deviceId: device.deviceId,
        notes: device.cashUnits.recycler1,
        denomination: denominationRecycler1,
        fiatCode
      }
      : null

    const recycler2Alert = device.numberOfRecyclers >= 2 && isUnitLow(device.cashUnits.recycler2, getCashUnitCapacity(device.model, 'recycler'), notifications.fillingPercentageRecycler2)
      ? {
        code: 'LOW_RECYCLER_STACKER',
        cassette: 4,
        machineName,
        deviceId: device.deviceId,
        notes: device.cashUnits.recycler2,
        denomination: denominationRecycler2,
        fiatCode
      }
      : null

    const recycler3Alert = device.numberOfRecyclers >= 3 && isUnitLow(device.cashUnits.recycler3, getCashUnitCapacity(device.model, 'recycler'), notifications.fillingPercentageRecycler3)
      ? {
        code: 'LOW_RECYCLER_STACKER',
        cassette: 4,
        machineName,
        deviceId: device.deviceId,
        notes: device.cashUnits.recycler3,
        denomination: denominationRecycler3,
        fiatCode
      }
      : null

    const recycler4Alert = device.numberOfRecyclers >= 4 && isUnitLow(device.cashUnits.recycler4, getCashUnitCapacity(device.model, 'recycler'), notifications.fillingPercentageRecycler4)
      ? {
        code: 'LOW_RECYCLER_STACKER',
        cassette: 4,
        machineName,
        deviceId: device.deviceId,
        notes: device.cashUnits.recycler4,
        denomination: denominationRecycler4,
        fiatCode
      }
      : null

    const recycler5Alert = device.numberOfRecyclers >= 5 && isUnitLow(device.cashUnits.recycler5, getCashUnitCapacity(device.model, 'recycler'), notifications.fillingPercentageRecycler5)
      ? {
        code: 'LOW_RECYCLER_STACKER',
        cassette: 4,
        machineName,
        deviceId: device.deviceId,
        notes: device.cashUnits.recycler5,
        denomination: denominationRecycler5,
        fiatCode
      }
      : null

    const recycler6Alert = device.numberOfRecyclers >= 6 && isUnitLow(device.cashUnits.recycler6, getCashUnitCapacity(device.model, 'recycler'), notifications.fillingPercentageRecycler6)
      ? {
        code: 'LOW_RECYCLER_STACKER',
        cassette: 4,
        machineName,
        deviceId: device.deviceId,
        notes: device.cashUnits.recycler6,
        denomination: denominationRecycler6,
        fiatCode
      }
      : null

    return _.compact([
      cashInAlert,
      cassette1Alert,
      cassette2Alert,
      cassette3Alert,
      cassette4Alert,
      recycler1Alert,
      recycler2Alert,
      recycler3Alert,
      recycler4Alert,
      recycler5Alert,
      recycler6Alert
    ])
  }

  function checkCryptoBalances (fiatCode, devices) {
    const fiatBalancePromises = cryptoCodes => _.map(c => fiatBalance(fiatCode, c), cryptoCodes)

    const fetchCryptoCodes = _deviceId => {
      const localeConfig = configManager.getLocale(_deviceId, settings.config)
      return localeConfig.cryptoCurrencies
    }

    const union = _.flow(_.map(fetchCryptoCodes), _.flatten, _.uniq)
    const cryptoCodes = union(devices)
    const checkCryptoBalanceWithFiat = _.partial(checkCryptoBalance, [fiatCode])

    return Promise.all(fiatBalancePromises(cryptoCodes))
      .then(balances => _.map(checkCryptoBalanceWithFiat, _.zip(cryptoCodes, balances)))
  }

  function checkCryptoBalance (fiatCode, rec) {
    const [cryptoCode, fiatBalance] = rec
    if (!fiatBalance) return null

    const notifications = configManager.getNotifications(cryptoCode, null, settings.config)
    const lowAlertThreshold = notifications.cryptoLowBalance
    const highAlertThreshold = notifications.cryptoHighBalance

    const req = {
      cryptoCode,
      fiatBalance,
      fiatCode
    }

    if (_.isFinite(lowAlertThreshold) && new BN(fiatBalance.balance).lt(lowAlertThreshold)) {
      return _.set('code')('LOW_CRYPTO_BALANCE')(req)
    }

    if (_.isFinite(highAlertThreshold) && new BN(fiatBalance.balance).gt(highAlertThreshold)) {
      return _.set('code')('HIGH_CRYPTO_BALANCE')(req)
    }

    return null
  }

  function checkBalances () {
    const localeConfig = configManager.getGlobalLocale(settings.config)
    const fiatCode = localeConfig.fiatCurrency

    return machineLoader.getMachines()
      .then(devices => Promise.all([
        checkCryptoBalances(fiatCode, devices),
        checkDevicesCashBalances(fiatCode, devices)
      ]))
      .then(_.flow(_.flattenDeep, _.compact))
  }

  function randomCode () {
    return new BN(crypto.randomBytes(3).toString('hex'), 16).shiftedBy(-6).toFixed(6).slice(-6)
  }

  function getPhoneCode (phone) {
    const code = settings.config.notifications_thirdParty_sms === 'mock-sms'
      ? '123'
      : randomCode()

    const timestamp = `${(new Date()).toISOString().substring(11, 19)} UTC`
    return sms.getSms(CONFIRMATION_CODE, phone, { code, timestamp })
      .then(smsObj => {
        const rec = {
          sms: smsObj
        }

        return sms.sendMessage(settings, rec)
          .then(() => code)
      })
  }

  function getEmailCode (toEmail) {
    const code = settings.config.notifications_thirdParty_email === 'mock-email'
      ? '123'
      : randomCode()

    const rec = {
      email: {
        toEmail,
        subject: 'Your cryptomat code',
        body: `Your cryptomat code: ${code}`
      }
    }

    return email.sendCustomerMessage(settings, rec)
      .then(() => code)
  }

  function sweepHdRow (row) {
    const txId = row.id
    const cryptoCode = row.crypto_code

    return wallet.sweep(settings, txId, cryptoCode, row.hd_index)
      .then(txHash => {
        if (txHash) {
          logger.debug('[%s] Swept address with tx: %s', cryptoCode, txHash)

          const sql = `update cash_out_txs set swept='t'
      where id=$1`

          return db.none(sql, row.id)
        }
      })
      .catch(err => logger.error('[%s] [Session ID: %s] Sweep error: %s', cryptoCode, row.id, err.message))
  }

  function sweepHd () {
    const sql = `SELECT id, crypto_code, hd_index FROM cash_out_txs
  WHERE hd_index IS NOT NULL AND NOT swept AND status IN ('confirmed', 'instant') AND created > now() - interval '1 week'`

    return db.any(sql)
      .then(rows => Promise.all(rows.map(sweepHdRow)))
      .catch(logger.error)
  }

  function getMachineNames () {
    return machineLoader.getMachineNames(settings.config)
  }

  function getRawRates () {
    const localeConfig = configManager.getGlobalLocale(settings.config)
    const fiatCode = localeConfig.fiatCurrency

    const cryptoCodes = configManager.getAllCryptoCurrencies(settings.config)
    const tickerPromises = cryptoCodes.map(c => getTickerRates(fiatCode, c))

    return Promise.all(tickerPromises)
  }

  function getRates () {
    return getRawRates()
      .then(buildRates)
  }

  function rateAddress (cryptoCode, address) {
    return walletScoring.rateAddress(settings, cryptoCode, address)
  }

  function isWalletScoringEnabled (tx) {
    return walletScoring.isWalletScoringEnabled(settings, tx.cryptoCode)
  }

  function probeLN (cryptoCode, address) {
    return wallet.probeLN(settings, cryptoCode, address)
  }

  return {
    getRates,
    recordPing,
    buildRates,
    getRawRates,
    buildRatesNoCommission,
    pollQueries,
    sendCoins,
    newAddress,
    isHd,
    isZeroConf,
    getStatus,
    getPhoneCode,
    getEmailCode,
    executeTrades,
    pong,
    clearOldLogs,
    notifyConfirmation,
    sweepHd,
    sendMessage,
    checkBalances,
    getMachineNames,
    buy,
    sell,
    getNotificationConfig,
    notifyOperator,
    fetchCurrentConfigVersion,
    pruneMachinesHeartbeat,
    rateAddress,
    isWalletScoringEnabled,
    probeLN,
    buildAvailableUnits
  }
}

module.exports = plugins
