/**
 * A reactive (in terms of Vue reactivity) entities data cache.
 * To be used for entities with small (< 2000 rows) amount of data to lookup a display value for specified ID.
 *
 * Module is injected into `Vue.prototype` as `$lookups` and exported as `@unitybase/adminui-vue`.lookups.
 *
 * The flow:
 *   - `subscribe` method loads entity data (ID, description attribute and optionally addition attributes specified in attr array)
 *   to the reactive client-side store, and adds a `UB.connection.on(${entity}:changed` listener what change a local cache data
 *   when it's edited locally
 *   - after `subscribe`  methods `lookups.get`, `lookups.getDescriptionById`, `lookups.getEnum` can be used to get a value for
 *   description attribute (ar any other attribute added during `subscribe`) by entity ID value (or by combination of entity attributes values)
 *   - when data for entity no longer needed `unsubscribe` should be called to free a resources
 *
 * **NOTE:** `lookups` subscribes to `ubm_enum` on initialization, so `lookups.getEnum` can be used without addition to `subscribe('umb_enum')`
 *
 * @module lookups
 * @memberOf module:@unitybase/adminui-vue
 */

/**
 * @typedef {object} LookupSubscription
 *
 * @property {number} subscribes Subscribe counter
 * @property {function} onEntityChanged Client local changes listener
 * @property {Set<string>} attrs Lookup attributes
 * @property {Set<string>} [partitions] Lookup partitions
 * @property {array<object>} data Lookup data
 * @property {object} mapById
 * @property {string} descriptionAttrName
 */

const { debounce } = require('throttle-debounce')
const Vue = require('vue')
const UB = require('@unitybase/ub-pub')
const ENUM_ENTITY = 'ubm_enum'
let LOOKUP_CACHE_INTERVAL_MS = 0 // never refresh lookups

