/**
 * Client-side data cache. In case of Browser will use `indexedDB`
 * as storage, in case of Node.js will cache data in memory
 *
 * @module UBCache
 * @memberOf module:@unitybase/ub-pub
 */
module.exports = UBCache

/* eslint-disable prefer-promise-reject-errors */
// Originally found on  from https://github.com/mozilla/localForage
const dbInfo = {
  name: 'UB',
  stores: ['permanent', 'session', 'userData'],
  version: 1
}

/**
 * @classdesc
 * Client side cache. Wrapper around <a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB">indexedDB</a>
 * !!! Don't try to refactor this code - starting of transaction in separate promise is not work for Firefox!!! see <a href="http://stackoverflow.com/questions/28388129/inconsistent-interplay-between-indexeddb-transactions-and-promises">this topic</a>
 *
 * Contain functions for simple key/value manipulations and advanced (entity related) manipulations.
 *
 * Create separate <a href="https://developer.mozilla.org/en-US/docs/Web/API/IDBDatabase">database</a>
 * for each connection inside application.
 *
 * For every database will create three <a href ="https://developer.mozilla.org/en-US/docs/Web/API/IDBDatabase.createObjectStore">store</a>
 *
 *  - **permanent** for data persistent between sessions and
 *  - **session** for data, live inside user session only (from login to login)
 *  - **user** for custom data
 *
 * @example
var c = await new UBCache('mydb').createStorage();
c.put([
  {key: 'note1', value: {prop1: 1, prop2: 'do something'} },
  {key: 'note2', value: 'do something else'}
]).then();
c.get('note1').then(UB.logDebug); //output result to console
c.clear();
c.get('note1').then(function(value){
  console.log(value === undefined ? 'all cleared': 'hm... something wrong')
})
 * @class UBCache
 * @author pavel.mash on 17.04.2014 (rewrites to ES6 on 12.2016)
 * @param {string} dbName Name of indexedDB database we create. Usually this is {@link UBConnection#baseURL}. Constructor lower case dbName during creation
 * @param {number} [version] Optionally database version
 */
function UBCache (dbName, version) {
  /**
   * @property {string} dbName name of indexedDB database
   * @readonly
   */
  this.dbName = dbName.toLowerCase()
  this.userDBVersion = version || 1
  /**
   * @property {IDBDatabase} _idbDatabase opened indexed DB. undefined if not accessible (FF in incognito/nodeJS etc.)
   * @private
   */
  this._idbDatabase = undefined
  this.__inMemoryCache = {}
}

/**
 * If indexedDB is available and access to it is allowed - open an indexed DB, otherwise - fallback UBCache to use in-memory storage.
 *
 * MUST be called and awaited before any get/put operations
 *
 * @returns {Promise<UBCache>}
 */
UBCache.prototype.createStorage = function () {
  const me = this
  const iDB = (typeof window !== 'undefined') && window.indexedDB
  if (!iDB || me._idbDatabase) { // indexedDB is not available or already opened
    return Promise.resolve(me)
  }
  return new Promise((resolve, reject) => {
    const openRequest = iDB.open(me.dbName, this.userDBVersion)
    openRequest.onerror = function withStoreOnError (e) {
      const err = e.target && e.target.error
      // FireFox in incognito mode throws:
      // "InvalidStateError: A mutation operation was attempted on a database that did not allow mutations."
      if (err && err.toString().includes('did not allow mutations')) {
        // fallback to in-memory storage
        me._idbDatabase = undefined
        if (window.console) window.console.warn('UBCache fallback to in-memory storage (incognito mode)')
        resolve(me)
      } else {
        reject(e) // openRequest.error.name
      }
    }
    openRequest.onblocked = function () {
      reject({ errMsg: 'databaseIsBlocked', errDetails: 'we need to upgrade database, but some other browser tab also open it' })
    }
    openRequest.onsuccess = function withStoreOnSuccess () {
      me._idbDatabase = openRequest.result
      resolve(me)
    }
    openRequest.onupgradeneeded = function withStoreOnUpgradeNeeded (e) {
      // First time setup: create an empty object stores
      const db = e.target.result
      const tx = e.target.transaction
      console.debug(`upgrading database "${db.name}" from version ${e.oldVersion} to version ${e.newVersion}...`)
      dbInfo.stores.forEach(function (storeName) {
        // noinspection JSUnresolvedVariable
        if (!db.objectStoreNames.contains(storeName)) {
          db.createObjectStore(storeName)
        } else {
          // noinspection JSUnresolvedFunction
          tx.objectStore(storeName).clear()
        }
      })
    }
  })
}

