// Copyright IBM Corp. 2016,2019. All Rights Reserved.
// Node module: loopback
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

'use strict';
const g = require('../../lib/globalize');

/**
 * Data model for key-value databases.
 *
 * @class KeyValueModel
 * @inherits {Model}
 */

module.exports = function(KeyValueModel) {
  /**
   * Return the value associated with a given key.
   *
   * @param {String} key Key to use when searching the database.
   * @options {Object} options
   * @callback {Function} callback
   * @param {Error} err Error object.
   * @param {Any} result Value associated with the given key.
   * @promise
   *
   * @header KeyValueModel.get(key, cb)
   */
  KeyValueModel.get = function(key, options, callback) {
    throwNotAttached(this.modelName, 'get');
  };

  /**
   * Persist a value and associate it with the given key.
   *
   * @param {String} key Key to associate with the given value.
   * @param {Any} value Value to persist.
   * @options {Number|Object} options Optional settings for the key-value
   *   pair. If a Number is provided, it is set as the TTL (time to live) in ms
   *   (milliseconds) for the key-value pair.
   * @property {Number} ttl TTL for the key-value pair in ms.
   * @callback {Function} callback
   * @param {Error} err Error object.
   * @promise
   *
   * @header KeyValueModel.set(key, value, cb)
   */
  KeyValueModel.set = function(key, value, options, callback) {
    throwNotAttached(this.modelName, 'set');
  };

  /**
   * Set the TTL (time to live) in ms (milliseconds) for a given key. TTL is the
   * remaining time before a key-value pair is discarded from the database.
   *
   * @param {String} key Key to use when searching the database.
   * @param {Number} ttl TTL in ms to set for the key.
   * @options {Object} options
   * @callback {Function} callback
   * @param {Error} err Error object.
   * @promise
   *
   * @header KeyValueModel.expire(key, ttl, cb)
   */
  KeyValueModel.expire = function(key, ttl, options, callback) {
    throwNotAttached(this.modelName, 'expire');
  };

  /**
   * Return the TTL (time to live) for a given key. TTL is the remaining time
   * before a key-value pair is discarded from the database.
   *
   * @param {String} key Key to use when searching the database.
   * @options {Object} options
   * @callback {Function} callback
   * @param {Error} error
   * @param {Number} ttl Expiration time for the key-value pair. `undefined` if
   *   TTL was not initially set.
   * @promise
   *
   * @header KeyValueModel.ttl(key, cb)
   */
  KeyValueModel.ttl = function(key, options, callback) {
    throwNotAttached(this.modelName, 'ttl');
  };

  /**
   * Return all keys in the database.
   *
   * **WARNING**: This method is not suitable for large data sets as all
   * key-values pairs are loaded into memory at once. For large data sets,
   * use `iterateKeys()` instead.
   *
   * @param {Object} filter An optional filter object with the following
   * @param {String} filter.match Glob string used to filter returned
   *   keys (i.e. `userid.*`). All connectors are required to support `*` and
   *   `?`, but may also support additional special characters specific to the
   *   database.
   * @param {Object} options
   * @callback {Function} callback
   * @promise
   *
   * @header KeyValueModel.keys(filter, cb)
   */
  KeyValueModel.keys = function(filter, options, callback) {
    throwNotAttached(this.modelName, 'keys');
  };

  /**
   * Asynchronously iterate all keys in the database. Similar to `.keys()` but
   * instead allows for iteration over large data sets without having to load
   * everything into memory at once.
   *
   * Callback example:
   * ```js
   * // Given a model named `Color` with two keys `red` and `blue`
   * var iterator = Color.iterateKeys();
   * it.next(function(err, key) {
   *   // key contains `red`
   *   it.next(function(err, key) {
   *     // key contains `blue`
   *   });
   * });
   * ```
   *
   * Promise example:
   * ```js
   * // Given a model named `Color` with two keys `red` and `blue`
   * var iterator = Color.iterateKeys();
   * Promise.resolve().then(function() {
   *   return it.next();
   * })
   * .then(function(key) {
   *   // key contains `red`
   *   return it.next();
   * });
   * .then(function(key) {
   *   // key contains `blue`
   * });
   * ```
   *
   * @param {Object} filter An optional filter object with the following
   * @param {String} filter.match Glob string to use to filter returned
   *   keys (i.e. `userid.*`). All connectors are required to support `*` and
   *   `?`. They may also support additional special characters that are
   *   specific to the backing database.
   * @param {Object} options
   * @returns {AsyncIterator} An Object implementing `next(cb) -> Promise`
   *   function that can be used to iterate all keys.
   *
   * @header KeyValueModel.iterateKeys(filter)
   */
  KeyValueModel.iterateKeys = function(filter, options) {
    throwNotAttached(this.modelName, 'iterateKeys');
  };

  /*!
   * Set up remoting metadata for this model.
   *
   * **Notes**:
   * - The method is called automatically by `Model.extend` and/or
   *   `app.registry.createModel`
   * - In general, base models use call this to ensure remote methods are
   *   inherited correctly, see bug at
   *   https://github.com/strongloop/loopback/issues/2350
   */
  KeyValueModel.setup = function() {
    KeyValueModel.base.setup.apply(this, arguments);

    this.remoteMethod('get', {
      accepts: {
        arg: 'key', type: 'string', required: true,
        http: {source: 'path'},
      },
      returns: {arg: 'value', type: 'any', root: true},
      http: {path: '/:key', verb: 'get'},
      rest: {after: convertNullToNotFoundError},
    });

    this.remoteMethod('set', {
      accepts: [
        {arg: 'key', type: 'string', required: true,
          http: {source: 'path'}},
        {arg: 'value', type: 'any', required: true,
          http: {source: 'body'}},
        {arg: 'ttl', type: 'number',
          http: {source: 'query'},
          description: 'time to live in milliseconds'},
      ],
      http: {path: '/:key', verb: 'put'},
    });

    this.remoteMethod('expire', {
      accepts: [
        {arg: 'key', type: 'string', required: true,
          http: {source: 'path'}},
        {arg: 'ttl', type: 'number', required: true,
          http: {source: 'form'}},
      ],
      http: {path: '/:key/expire', verb: 'put'},
    });

    this.remoteMethod('ttl', {
      accepts: {
        arg: 'key', type: 'string', required: true,
        http: {source: 'path'},
      },
      returns: {arg: 'value', type: 'any', root: true},
      http: {path: '/:key/ttl', verb: 'get'},
    });

    this.remoteMethod('keys', {
      accepts: {
        arg: 'filter', type: 'object', required: false,
        http: {source: 'query'},
      },
      returns: {arg: 'keys', type: ['string'], root: true},
      http: {path: '/keys', verb: 'get'},
    });
  };
};

function throwNotAttached(modelName, methodName) {
  throw new Error(g.f(
    'Cannot call %s.%s(). ' +
      'The %s method has not been setup. ' +
      'The {{KeyValueModel}} has not been correctly attached ' +
      'to a {{DataSource}}!',
    modelName, methodName, methodName,
  ));
}

function convertNullToNotFoundError(ctx, cb) {
  if (ctx.result !== null) return cb();

  const modelName = ctx.method.sharedClass.name;
  const id = ctx.getArgByName('id');
  const msg = g.f('Unknown "%s" {{key}} "%s".', modelName, id);
  const error = new Error(msg);
  error.statusCode = error.status = 404;
  error.code = 'KEY_NOT_FOUND';
  cb(error);
}