const instance = new Vue({
  data () {
    return {
      entities: /** @type {object<string, LookupSubscription>} */ {}
    }
  },

  methods: {
    async init () {
      const lookupCacheIntervalSecFromCfg = UB.connection.appConfig.uiSettings?.adminUI.lookupCacheRefreshIntervalSec
      if (lookupCacheIntervalSecFromCfg !== undefined) {
        if (typeof lookupCacheIntervalSecFromCfg !== 'number') {
          UB.logError('config.uiSettings.adminUI.lookupCacheRefreshIntervalSec should be an Integer. Refresh of lookups is disabled')
        } else {
          LOOKUP_CACHE_INTERVAL_MS = lookupCacheIntervalSecFromCfg * 1000
        }
      }

      const availableEntities = Object.keys(UB.connection.domain.entities)
      for (const entity of availableEntities) {
        this.$set(this.entities, entity, {
          subscribes: 0,
          refreshedAt: undefined,
          onEntityChanged: async response => {
            if (response === undefined) {
              return
            }
            const { ID, method, resultData } = response

            const responseID = resultData ? resultData.ID : ID
            if (responseID === undefined) {
              console.error('Lookups: server response must contain ID')
              return
            }

            const cachedEntity = this.entities[entity]
            if (method === 'delete') {
              const lookupItemIndex = cachedEntity.data.findIndex(item => item.ID === ID)
              cachedEntity.data.splice(lookupItemIndex, 1)
              delete cachedEntity.mapById[ID]
              return
            }

            const attrs = Array.from(cachedEntity.attrs)
            const updatedItem = {}
            const hasAllDataInResponse = attrs.every(attr => attr in resultData)

            if (hasAllDataInResponse) {
              for (const attr of attrs) {
                updatedItem[attr] = resultData[attr]
              }
            } else {
              Object.assign(
                updatedItem,
                await UB.Repository(entity)
                  .attrs(attrs)
                  .selectById(resultData.ID)
              )
            }

            if (method === 'insert') {
              cachedEntity.data.push(updatedItem)
              cachedEntity.mapById[updatedItem.ID] = updatedItem
            }

            if (method === 'update') {
              const lookupItem = cachedEntity.mapById[updatedItem.ID]
              if (lookupItem) {
                Object.assign(lookupItem, updatedItem)
              }
            }
          },
          attrs: new Set(['ID']),
          partitions: undefined,
          pendingAttrs: undefined,
          pendingPartitions: undefined,
          pendingPromise: null,
          data: [],
          mapById: {},
          descriptionAttrName: ''
        })
      }

      await this.subscribe(ENUM_ENTITY, ['eGroup', 'code', 'name', 'sortOrder'])
    },

    async subscribe (entity, attrs = [], partitions) {
      const entityInfo = UB.connection.domain.get(entity)
      if (partitions && !entityInfo.customSettings?.lookupPartitionAttr) {
        UB.logError(`Lookups: Entity "${entity}" supposed to be partitioned, but "lookupPartitionAttr" is not defined in entity custom settings`)
        partitions = undefined
      }
      if (entityInfo.customSettings?.lookupPartitionAttr && !partitions) {
        UB.logWarn(`Lookups: Entity "${entity}" is partitioned, but partitions not specified`)
      }

      const subscription = this.entities[entity]
      const isFirstSubscription = subscription.subscribes === 0
      const hasAdditionalAttrs = !attrs.every(
        attr => subscription.attrs.has(attr) ||
          subscription.pendingAttrs?.has(attr)
      )
      const hasAdditionalPartitions = partitions &&
        // In first subscription even if partitions passed - they are not "additional"
        !isFirstSubscription &&
        // And here, at last, check if there are new partitions to subscribe
        !partitions.every(
          partition => subscription.partitions?.has(partition) ||
            subscription.pendingPartitions?.has(partition)
        )

      if (isFirstSubscription) {
        UB.connection.on(`${entity}:changed`, subscription.onEntityChanged)
        if (partitions) {
          subscription.pendingPartitions = new Set(partitions)
        }
        subscription.descriptionAttrName = entityInfo.getDescriptionAttribute()
        subscription.pendingAttrs = new Set()
        subscription.pendingAttrs.add(subscription.descriptionAttrName)
      }

      if (hasAdditionalAttrs) {
        if (!subscription.pendingAttrs) {
          subscription.pendingAttrs = new Set()
        }
        for (const attr of attrs) {
          subscription.pendingAttrs.add(attr)
        }
      }
      if (hasAdditionalPartitions) {
        if (!subscription.pendingPartitions) {
          subscription.pendingPartitions = new Set()
        }
        for (const partition of partitions) {
          subscription.pendingPartitions.add(partition)
        }
      }

      subscription.subscribes++

      if (subscription.pendingPromise) {
        // Even, if there are additional attrs/partitions, we don't need to initiate loading,
        // because loading is done in a loop, while pendingAttrs/pendingPartitions are not empty
        await subscription.pendingPromise
        return
      }

      if (isFirstSubscription || hasAdditionalAttrs || hasAdditionalPartitions) {
        if (!subscription.debouncedLoading) {
          const loadData = async () => {
            while (subscription.pendingAttrs?.size > 0 || subscription.pendingPartitions?.size > 0) {
              // Move pendingAttrs/pendingPartitions to attrs/partitions, clear them
              if (subscription.pendingAttrs?.size > 0) {
                subscription.attrs = subscription.attrs
                  ? new Set([...subscription.attrs, ...subscription.pendingAttrs])
                  : subscription.pendingAttrs
                subscription.pendingAttrs = undefined
              }
              if (subscription.pendingPartitions?.size > 0) {
                subscription.partitions = subscription.partitions
                  ? new Set([...subscription.partitions, ...subscription.pendingPartitions])
                  : subscription.pendingPartitions
                subscription.pendingPartitions = undefined
              }

              const resultData = await UB.Repository(entity)
                .attrs([...subscription.attrs])
                .whereIf(
                  subscription.partitions,
                  entityInfo.customSettings.lookupPartitionAttr,
                  'in',
                  subscription.partitions ? [...subscription.partitions] : undefined
                )
                .limit(UB.LIMITS.lookupMaxRows)
                .select()

              if (resultData.length >= UB.LIMITS.lookupMaxRows) {
                UB.logError(`Lookups: Entity "${entity}" result truncated to ${UB.LIMITS.lookupMaxRows} records to prevent performance problems. Consider to avoid lookup to a huge entities`)
              } else if (resultData.length >= UB.LIMITS.lookupWarningRows) {
                UB.logWarn(`Lookups: Too many rows (${resultData.length}) returned for "${entity}" lookup. Consider to avoid lookups for huge entities to prevents performance degradation`)
              }
              subscription.data.splice(0, subscription.data.length, ...resultData)
              // create hash by ID for O(1) lookup
              const mapById = {}
              resultData.forEach(r => { mapById[r.ID] = r })
              this.$set(subscription, 'mapById', mapById)
              subscription.refreshedAt = Date.now()
            }
          }

          // Each subscription must have own debouncedLoading instance, so that for different entities
          // debounce work independently.
          subscription.debouncedLoading = debounce(10, async () => {
            // Only now set pendingPromise, so that before real loading started, all subsequent subscribe calls
            // will "bounce" the loading (prolong the debounce timer)
            subscription.pendingPromise = loadData()
            try {
              await subscription.pendingPromise
              subscription.debouncePromiseResolve()
            } catch (e) {
              subscription.debouncePromiseReject(e)
            } finally {
              subscription.pendingPromise = null
              subscription.debouncePromise = null
              subscription.debouncePromiseResolve = null
              subscription.debouncePromiseReject = null
            }
          })
        }

        if (!subscription.debouncePromise) {
          // Initialize a promise, which allow to await for the moment, when debounce timer elapsed,
          // and loading finishes
          subscription.debouncePromise = new Promise((resolve, reject) => {
            subscription.debouncePromiseResolve = resolve
            subscription.debouncePromiseReject = reject
            subscription.debouncedLoading()
          })
        } else {
          // Just prolong the debounce timer
          subscription.debouncedLoading()
        }

        await subscription.debouncePromise
      }
    },

    unsubscribe (entity) {
      const subscription = this.entities[entity]
      subscription.subscribes--
      if (subscription.subscribes === 0) {
        UB.connection.removeListener(`${entity}:changed`, subscription.onEntityChanged)
        subscription.data.splice(0, subscription.data.length)
        // remove additional attrs
        subscription.attrs.clear()
        subscription.partitions = undefined
        subscription.pendingAttrs = undefined
        subscription.pendingPartitions = undefined
        subscription.mapById = {}
        subscription.refreshedAt = undefined
      }
    },

    async refresh (entity, attrs = [], partitions) {
      if (!LOOKUP_CACHE_INTERVAL_MS) return

      const subscription = this.entities[entity]
      if (subscription.refreshedAt + LOOKUP_CACHE_INTERVAL_MS <= Date.now()) {
        const subscription = this.entities[entity]
        subscription.attrs.clear()
        await this.subscribe(entity, attrs, partitions)
      }
    },

    getDescriptionById (entity, ID) {
      const subscription = this.entities[entity]
      // for safe deleted record
      if (subscription.mapById[ID] === undefined) {
        return '---'
      }
      return subscription.mapById[ID][subscription.descriptionAttrName]
    },

    get (entity, predicate, resultIsRecord = false) {
      if (predicate === null) {
        return resultIsRecord ? {} : null
      }
      let founded
      if (typeof predicate === 'number') {
        founded = this.entities[entity].mapById[predicate]
      } else if (typeof predicate === 'object') {
        const pKeys = Object.keys(predicate)
        founded = this.entities[entity].data.find(
          r => pKeys.every(k => r[k] === predicate[k])
        )
      }

      if (resultIsRecord) {
        return founded || {}
      } else {
        if (founded) {
          return founded[this.entities[entity].descriptionAttrName]
        } else {
          return null
        }
      }
    },

    getMany (entity, predicate) {
      if (typeof predicate !== 'object' || predicate === null) {
        return []
      }

      const pKeys = Object.keys(predicate)
      return this.entities[entity].data.filter(
        r => pKeys.every(k => r[k] === predicate[k])
      )
    }
  }
})