/**
 * SESSION store name
 *
 * @readonly
 * @type {string}
 */
UBCache.SESSION = 'session'
/**
 * PERMANENT store name
 *
 * @readonly
 * @type {string}
 */
UBCache.PERMANENT = 'permanent'

/**
 * Possible cache types for business logic data
 *
 * @readonly
 * @enum
 */
UBCache.cacheTypes = {
  /**
   * No cache performed
   *
   * @type {string}
   */
  None: 'None',

  /**
   * Client validate data version on server for each request
   *
   * @type {string}
   */
  Entity: 'Entity',

  /**
   * First request to data in the current session ALWAYS retrieve data from server. All other requests got a local copy
   *
   * @type {string}
   */
  Session: 'Session',

  /**
   * Client validate data version on server ONLY for first request in the current session
   *
   * @type {string}
   */
  SessionEntity: 'SessionEntity'
}

/**
 * Predefined callback functions, called when indexedDB transaction complete.
 * Can be customized after UBCache is created.
 * Default implementation will do nothing
 *
 * @type {Function}
 * @private
 */
UBCache.prototype.onTransactionComplete = function (e) {
  // if (e.target.mode !== 'readonly'){
  //   UB.logDebug('IDB ' + e.target.mode + ' transaction complete');
  // }
}

/**
 * Predefined callback functions, called when indexedDB transaction aborted.
 * Can be customized after UBCache is created.
 * Default implementation will put error to log
 *
 * @type {Function}
 * @private
 */
UBCache.prototype.onTransactionAbort = function (e) {
  if (window.console) {
    window.console.error('IDB transaction aborted: ' + (e.target.error.message || e.target.errorCode))
  }
}
/**
 * Predefined callback functions, called when error occurred during indexedDB transaction.
 * Can be customized after UBCache is created.
 * Default implementation will put error to log
 *
 * @type {Function}
 * @private
 */
UBCache.prototype.onTransactionError = function (e) {
  if (window.console) {
    window.console.error('IDB transaction failed: ' + e.target.errorCode)
  }
}

/**
 * Retrieve data from store by key. If key not found - resolve result to `undefined`
 *
 * @param {string} key
 * @param {string} [storeName] default to 'userData'
 * @returns {Promise} resolved to key value.
 */
UBCache.prototype.get = function (key, storeName = 'userData') {
  if (!this._idbDatabase) {
    const store = this.__inMemoryCache[storeName]
    return Promise.resolve(store ? store[key] : undefined)
  }

  const trans = this._idbDatabase.transaction([storeName], 'readwrite')
  trans.oncomplete = this.onTransactionComplete
  trans.onabort = this.onTransactionAbort
  trans.onerror = this.onTransactionError
  return new Promise((resolve, reject) => {
    const req = trans.objectStore(storeName).get(key)
    req.onsuccess = function getItemOnSuccess () {
      resolve(req.result)
    }
    req.onerror = function getItemOnError () {
      reject({ errMsg: req.error.name })
    }
  })
}

/**
 * Retrieves all values from store. **This is slow operation - try to avoid it**
 *
 * @param {string} [storeName] default to 'userData'
 * @returns {Promise} resolved to Array of store keys
 */
UBCache.prototype.getAllKeys = function (storeName = 'userData') {
  if (!this._idbDatabase) {
    const store = this.__inMemoryCache[storeName]
    const res = []
    if (store) {
      for (const prop in store) {
        // noinspection JSUnfilteredForInLoop
        res.push(store[prop])
      }
    }
    return Promise.resolve(res)
  }

  const trans = this._idbDatabase.transaction([storeName], 'readwrite')
  trans.oncomplete = this.onTransactionComplete
  trans.onabort = this.onTransactionAbort
  trans.onerror = this.onTransactionError
  return new Promise((resolve, reject) => {
    const results = []
    const req = trans.objectStore(storeName).openCursor()
    req.onsuccess = function (e) {
      const cursor = e.target.result
      if (cursor) {
        results.push(cursor.key)
        cursor.continue()
      } else {
        resolve(results)
      }
    }
    req.onerror = function (e) {
      reject(e.target.result)
    }
  })
}

/**
 * Put one or several values to store (in single transaction).
 * Modifies existing values or inserts as new value if nonexistent.
 *
 * **If value === `undefined` we put null instead, to understand in future get this is null value or key not exist**
 *
 * @param {{key: string, value}|Array<{key: string, value}>} data
 * @param {string} [storeName] default to 'userData'
 * @returns {Promise}
 */
