text: "use strict";
  
  /**
   * @fileOverview Define the Nucleus Datastore class that wraps a Redis client.
   *
   * @author Sebastien Filion
   */
  const fs = require('fs');
  const path = require('path');
  
  const Promise = require('bluebird');
  const redis = require('redis');
  
  const NucleusDeferredClassProxy = require('./DeferredClassProxy.nucleus');
  const NucleusError = require('./Error.nucleus');
  const NucleusEvent = require('./Event.nucleus');
  
  const nucleusValidator = require('./validator.nucleus');
  
  Promise.promisifyAll(fs);
  Promise.promisifyAll(redis.RedisClient.prototype);
  Promise.promisifyAll(redis.Multi.prototype);
  
  const $$dotNotationKeyRegularExpression = /[A-Za-z0-9-_$](([[0-9]+])|(\.[A-Za-z0-9-_$]))+/;
  const $$keyspaceNotificationChannelNameRegularExpression = new RegExp('__keyspace@[0-9]__:.*|__keyevent@[0-9]__:.*');
  const $$predicateRegularExpression = new RegExp('SOP\\:[A-Za-z0-9\\-]+\\:[A-Za-z0-9\\-]+\\:([A-Za-z0-9\\-]+)');
  
  class NucleusDatastore extends NucleusDeferredClassProxy {
  
    /**
     * Creates a Redis client. The constructor returns a Proxy that interfaces the class and a Promise that resolves once
     * the server is connected.
     * @example
     * const datastoreName = 'GameOfThrones';
     * const $datastore = new NucleusDatastore(datastoreName);
     *
     * await $datastore.then(console.log.bind(console, "Datastore is connected and ready");
     *
     * await $datastore.ping();
     *
     * @argument {String} datastoreName
     * @argument {Object} options
     * @argument {Number} [options.$logger=console]
     * @argument {Number} [options.index=0]
     * @argument {Number} [options.port=6379]
     * @argument {String} [options.URL="localhost"]
     *
     * @returns {Proxy}
     */
    constructor (datastoreName = 'Untitled', options = {}) {
      const {
        $logger = console,
        index: datastoreIndex = 0,
        port: datastorePort = 6379,
        URL: datastoreURL = 'localhost'
      } = options;
  
      super();
  
      this.name = datastoreName;
      this.type = 'Redis';
  
      this.index = datastoreIndex;
  
      this.$$handlerCallbackListByChannelName = {};
      this.scriptSHAbyScriptName = {};
  
      this.$$server = redis.createClient({
        db: datastoreIndex,
        host: datastoreURL,
        port: datastorePort
      });
  
      this.$logger = $logger;
  
      this.$$promise = new Promise((resolve, reject) => {
        if (this.$$server.connected) resolve();
        else {
          this.$$server.onAsync('connect', resolve);
          this.$$server.onceAsync('error', reject);
        }
      });
  
      const $$proxy = new Proxy(this, {
        get: function (object, property) {
          if (property in object) return (typeof object[property] === 'function') ? object[property].bind(object) : object[property];
          else if (property in object.$$promise) {
            return (typeof object.$$promise[property] === 'function') ? object.$$promise[property].bind(object.$$promise) : object.$$promise[property];
          }
          else undefined;
        }
      });
  
      this.$$server.on('message', this.handleRedisEvent.bind(this));
      this.$$server.on('pmessage', this.handleRedisEvent.bind(this));
  
      return $$proxy;
    }
  
    /**
     * Adds an item to a hash given a field and its key. `HMSET key field value`
     * @example
     * // Add one item to a specific field.
     * const itemKey = 'JonSnow';
     * const itemField = 'fullName';
     * const item = 'Jon Snow';
     * await $datastore.addItemToHashFieldByName(itemKey, itemField, item);
     *
     * // Add multiple items to multiple fields.
     * const itemKey = 'JonSnow';
     * const hashList = ['fullName', 'Jon Snow', 'father', 'Eddard Stark'];
     * await $datastore.addItemToHashFieldByName(itemKey, itemField, item);
     *
     * // Add item to be serialized as JSON to a field.
     * const itemKey = 'JonSnow';
     * const itemField = 'sibblings';
     * const item = [ 'Rickon Stark', 'Bran Stark', 'Arya Stark', 'Sansa Stark', 'Robb Stark' ];
     * const storedHashList = await $datastore.addItemToHashFieldByName(itemKey, itemField, item);
     *
     * @argument {String} itemKey
     * @argument {String} [itemField]
     * @argument {*} [item]
     * @argument {Array} [hashList]
     *
     * @returns {Promise<*>}
     *
     * @throws Will throw an error if the item key is missing or an empty string.
     * @throws Will throw an error if the item field is missing or an empty string.
     * @throws Will throw an error if an inconsistent list of item field and item is passed.
     */
    addItemToHashFieldByName (itemKey, ...hashList) {
      if (!nucleusValidator.isString(itemKey)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item name must be a string.");
  
      if (hashList.length === 1 && nucleusValidator.isArray(hashList[0])) hashList = hashList[0];
      else if (hashList.length === 1 && nucleusValidator.isObjectLike(hashList[0])) hashList = Reflect.ownKeys(hashList[0])
        .reduce((accumulator, property) => {
          accumulator.push(property);
          accumulator.push(hashList[0][property]);
  
          return accumulator;
        }, []);
  
      if (hashList.length === 2) {
        const [ itemField, item ] = hashList;
  
        if (typeof itemField !== 'string') throw new NucleusError.UnexpectedValueTypeNucleusError("The item field must be a string.");
  
        const stringifiedItem = NucleusDatastore.stringifyItem(item);
  
        return this.$$server.hsetAsync(itemKey, itemField, stringifiedItem)
          .return(item);
      } else if (hashList.length % 2 === 0) {
        hashList = hashList
          .map((item, index) => {
            if (index % 2 === 0) return item;
            else return NucleusDatastore.stringifyItem(item);
          });
  
        return this.$$server.hmsetAsync(itemKey, hashList)
          .return(hashList);
      } else throw new NucleusError.UndefinedContextNucleusError("The number of item field and item provided is inconsistent");
    }
  
    /**
     * Adds an item to a list given its key. `LPUSH key value`
     * @example
     * // Add one item to the list.
     * const itemKey = 'GreatHouseList';
     * const item = 'Lannister';
     * await $datastore.addItemToListByName(itemKey, item);
     *
     * // Add multiple items to the list.
     * const itemKey = 'GreatHouseList';
     * const itemList = [ 'Lannister', 'Tyrell', 'Arryn', 'Targaryen', 'Martell', 'Baratheon' ];
     * await $datastore.addItemToListByName(itemKey, item);
     *
     * @argument {String} itemKey
     * @argument {*} [item]
     * @argument {*[]} [itemList]
     *
     * @returns {Promise<*>}
     *
     * @throws Will throw an error if the item key is missing or an empty string.
     */
    addItemToListByName (itemKey, itemList) {
      if (!nucleusValidator.isString(itemKey)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item name must be a string.");
  
      if (!nucleusValidator.isArray(itemList)) itemList = [itemList];
  
      const stringifiedItemList = itemList.map(NucleusDatastore.stringifyItem);
  
      return this.$$server.lpushAsync.apply(this.$$server, [itemKey].concat(stringifiedItemList))
        .return(arguments[0]);
    }
  
    /**
     * Adds an item to a set. `SADD key value`
     * @example
     * const itemKey = 'WesterosKingSet';
     * const item = 'Tommen Baratheon';
     * await $datastore.addItemToSetByName(itemKey, item);
     *
     * @argument {String} itemKey
     * @argument {String} item
     *
     * @returns {Promise<*>}
     *
     * @throws Will throw an error if the item key is missing or an empty string.
     * @throws Will throw an error if the item is not a string.
     */
    addItemToSetByName (itemKey, item) {
      if (!nucleusValidator.isString(itemKey)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item name must be a string.");
      if (!nucleusValidator.isString(item)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item must be a string.");
  
      const stringifiedItem = NucleusDatastore.stringifyItem(item);
  
      return this.$$server.saddAsync(itemKey, stringifiedItem);
    }
  
    /**
     * Adds a triple to a hexastore.
     * @example
     * const itemKey = 'HouseStarkFamilyTree';
     * const subject = 'Arya Stark'
     * const predicate = 'is-daughter-of';
     * const object = 'Cately Stark Tully;
     * await $datastore.addTripleToHexastore(itemKey, subject, predicate, object);
     *
     * @see {@link http://www.vldb.org/pvldb/1/1453965.pdf|Hexastore paper}
     *
     * @argument {String} itemKey
     * @argument {String} subject
     * @argument {String} predicate
     * @argument {String} object
     *
     * @returns {Promise<void>}
     *
     * @throws Will throw an error if the item key is missing or an empty string.
     * @throws Will throw an error if the subject is not a string.
     * @throws Will throw an error if the predicate is not a string.
     * @throws Will throw an error if the object is not a string.
     */
    addTripleToHexastore (itemKey, subject, predicate, object) {
      if (!nucleusValidator.isString(itemKey)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item name must be a string.");
      if (!nucleusValidator.isString(subject)) throw new NucleusError.UnexpectedValueTypeNucleusError("The subject must be a string.");
      if (!nucleusValidator.isString(predicate)) throw new NucleusError.UnexpectedValueTypeNucleusError("The predicate must be a string.");
      if (!nucleusValidator.isString(object)) throw new NucleusError.UnexpectedValueTypeNucleusError("The object must be a string.");
  
      return this.$$server.multi()
        .zadd(itemKey, 0, `SPO:${subject}:${predicate}:${object}`)
        .zadd(itemKey, 0, `SOP:${subject}:${object}:${predicate}`)
        .zadd(itemKey, 0, `OPS:${object}:${predicate}:${subject}`)
        .zadd(itemKey, 0, `OSP:${object}:${subject}:${predicate}`)
        .zadd(itemKey, 0, `PSO:${predicate}:${subject}:${object}`)
        .zadd(itemKey, 0, `POS:${predicate}:${object}:${subject}`)
        .execAsync();
    }
  
    /**
     * Creates an item. `SET key value`
     * @example
     * // Create an item from a primitive.
     * const itemKey = 'HandOfTheKing';
     * const item = 'Eddard Stark';
     * await $datastore.createItem(itemKey, item);
     *
     * // Create an item from a data structure that will be serialize.
     * const itemKey = 'HandOfTheKing';
     * const item = { fullName: 'Eddard Stark' };
     * await $datastore.createItem(itemKey, item);
     *
     * // Create an item with a time to live.
     * const itemKey = 'HandOfTheKing';
     * const item = 'Eddard Stark';
     * const TTL = 60 * 60 * 54 + 56;
     * await $datastore.createItem(itemKey, item, TTL);
     *
     * @argument {String} itemKey
     * @argument {*} item
     * @argument TTL - Time to live in seconds.
     *
     * @returns {Promise<*>}
     *
     * @throws Will throw an error if the item key is missing or an empty string.
     */
    createItem (itemKey, item, TTL) {
      if (!nucleusValidator.isString(itemKey)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item key must be a string.");
  
      const stringifiedItem = NucleusDatastore.stringifyItem(item);
  
      if (!!TTL) return this.$$server.setexAsync(itemKey, TTL, stringifiedItem)
        .return(item);
  
      return this.$$server.setAsync(itemKey, stringifiedItem)
        .return(item);
    }
  
    /**
     * Creates a resource given its type and ID.
     * @example
     * const resourceType = 'Character';
     * const resourceID = '7e4a3ef0-348a-4386-a15d-440c21df9e99';
     * const resourceAttributes = {
     *   meta: {
     *     createdISOTime: '2018-09-30T21:27:34.156Z'
     *   },
     *   fullName: 'Daenerys Targaryen',
     *   titleList = [
     *     'Queen of the Andals, the Rhoynar, and the First Men',
     *     'Protector of the Seven Kingdoms',
     *     'Khaleesi of the Great Grass Sea',
     *     'Lady of Dragonstone',
     *     'Queen of Meereen'
     *   ]
     * };
     * const resource = await $datastore.createResourceByTypeAndID(resourceType, resourceID, resourceAttributes);
     *
     * resource.ID === resourceID;
     * resource.type === resourceType;
     * resource.fullName === resourceAttributes.fullName;
     *
     * @argument {String} resourceType
     * @argument {String} resourceID
     * @argument {Object} resourceAttributes
     * @argument {Number} [TTL] - Time to live in seconds.
     *
     * @returns {Promise<Object>>} - The resource object including the `ID` and `type` attributes.
     */
    async createResourceByTypeAndID (resourceType, resourceID, resourceAttributes, TTL) {
      if (!resourceAttributes.hasOwnProperty('ID')) resourceAttributes.ID = resourceID;
      if (!resourceAttributes.hasOwnProperty('type')) resourceAttributes.type = resourceType;
  
      await this.addItemToHashFieldByName(`${resourceType}:${resourceID}`, resourceAttributes);
      if (TTL) await this.$$server.expireAsync(`${resourceType}:${resourceID}`, TTL);
  
      return resourceAttributes;
    }
  
    /**
     * Destroys the Redis connection.
     *
     * @returns {Promise}
     */
    destroy () {
  
      return this.$$server.quitAsync()
        .timeout(1000)
        // Seems like using BRPOP or BLPOP prevents it from being quit properly
        // See https://github.com/sebastienfilion/idex.nucleus/issues/34
        .catch(async (error) => {
          if (error.name === 'TimeoutError') {
            const blockingItemKey = this.$$server.command_queue.toArray()
              .reduce((accumulator, { command: commandName, args: [ blockingItemKey ] }) => {
                if (!!accumulator) return accumulator;
  
                if (commandName === 'brpop' || commandName === 'blpop') return blockingItemKey;
              }, undefined);
  
            // If there is no blocking item key, there's something else blocking the connection and should be reported.
            if (!blockingItemKey) throw error;
  
            this.$logger.warn(`The Nucleus Datastore "${this.name}" did not quit as expected. Attempting to kill the client.`);
  
            const $duplicateDatastore = this.duplicateConnection();
  
            await $duplicateDatastore;
  
            // Use the new connection to push a special message to the blocked client until it's released.
            await new Promise(resolve => {
              const $$interval = setInterval(() => {
                if (!this.$$server.connected) {
                  resolve();
                  clearInterval($$interval);
                } else {
                  $duplicateDatastore.$$server.lpush(blockingItemKey, '$$_ForceQuit');
                  this.$$server.quit();
                }
              }, 500);
            });
  
            await $duplicateDatastore.destroy();
  
            this.$logger.info(`The Nucleus Datastore "${this.name}" was successfully killed.`);
          } else throw error;
        });
    }
  
    /**
     * Duplicates the connection.
     * @example
     * const $duplicateDatastore = $datastore('EventHandler');
     *
     * await $duplicateDatastore.then(console.log.bind(console, "Event Handler datastore is connected and ready");
     *
     * await $duplicateDatastore.ping();
     *
     * @argument {String} [datastoreName=`${this.name}Duplicate`]
     *
     * @returns {NucleusDatastore}
     */
    duplicateConnection (datastoreName = `${this.name}Duplicate`) {
      const { db: index, host: URL, port } = this.$$server.options;
  
      return new NucleusDatastore(datastoreName, { $logger: this.$logger || console, index, URL, port });
    }
  
    /**
     * Evaluates a LUA script.
     * @example
     * const LUAScript = `local pattern = ARGV[1]
     * local itemKeyList = redis.call('KEYS', '*'..pattern..'*'
     * for (index, itemKey in pairs(itemKeyList)) do
     *   redis.call('DEL', itemKey)
     * end
     * return [];`
     * const pattern = 'Martell';
     * await $datastore.evaluateLUAScript(LUAScript, pattern);
     *
     * @argument {String} LUAScript
     * @argument {Array} argumentList
     *
     * @returns {Promise<*>}
     */
    evaluateLUAScript (LUAScript, ...argumentList) {
      const augmentedArgumentList = [argumentList.length].concat(Array.apply(null, { length: argumentList.length }).map((empty, index) => index), argumentList);
  
      return this.$$server.evalAsync(LUAScript, augmentedArgumentList)
        .then(NucleusDatastore.parseItem);
    }
  
    /**
     * Evaluates a LUA script given its name.
     * This assumes that the script was pre-loaded and its SHA ID has been stored.
     * @example
     * const LUAScriptName = 'RemoveGreatHouseByName';
     * await $datastore.registerScriptByName(LUAScriptName, LUAScript);
     *
     * await $datastore.evaluateLUAScriptByName(LUAScriptName, 'Martell');
     *
     * @see {@link #registerScriptByName|NucleusDatastore#registerScriptByName}
     *
     * @argument {String} LUAScriptName
     * @argument {Array} argumentList
     *
     * @returns {Promise<*>}
     */
    async evaluateLUAScriptByName (LUAScriptName, ...argumentList) {
      const LUAScriptSHA = this.scriptSHAbyScriptName[LUAScriptName] || await this.retrieveItemFromHashFieldByName('LUAScriptSHAByScriptName', LUAScriptName);
  
      if (!LUAScriptSHA) throw new NucleusError.UndefinedContextNucleusError(`Could not retrieved any registered script for the LUA script "${LUAScriptName}".`);
  
      return this.evaluateLUAScriptBySHA(LUAScriptSHA, ...argumentList);
    }
  
    /**
     * Evaluates a LUA script given its SHA.
     * @see {@link https://redis.io/commands/eval}
     * @see {@link #evaluateLUAScriptByName|NucleusDatastore#evaluateLUAScriptByName}
     *
     * @argument {String} LUAScriptSHA
     * @argument {String} argumentList
     *
     * @returns {Promise<*>}
     */
    evaluateLUAScriptBySHA (LUAScriptSHA, ...argumentList) {
      const augmentedArgumentList = [argumentList.length].concat(Array.apply(null, { length: argumentList.length }).map((empty, index) => index), argumentList);
  
      return this.$$server.evalshaAsync(LUAScriptSHA, augmentedArgumentList)
        .then(NucleusDatastore.parseItem);
    }
  
  
    /**
     * Executes all handler callback for a given channel name.
     *
     * @argument {String} channelName
     * @argument {NucleusEvent} $event
     *
     * @returns {Promise}
     */
    async executeHandlerCallbackForChannelName (channelName, $event) {
      const $$handlerCallbackList = this.$$handlerCallbackListByChannelName[channelName];
  
      if (nucleusValidator.isEmpty($$handlerCallbackList)) return Promise.resolve();
  
      if ($$keyspaceNotificationChannelNameRegularExpression.test(channelName)) {
        this.$logger.debug(`Executing ${$$handlerCallbackList.length} handler callback${($$handlerCallbackList.length > 1) ? 's' : ''} for the channel "${channelName}".`, { channelName, command: $event, processID: process.pid });
  
        return Promise.all($$handlerCallbackList
          .map(($$handlerCallback) => {
  
            return Promise.resolve($$handlerCallback.call(this, channelName, $event));
          }));
      } else {
        const { meta: { correlationID } } = $event;
        this.$logger.debug(`Executing ${$$handlerCallbackList.length} handler callback${($$handlerCallbackList.length > 1) ? 's' : ''} for the channel "${channelName}".`, { channelName, correlationID, eventID: $event.ID, eventName: $event.name, processID: process.pid });
  
        return Promise.all($$handlerCallbackList
          .map(($$handlerCallback) => {
  
            return Promise.resolve($$handlerCallback.call(this, $event));
          }));
      }
    }
  
    /**
     * Verifies if an item is part of a given item set.
     * @example
     * const itemKey = 'Targaryen';
     * const item = 'Jon Snow';
     * const { isMember } = await $datastore.itemIsMemberOfSet(itemKey, item);
     *
     * @argument {String} itemKey
     * @argument {String} item
     *
     * @returns {Promise<Object>}
     *
     * @throws Will throw an error if the item key is missing or an empty string.
     * @throws Will throw an error if the item is not a string.
     */
    itemIsMemberOfSet (itemKey, item) {
      if (!nucleusValidator.isString(itemKey)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item key must be a string.");
      if (!nucleusValidator.isString(item)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item to retrieve must be a string.");
  
      const stringifiedItem = NucleusDatastore.stringifyItem(item);
  
      return this.$$server.sismemberAsync(itemKey, stringifiedItem)
        .then((isMemberCode) => {
  
          return { isMember: !!isMemberCode };
        });
    }
  
    /**
     * Handles event published to a specific channel given a handler callback.
     *
     * @argument {String} channelName
     * @argument {Function} handlerCallback
     *
     * @returns {Promise<Object>}
     */
    handleEventByChannelName (channelName, handlerCallback) {
      if (!this.$$handlerCallbackListByChannelName.hasOwnProperty(channelName)) this.$$handlerCallbackListByChannelName[channelName] = [];
  
      this.$$handlerCallbackListByChannelName[channelName].push(handlerCallback);
  
      return Promise.resolve({ channelName, handlerCallback });
    }
  
    /**
     * Handles Redis event.
     *
     * @argument {String[]} argumentList
     */
    handleRedisEvent (...argumentList) {
      if (argumentList.length === 3) {
        const channelPattern = arguments[0];
        const channelName = arguments[1];
        const data = arguments[2];
  
        // NOTE: Implement handling of pattern channel
      } else if (argumentList.length === 2) {
        const channelName = arguments[0];
        const data = arguments[1];
  
        if ($$keyspaceNotificationChannelNameRegularExpression.test(channelName)) {
  
          this.executeHandlerCallbackForChannelName(channelName, data)
            .catch((error) => {
              this.$logger.error(error);
            });
        } else {
          const parsedData = NucleusDatastore.parseItem(data);
  
          if (parsedData.hasOwnProperty('name') && parsedData.hasOwnProperty('message')) {
            const { meta, message, name } = parsedData;
            const $event = new NucleusEvent(name, message, Object.assign({}, meta, { originUserID: meta.authorUserID }));
  
            this.executeHandlerCallbackForChannelName(channelName, $event)
              .catch((error) => {
                this.$logger.error(error);
              });
          }
        }
      }
    }
  
    /**
     * Pings the Redis server. `PING message`
     *
     * @argument {String} message
     *
     * @returns {Promise}
     */
    ping (message) {
  
      return this.$$server.pingAsync(message);
    }
  
    /**
     * Registers a script given its name. `LOAD script`
     * @example
     * const LUAScriptName = 'RemoveGreatHouseByName';
     * const LUAScript = `local pattern = ARGV[1]
     * local itemKeyList = redis.call('KEYS', '*'..pattern..'*'
     * for (index, itemKey in pairs(itemKeyList)) do
     *   redis.call('DEL', itemKey)
     * end
     * return [];`
     * await $datastore.registerScriptByName(LUAScriptName, LUAScript);
     *
     * @argument {String} LUAScriptName
     * @argument {String} LUAScript
     *
     * @returns {Promise<*>}
     */
    async registerScriptByName (LUAScriptName, LUAScript) {
      const scriptSHA = await this.$$server.scriptAsync('load', LUAScript);
  
      this.scriptSHAbyScriptName[LUAScriptName] = scriptSHA;
  
      return this.addItemToHashFieldByName('LUAScriptSHAByScriptName', LUAScriptName, scriptSHA);
    }
  
    /**
     * Removes a tripple from the hexastore.
     * @example
     * const itemKey = 'GreatHouseAllegiance';
     * const subject = 'House Frey'
     * const predicate = 'pledged-allegiance-to';
     * const object = 'House Stark;
     * await $datastore.removeTriplesFromHexastore(itemKey, subject, predicate, object);
     *
     * @argument {String} itemKey
     * @argument {String} subject
     * @argument {String} predicate
     * @argument {String} object
     *
     * @returns {Promise}
     */
    removeTriplesFromHexastore (itemKey, subject, predicate, object) {
      if (!nucleusValidator.isString(itemKey)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item name must be a string.");
      if (!nucleusValidator.isString(subject)) throw new NucleusError.UnexpectedValueTypeNucleusError("The subject must be a string.");
      if (!nucleusValidator.isString(predicate)) throw new NucleusError.UnexpectedValueTypeNucleusError("The predicate must be a string.");
      if (!nucleusValidator.isString(object)) throw new NucleusError.UnexpectedValueTypeNucleusError("The object must be a string.");
  
      return this.$$server.multi()
        .zrem(itemKey, `SPO:${subject}:${predicate}:${object}`)
        .zrem(itemKey, `SOP:${subject}:${object}:${predicate}`)
        .zrem(itemKey, `OPS:${object}:${predicate}:${subject}`)
        .zrem(itemKey, `OSP:${object}:${subject}:${predicate}`)
        .zrem(itemKey, `PSO:${predicate}:${subject}:${object}`)
        .zrem(itemKey, `POS:${predicate}:${object}:${subject}`)
        .execAsync();
    }
  
    /**
     * Removes a triple from a hexastore given the subject vector.
     * This will remove every relationship where the given vector is subject or object.
     * @example
     * const itemKey = 'GreatHouseAllegiance';
     * const vector = 'House Martell'
     * await $datastore.removeAllTriplesFromHexastoreByVector(itemKey, vector);
     *
     * @argument {String} itemKey
     * @argument {String} vector
     *
     * @returns {Promise<void>}
     *
     * @throws Will throw an error if the item key is missing or an empty string.
     * @throws Will throw an error if the vector is not a string.
     */
    async removeAllTriplesFromHexastoreByVector (itemKey, vector) {
      if (!nucleusValidator.isString(itemKey)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item name must be a string.");
      if (!nucleusValidator.isString(vector)) throw new NucleusError.UnexpectedValueTypeNucleusError("The vector must be a string.");
  
      const removeAllTriplesFromHexastoreByVectorLuaScript = await fs.readFileAsync(path.join(__dirname, '/lua/removeAllTriplesFromHexastoreByVector.lua'), 'UTF8');
  
      return this.evaluateLUAScript(removeAllTriplesFromHexastoreByVectorLuaScript, itemKey, vector);
    }
  
    /**
     * Removes an item given its key. `DEL key`
     * @example
     * const itemKey = 'HouseBolton';
     * await $datastore.removeItemByName(itemKey);
     *
     * @argument {String} itemKey
     *
     * @returns {Promise<null>}
     *
     * @throws Will throw an error if the item key is missing or an empty string.
     */
    removeItemByName (itemKey) {
      if (!nucleusValidator.isString(itemKey)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item key must be a string.");
  
      return this.$$server.delAsync(itemKey)
        .return(null);
    }
  
    /**
     * Removes an item from a hash given a field. `HMDEL key field`
     * @example
     * const itemKey = 'JonSnow';
     * const itemField = 'father';
     * await $datastore.removeItemFromFieldByName(itemKey, itemField);
     *
     * @argument {String} itemKey
     * @argument {String} itemField
     *
     * @returns {Promise<null>}
     *
     * @throws Will throw an error if the item key is missing or an empty string.
     * @throws Will throw an error if the item field is missing or an empty string.
     */
    removeItemFromFieldByName (itemKey, itemField) {
      if (!nucleusValidator.isString(itemKey)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item name must be a string.");
      if (!nucleusValidator.isString(itemField)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item field must be a string.");
  
      return this.$$server.hdelAsync(itemKey, itemField)
        .return(null);
    }
  
    /**
     * Removes a resource given its type and ID.
     * @example
     * const resourceType = 'Character';
     * const resourceID = '6d4500b6-b4aa-462a-b01b-a9bc19dbc699';
     * await $datastore.removeResourceByTypeAndID(resourceType, resourceID);
     *
     * @argument {String} resourceType
     * @argument {String} resourceID
     *
     * @returns {Object} - The resource object including the `ID` and `type` attributes.
     */
    removeResourceByTypeAndID (resourceType, resourceID) {
  
      return this.removeItemByName(`${resourceType}:${resourceID}`)
        .return({});
    }
  
    /**
     * Retrieves all the items from a hash given its name. `HGETALL key`
     * @example
     * const itemKey = 'JonSnow';
     * const item = await $datastore.retrieveAllItemsFromHashByName(itemKey);
     * item.fullName === 'Jon Snow';
     *
     * @argument itemKey
     *
     * @returns {Promise<Array>}
     *
     * @throws Will throw an error if the item key is missing or an empty string.
     */
    retrieveAllItemsFromHashByName (itemKey) {
      if (!nucleusValidator.isString(itemKey)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item key must be a string.");
  
      return this.$$server.hgetallAsync(itemKey)
        .then(NucleusDatastore.parseHashItem);
    }
  
    /**
     * Retrieves a batch of items given a list of name.
     * @example
     * const itemNameList = [ 'RickonStark', 'BranStark', 'AryaStark', 'SansaStark', 'RobbStark' ];
     * const resourceList = await $datastore.retrieveBatchItemByName(itemNameList);
     *
     * resourceList.length === 5;
     * resourceList[0].fullName === 'Rickon Stark';
     *
     * @argument {String} itemNameList
     *
     * @returns {String<Object>}
     */
    retrieveBatchItemByName (itemNameList) {
      const itemDatastoreRequestList = itemNameList
        .map((itemKey) => {
  
          return ['HGETALL', itemKey];
        });
  
      return this.$$server.multi(itemDatastoreRequestList).execAsync()
        .then(itemFields => itemFields.filter(Boolean).map(NucleusDatastore.parseHashItem));
    }
  
    /**
     * Retrieves a resource given its type and ID.
     * @example
     * const resourceType = 'Character';
     * const resourceIDList = ['7e4a3ef0-348a-4386-a15d-440c21df9e99',  '3c9e2e3e-86f6-4989-8f13-2a3d85a7403f', 'b3dce695-ae9a-48b0-82b3-579f5874e4d0'];
     * const resourceList = await $datastore.retrieveBatchResourceByTypeAndIDList(resourceType, resourceIDList);
     *
     * resourceList.length === 3;
     * resourceList[0].ID === resourceIDList[0];
     * resourceList[0].type === resourceType;
     * resourceList[0].fullName === 'Daenerys Targaryen';
     * resourceList[1].ID === resourceIDList[1];
     * resourceList[1].type === resourceType;
     * resourceList[1].fullName === 'Jon Snow';
     * resourceList[2].ID === resourceIDList[2];
     * resourceList[2].type === resourceType;
     * resourceList[2].fullName === 'Tyrion Lannister';
     *
     * @argument {String} resourceType
     * @argument {String[]} resourceIDList
     *
     * @returns {resourceList} - The list of resource object including the `ID` and `type` attributes.
     */
    retrieveBatchResourceByTypeAndIDList (resourceType, resourceIDList) {
      const itemNameList = resourceIDList
        .map((resourceID) => `${resourceType}:${resourceID}`);
  
      return this.retrieveBatchItemByName(itemNameList);
    }
  
    /**
     * Retrieves an item given its key. `GET key`
     * @example
     * const itemKey = 'WhatsYourName';
     * const name = await $datastore.retrieveItemByName(itemKey);
     *
     * name === 'A Girl has no name.';
     *
     * @argument {String} itemKey
     *
     * @returns {Promise<*>}
     *
     * @throws Will throw an error if the item key is missing or an empty string.
     */
    retrieveItemByName (itemKey) {
      if (!nucleusValidator.isString(itemKey)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item key must be a string.");
  
      return this.$$server.getAsync(itemKey)
        .then(NucleusDatastore.parseItem);
      }
  
    /**
     * Remove an item from a hash given an item field. `HMGET key field`
     * @example
     * // Retrieve on field.
     * const itemKey = 'JonSnow';
     * const itemField = 'father';
     * const item = await $datastore.retrieveItemFromHashFieldByName(itemKey, itemField);
     * item === 'Eddard Stark';
     *
     * // Retrieve multiple fields.
     * const itemKey = 'JonSnow';
     * const itemFieldList = ['fullName', 'father'];
     * const [ fullName, father ] = await $datastore.retrieveItemFromHashFieldByName(itemKey, itemFieldList);
     *
     * fullName === 'Jon Snow';
     * father === 'Eddard Stark';
     *
     * @argument {String} itemKey
     * @argument {String} [itemField]
     * @argument {String[]} [itemFieldList]
     *
     * @returns {Promise<*>}
     *
     * @throws Will throw an error if the item key is missing or an empty string.
     * @throws Will throw an error if the item field is missing or an empty string.
     */
    retrieveItemFromHashFieldByName (itemKey, ...itemFieldList) {
      if (!nucleusValidator.isString(itemKey)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item name must be a string.");
  
      if (itemFieldList.length === 1) {
        const itemField = itemFieldList[0];
  
        if (!nucleusValidator.isString(itemField)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item field must be a string.");
  
        return this.$$server.hgetAsync(itemKey, itemField)
          .then(NucleusDatastore.parseItem);
      }
  
      return this.$$server.hmgetAsync(itemKey, itemFieldList)
        .then(NucleusDatastore.parseItem);
    }
  
    /**
     * Retrieves an item from a list but blocks the client if the list is empty. `BRPOP key timeout`
     * @example
     * const itemKey = 'WhatIsComing';
     * const item = await $datastore.retrieveItemFromListDeferred(itemKey);
     * // Connection and thread will be blocked until an item is pushed to the list.
     * // You should never user `await` with this method on the main thread.
     * await $datastore.addItemToListByName(itemKey, 'Winter');
     *
     * @argument {String} itemKey
     *
     * @returns {Promise}
     */
    retrieveItemFromListDeferred (itemKey) {
      if (!nucleusValidator.isString(itemKey)) throw new NucleusError.UnexpectedValueTypeNucleusError("The item name must be a string.");
  
      return (new Promise((resolve, reject) => {
        try {
          this.$$server.brpop(itemKey, 0, function (error, [ itemKey, item ]) {
            if (!!error) {
              reject(error);
  
              return;
            }
  
            if (nucleusValidator.isObject(itemKey)) {
              const { code: redisErrorCode, message: redisErrorMessage } = itemKey;
  
              if (redisErrorCode === 'ERR') reject(new NucleusError(`Could not retrieve the item from the list because of an external error: ${redisErrorCode}`));
            }
  
            resolve(item);
          });
        } catch (error) {
  
          reject(new NucleusError(`Could not retrieve the item from the list because of an external error: ${error}`, { error }));
        }
      }));
    }
  
    /**
     * Retrieves the relationship between a subject and an object from a hexastore.
     * const itemName = 'HouseTargeryanFamilyTree';
     * const subject = 'Daenarys Targeryan';
     * const object = 'Aerys II Targaryen';
     * const relationshipList = await $datastore.retrieveRelationshipListFromHexastore(itemName, subject, object);
     *
     * relationshipList[0] === 'is-daughter-of';
     *
     * @argument {String} itemName
     * @argument {String} subject
     * @argument {String} object
     *
     * @returns {Promise<String[]>}
     */
    retrieveRelationshipListFromHexastore (itemName, subject, object) {
  
      return this.retrieveVectorByIndexSchemeFromHexastore(itemName, 'SOP', subject, object);
    }
  
    /**
     * Retrieves a resource given its type and ID.
     * @example
     * const resourceType = 'Character';
     * const resourceID = '7e4a3ef0-348a-4386-a15d-440c21df9e99';
     * const resource = await $datastore.retrieveResourceByTypeAndID(resourceType, resourceID);
     *
     * resource.ID === resourceID;
     * resource.type === resourceType;
     * resource.fullName === 'Daenarys Targeryan';
     *
     * @argument {String} resourceType
     * @argument {String} resourceID
     *
     * @returns {Object} - The resource object including the `ID` and `type` attributes.
     */
    retrieveResourceByTypeAndID (resourceType, resourceID) {
      
      return this.retrieveAllItemsFromHashByName(`${resourceType}:${resourceID}`);
    }
  
    /**
     * Retrieves the any vector from any triple given the index scheme from a hexastore.
     * @see {@link http://www.vldb.org/pvldb/1/1453965.pdf|Hexastore paper}
     *
     * @example
     * const itemName = 'HouseTargeryanFamilyTree';
     * const object = 'Daenarys Targeryan';
     * const predicate = 'is-children-of';
     * const itemList = await $datastore.retrieveVectorByIndexSchemeFromHexastore(itemName, 'OPS', object, predicate);
     *
     * itemList.length === 3;
     * itemList[0].fullName === 'Drogon';
     * itemList[1].fullName === 'Rhaegal';
     * itemList[2].fullName === 'Viserion';
     *
     * @argument {String} itemName
     * @argument {String} indexingScheme=SPO,SOP,OPS,OSP,PSO,POS
     * @argument {String} vectorA
     * @argument {String} vectorB
     *
     * @returns {Promise<String[]>}
     */
    retrieveVectorByIndexSchemeFromHexastore (itemName, indexingScheme, vectorA, vectorB) {
  
      return this.$$server.zrangebylexAsync(itemName, `[${indexingScheme}:${vectorA}:${vectorB}:`, `[${indexingScheme}:${vectorA}:${vectorB}:\xff`)
        .then((itemList = []) => {
  
          return itemList
            .map((item) => {
              const [ indexScheme, vectorA, vectorB, vectorC ] = item.split(':');
  
              return vectorC;
            });
        });
    }
  
    /**
     * Searches for items in a hash given its name.
     * @example
     * $datastore.searchItemInHashByName('UserSettings', 'localization');
     * $datastore.searchItemInHashByName('UserSettings', 'localization.defaultLanguageISO');
     * $datastore.searchItemInHashByName('UserSettings', ['localization', 'notifications');
     * $datastore.searchItemInHashByName('UserSettings', ['localization.defaultLanguageISO', 'notifications.channels.email']);
     *
     * @argument {String} itemKey
     * @argument {String} [fieldName]
     * @argument {String[]} [fieldNameList]
     *
     * @return {*}
     */
    async searchItemInHashByName (itemKey, fieldName) {
      if (nucleusValidator.isArray(fieldName)) {
        const fieldNameList = fieldName;
  
        const fieldNameAsDotNotationCount = fieldNameList
          .filter((fieldName) => {
  
            return $$dotNotationKeyRegularExpression.test(fieldName);
          }).length;
  
        const fieldNameThatExistsCount = (await Promise.all(fieldNameList
          .map((fieldName) => {
  
            return this.$$server.hexistsAsync(itemKey, fieldName);
          }))
          .then((fieldNameExistsList) => {
  
            return fieldNameExistsList.filter(Boolean);
          })).length;
  
        if (fieldNameAsDotNotationCount > 0 && fieldNameAsDotNotationCount !== fieldNameList.length) throw new NucleusError.UnexpectedValueNucleusError("The field name list must be only field names to retrieve a group or use dot notation to retrieve a specific field's value, not both.");
  
        const fieldNamesAreDotNotation = fieldNameAsDotNotationCount === fieldNameList.length && fieldNameThatExistsCount === fieldNameList.length;
  
        if (fieldNamesAreDotNotation) return this.retrieveItemFromHashFieldByName.apply(this, [itemKey].concat(fieldNameList))
          .then((itemList) => {
  
            return itemList
              .reduce((accumulator, item, index) => {
                const fieldName = fieldNameList[index];
                const [field, key] = fieldName.split(".");
  
                if (!(field in accumulator)) accumulator[field] = {};
  
                accumulator[field][key] = item;
  
                return accumulator;
              }, {});
          });
        else {
          const datastoreRequestList = fieldNameList
            .map((fieldName) => {
  
              return ['HSCAN', itemKey, 0, 'MATCH', `${fieldName}*`];
            });
  
          return this.$$server.multi(datastoreRequestList).execAsync()
            .then((responseList) => {
  
              return responseList
                .reduce((accumulator, [ cursor, itemHashList ], index) => {
                  const fieldName = fieldNameList[index];
                  const parsedHashItems = NucleusDatastore.parseHashItem(itemHashList);
                  const parsedJSONItems = NucleusDatastore.parseItem(parsedHashItems);
                  const { [fieldName]: item } = NucleusDatastore.expandDotNotationObject(parsedJSONItems);
  
                  accumulator[fieldName] = item;
  
                  return accumulator;
                }, {});
            });
        }
  
        // return this.retrieveItemFromHashFieldByName.apply(this, [itemKey].concat(fieldNameList));
      } else if (nucleusValidator.isString(fieldName)) {
        const fieldNameIsDotNotation = $$dotNotationKeyRegularExpression.test(fieldName);
  
        const fieldExists = await this.$$server.hexistsAsync(itemKey, fieldName);
  
        if (fieldNameIsDotNotation && fieldExists) return this.retrieveItemFromHashFieldByName(itemKey, fieldName)
          .then((item) => {
  
            // TODO: Change this
            return NucleusDatastore.expandDotNotationObject({[fieldName]: item});
          });
        else return this.$$server.hscanAsync(itemKey, 0, 'MATCH', `${fieldName}*`)
          .then(([ cursor, itemHashList ]) => {
            // HSCAN returns an array that contains the keys and values.
            const parsedHashItems = NucleusDatastore.parseHashItem(itemHashList);
            // Any Redis item's value is likely to be JSON.
            const parsedJSONItems = NucleusDatastore.parseItem(parsedHashItems);
            // Settings objects are likely to have dot notation properties.
            const items = NucleusDatastore.expandDotNotationObject(parsedJSONItems);
  
            return items;
          });
      } else throw new NucleusError.UnexpectedValueTypeNucleusError("The field name must be a string or a list of string.");
    }
  
    /**
     * Subscribes the client to a channel given its name.
     *
     * @argument {String} channelName
     *
     * @returns {Promise}
     */
    subscribeToChannelName (channelName) {
  
      return this.$$server.subscribeAsync(channelName);
    }
  
    /**
     * Unsubscribes the client from a channel given its name.
     *
     * @argument {String} channelName
     *
     * @returns {Promise}
     */
    unsubscribeFromChannelName (channelName) {
  
      return this.$$server.unsubscribeAsync(channelName);
    }
  
    /**
     * Updates a resource given its type and ID.
     * @example
     * const resourceType = 'Character';
     * const resourceID = '3c9e2e3e-86f6-4989-8f13-2a3d85a7403f';
     * const resourceAttributes = {
     *   father: 'Rhaegar Targaryen'
     * };
     * const resource = await $datastore.updateResourceByTypeAndID(resourceType, resourceID, resourceAttributes);
     *
     * resource.ID === resourceID;
     * resource.type === resourceType;
     * resource.father === resourceAttributes.father;
     *
     * @argument {String} resourceType
     * @argument {String} resourceID
     * @argument {Object} resourceAttributes
     * @argument {Number} [TTL] - Time to expire the resource in seconds.
     *
     * @returns {Object} - The resource object including the `ID` and `type` attributes.
     */
    async updateResourceByTypeAndID (resourceType, resourceID, resourceAttributes, TTL) {
      if (!resourceAttributes.hasOwnProperty('ID')) resourceAttributes.ID = resourceID;
      if (!resourceAttributes.hasOwnProperty('type')) resourceAttributes.type = resourceType;
  
      await this.addItemToHashFieldByName(`${resourceType}:${resourceID}`, resourceAttributes);
      if (TTL) await this.$$server.expireAsync(`${resourceType}:${resourceID}`, TTL);
  
      return resourceAttributes;
    }
  
    /**
     * Verifies that an item exists given its name.
     *
     * @argument {String} itemName
     *
     * @returns {Promise<boolean>}
     */
    async verifyThatItemByNameExist (itemName) {
  
      return !!(await this.$$server.existsAsync(itemName));
    }
  
    /**
     * Verifies that a resource exists given its type and ID.
     * @example
     * const resourceType = 'User';
     * const resourceID = '7e4a3ef0-348a-4386-a15d-440c21df9e99';
     * const resourceExists = await $datastore.verifyThatResourceExistByTypeAndID(resourceType, resourceID);
     *
     * resourceExists === true;
     *
     * @argument {String} resourceType
     * @argument {String} resourceID
     *
     * @returns {Boolean}
     */
    verifyThatResourceExistByTypeAndID (resourceType, resourceID) {
  
      return this.verifyThatItemByNameExist(`${resourceType}:${resourceID}`);
    }
  
    /**
     * Collaps an object to a dot notation object.
     * @example
     * const collapsedObject = NucleusDatastore.collapseObjectToDotNotation({ a: { b: 'B' } });
     * collapsedObject['a.b'] === 'B';
     *
     * @argument {Object} object
     *
     * @returns {Object}
     */
    static collapseObjectToDotNotation (object) {
  
      function reduceObject (propertyNamePath, accumulator, object) {
        Object.keys(object)
          .forEach((propertyName, index) => {
            const value = object[propertyName];
  
            reduceValue(`${propertyNamePath}.${propertyName}`, accumulator, value);
          });
      }
  
      function reduceArray (propertyNamePath, accumulator, array) {
        if (array.length === 0) {
          reduceValue(`${propertyNamePath}[0]`, accumulator, null);
        } else {
          array
            .forEach((value, index) => {
              reduceValue(`${propertyNamePath}[${index}]`, accumulator, value);
            });
        }
      }
  
      function reduceValue (propertyNamePath, accumulator, value) {
        if (nucleusValidator.isObject(value)) return reduceObject(propertyNamePath, accumulator, value);
        if (nucleusValidator.isArray(value)) return reduceArray(propertyNamePath, accumulator, value);
  
        accumulator[propertyNamePath] = value;
  
        return accumulator;
      }
  
      return Object.keys(object)
        .reduce((accumulator, propertyName) => {
          const value = object[propertyName];
  
          reduceValue(propertyName, accumulator, value);
  
          return accumulator;
        }, {});
    }
  
    /**
     * Expands a dot notation object.
     * @example
     * const expandedObject = NucleusDatastore.expandDotNotationObject({ 'a.b': 'B' });
     * expandedObject.a.b === 'B';
     *
     * @argument {Object} object
     *
     * @returns {Object}
     */
    static expandDotNotationObject (object) {
  
      return Object.keys(object)
        .reduce((accumulator, propertyName) => {
          const value = object[propertyName];
          if ($$dotNotationKeyRegularExpression.test(propertyName)) {
            const dotNotationPropertyList = propertyName.replace(/\]$/, '').split(/[.\[\]]+/g);
  
            dotNotationPropertyList
              .reduce((accumulator, propertyName, index, list) => {
                if (index + 1 !== list.length) {
                  if (!(propertyName in accumulator)) {
                    if (/^[0-9]+$/.test(list[index + 1]) && !(propertyName in accumulator)) accumulator[propertyName] = [];
                    else accumulator[propertyName] = {};
                  }
  
                  return accumulator[propertyName];
                } else {
                  // If the property is a number, collapse as an array.
                  if (nucleusValidator.isArray(accumulator)) {
                    if (!propertyName || !value) return undefined;
  
                    accumulator[propertyName] = value;
                    accumulator = accumulator.filter(Boolean);
                  } else accumulator[propertyName] = (value === null) ? undefined : value;
                }
  
                return accumulator;
              }, accumulator);
          } else accumulator[propertyName] = (value === null) ? undefined : value;
  
          return accumulator;
        }, {});
    }
  
    /**
     * Parses a hash item list into an object.
     *
     * @argument {Array} itemList
     *
     * @returns {Object}
     */
    static parseHashItem (itemList = []) {
      // The most recent version of NPM redis returns an object as expected.
      if (nucleusValidator.isObject(itemList)) return NucleusDatastore.parseItem(itemList);
  
      if (!itemList) return {};
  
      return itemList
        .reduce((accumulator, item, index, list) => {
          if (index % 2 === 0) return accumulator;
          else {
            accumulator[list[index - 1]] = item;
  
            return accumulator;
          }
        }, {});
    }
  
    /**
     * Parses an item to a native data type.
     *
     * @argument {String} item
     *
     * @returns {*}
     */
    static parseItem (item) {
      if (nucleusValidator.isArray(item)) {
  
        return item.map(NucleusDatastore.parseItem);
      }
  
      if (nucleusValidator.isObject(item)) {
  
        return Object.keys(item)
          .reduce((accumulator, key) => {
            const parsedValue = NucleusDatastore.parseItem(item[key]);
  
            accumulator[key] = parsedValue;
  
            return accumulator;
          }, {});
      }
  
      try {
  
        return JSON.parse(item);
      } catch (error) {
  
        return item;
      }
    }
  
    /**
     * Stringifies a native data type.
     *
     * @argument {*} item
     *
     * @returns {String}
     */
    static stringifyItem (item) {
  
      return JSON.stringify(item);
    }
  
  }
  
  module.exports = NucleusDatastore;
  