module.exports = {
  /**
   * Subscribes to the local (in the current browser) entity changes. First call to `subscribe` for entity loads it data into client
   * @example
   *    const App = require('@unitybase/adminui-vue')
   *    await App.lookups.subscribe('tst_dictionary', ['code', 'userID'])
   *
   * @param {string} entity Entity name
   * @param {array<string>} [attrs] lookup attributes (in addition to ID and description attribute)
   * @param {array<number|string>} [partitions] lookup partitions
   * @returns {Promise<void>}
   */
  subscribe (entity, attrs, partitions) {
    return instance.subscribe(entity, attrs, partitions)
  },
  /**
   * Unsubscribe from entity changes. In case this is a last subscriber, data cache for entity is cleaned
   *
   * @param {string} entity Entity name
   */
  unsubscribe (entity) {
    instance.unsubscribe(entity)
  },

  /**
   * Refresh lookups associated with specified entity
   * @param {string} entity
   * @param {array<string>} [attrs] lookup attributes (in addition to ID and description attribute)
   * @param {array<number|string>} partitions lookup partitions
   * @returns {Promise<void>}
   */
  refresh (entity, attrs = [], partitions) {
    return instance.refresh(entity, attrs, partitions)
  },
  /**
   * Initialize lookups reactivity by create stubs for all available domain entities.
   * Subscribes to enum entity.
   * @private
   * @returns {Promise<void>}
   */
  init: instance.init,
  /**
   * Search for cached record inside in-memory entity values cache using predicate
   * @example
   *    // get description attribute value for tst_dictionary with ID=123
   *    // since second argument is number perform O(1) lookup by ID
   *    const dictD = lookups.get('tst_dictionary', 123)
   *    // get description attribute value for tst_dictionary with code='code10'
   *    // if code is not unique - returns FIRST occurrence
   *    // complexity is O(N) where n is entity row count
   *    const dictCode10D = lookups.get('tst_dictionary', {code: 'code10'})
   *    // search predicate can be complex
   *    lookups.get('ubm_enum', {eGroup: 'AUDIT_ACTION', code: 'INSERT'})
   *    // if third parameter specified - use it as attribute name for returned value instead of description attribute
   *    const dict123UserName = lookups.get('tst_dictionary', 123, 'userID.fullName')
   *    // if third parameter is `true` - return an object with all attributes specified during `subscribe`
   *    const objWithAllSubscribedAttrs = lookups.get('tst_dictionary', 245671369782, true)
   *
   * @param {string} entity Entity name
   * @param {number|Object|null} predicate
   *   In case predicate is of type number - search by ID - O(1)
   *   In case predicate is Object - search for record what match all predicate attributes - O(N)
   * @param {boolean} [resultIsRecord=false]
   *   - if `true` then return record as a result, in other cases - value of entity `displayAttribute`
   * @returns {*}
   */
  get (entity, predicate, resultIsRecord) {
    return instance.get(entity, predicate, resultIsRecord)
  },
  /**
   * Fast O(1) lookup by ID. The same as `lookups.get('entity_code', idAsNumber)`
   * but returns '---' in case row with specified ID is not found
   *
   * @param {string} entity Entity name
   * @param {number} ID
   * @returns {string} Value if description attribute or '---' in case record not found
   */
  getDescriptionById (entity, ID) {
    return instance.getDescriptionById(entity, ID)
  },
  /**
   * Get enum description by eGroup and code. Alias for `.get('ubm_enum', { eGroup, code })`
   *
   * @param {string} eGroup
   * @param {string} code
   * @returns {string|null}
   */
  getEnum (eGroup, code) {
    return instance.get(ENUM_ENTITY, { eGroup, code })
  },
  /**
   * Get all enum items enum by eGroup.
   *
   * @param {string} eGroup
   * @returns {array}
   */
  getEnumItems (eGroup) {
    const items = instance.getMany(ENUM_ENTITY, { eGroup })
    return items.sort((a, b) => a.sortOrder - b.sortOrder).map(item => ({ code: item.code, name: item.name }))
  }
}

module.exports.install = function (Vue) {
  /** @type {module:lookups} */
  Vue.prototype.$lookups = module.exports
  if (UB.core.UBApp) {
    UB.core.UBApp.on('applicationReady', () => {
      module.exports.init()
    })
  }
}