UBCache.prototype.put = function (data, storeName = 'userData') {
  if (!this._idbDatabase) {
    let store = this.__inMemoryCache[storeName]
    if (!store) {
      store = this.__inMemoryCache[storeName] = {}
    }
    if (!Array.isArray(data)) data = [data]
    data.forEach(function (item) {
      store[item.key] = item.value === undefined ? null : item.value
    })
    return Promise.resolve(true)
  }

  const trans = this._idbDatabase.transaction([storeName], 'readwrite')
  trans.oncomplete = this.onTransactionComplete
  trans.onabort = this.onTransactionAbort
  trans.onerror = this.onTransactionError

  return new Promise((resolve, reject) => {
    let req
    if (Array.isArray(data)) {
      data.forEach(function (item, i) {
        req = trans.objectStore(storeName).put(item.value === undefined ? null : item.value, item.key)
        req.onerror = function (e) {
          reject(e.target.result)
        }
        req.onsuccess = function (e) {
          if (i === data.length - 1) {
            resolve(e.target.result)
          }
        }
      })
    } else {
      req = trans.objectStore(storeName).put(data.value === undefined ? null : data.value, data.key)
      req.onsuccess = req.onerror = function (e) {
        resolve(e.target.result)
      }
    }
  })
}

/**
 * Removes all data from the store
 *
 * @param {string} [storeName] default to 'userData'
 * @returns {Promise}
 */
UBCache.prototype.clear = function (storeName = 'userData') {
  if (!this._idbDatabase) {
    this.__inMemoryCache[storeName] = {}
    return Promise.resolve(true)
  }

  const trans = this._idbDatabase.transaction([storeName], 'readwrite')
  trans.oncomplete = this.onTransactionComplete
  trans.onabort = this.onTransactionAbort
  trans.onerror = this.onTransactionError
  return new Promise((resolve, reject) => {
    const req = trans.objectStore(storeName).clear()
    req.onsuccess = function (e) {
      resolve(e.target.result)
    }
    req.onerror = function (e) {
      reject(e.target.result)
    }
  })
}

/**
 * Remove data from store.
 *
 * - If key is *String* - we delete one key;
 * - If key is *Array*  - we delete all keys in array;
 *
 * @function
 * @example
//remove data with key = 'key1' from userData store
$App.cache.remove('key1').then();

//remove 2 rows: with key = 'key1' and 'key2'  from session store
$App.cache.remove(['key1', 'key2'], UBCache.SESSION).then();
 * @param {string|Array<string>|RegExp} key
 * @param {string} [storeName] default to 'userData'
 * @returns {Promise}
 */
UBCache.prototype.remove = function (key, storeName = 'userData') {
  if (!this._idbDatabase) {
    const store = this.__inMemoryCache[storeName]
    if (store) {
      if (Array.isArray(key)) {
        delete store[key]
      } else {
        key.forEach(function (item) {
          delete store[item]
        })
      }
    }
    return Promise.resolve(true)
  }

  const trans = this._idbDatabase.transaction([storeName], 'readwrite')
  trans.oncomplete = this.onTransactionComplete
  trans.onabort = this.onTransactionAbort
  trans.onerror = this.onTransactionError
  return new Promise((resolve, reject) => {
    let req
    if (typeof key === 'string') {
      req = trans.objectStore(storeName).delete(key)
      req.onsuccess = req.onerror = function (e) {
        resolve(e.target.result)
      }
    } else if (Array.isArray(key)) { // non empty array
      if (key.length) {
        key.forEach(function (item, i) {
          req = trans.objectStore(storeName).delete(item)
          req.onerror = function (e) {
            reject(e.target.result)
          }
          req.onsuccess = function (e) {
            if (i === key.length - 1) {
              resolve(e.target.result)
            }
          }
        })
      } else {
        resolve(true) // empty array passed - nothing to delete
      }
    } else {
      reject({ errMsg: 'invalid key for UBCache.remove call' })
    }
  })
}

/**
 * Remove data from store where keys match regExp.
 * Internally use {@link UBCache#getAllKeys} so is slow.
 * Better to use `remove([key1, ..keyN])`
 *
 * @example
console.time('removeIfMach')
$App.cache.removeIfMach(/^admin:ru:cdn_/, 'permanent').then(function() {
   console.timeEnd('removeIfMach');
})
 * @param {RegExp} regExp
 * @param {string} [storeName] default to 'userData'
 * @returns {Promise}
 */
UBCache.prototype.removeIfMach = function (regExp, storeName) {
  const me = this
  return me.getAllKeys(storeName).then(function (allKeys) {
    const machKeys = allKeys.filter((item) => regExp.test(item))
    return me.remove(machKeys, storeName)
  })
}