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

// Turning on strict for this file breaks lots of test cases;
// disabling strict for this file
/* eslint-disable strict */

module.exports = ModelUtils;

/*!
 * Module dependencies
 */
const g = require('strong-globalize')();
const geo = require('./geo');
const {
  fieldsToArray,
  sanitizeQuery: sanitizeQueryOrData,
  isPlainObject,
  isClass,
  toRegExp,
} = require('./utils');
const BaseModel = require('./model');

/**
 * A mixin to contain utility methods for DataAccessObject
 */
function ModelUtils() {
}

/**
 * Verify if allowExtendedOperators is enabled
 * @options {Object} [options] Optional options to use.
 * @property {Boolean} allowExtendedOperators.
 * @returns {Boolean} Returns `true` if allowExtendedOperators is enabled, else `false`.
 */
ModelUtils._allowExtendedOperators = function(options) {
  const flag = this._getSetting('allowExtendedOperators', options);
  if (flag != null) return !!flag;
  // Default to `false`
  return false;
};

/**
 * Get settings via hierarchical determination
 * - method level options
 * - model level settings
 * - data source level settings
 *
 * @param {String} key The setting key
 */
ModelUtils._getSetting = function(key, options) {
  // Check method level options
  let val = options && options[key];
  if (val !== undefined) return val;
  // Check for settings in model
  const m = this.definition;
  if (m && m.settings) {
    val = m.settings[key];
    if (val !== undefined) {
      return m.settings[key];
    }
    // Fall back to data source level
  }

  // Check for settings in connector
  const ds = this.getDataSource();
  if (ds && ds.settings) {
    return ds.settings[key];
  }

  return undefined;
};

const operators = {
  eq: '=',
  gt: '>',
  gte: '>=',
  lt: '<',
  lte: '<=',
  between: 'BETWEEN',
  inq: 'IN',
  nin: 'NOT IN',
  neq: '!=',
  like: 'LIKE',
  nlike: 'NOT LIKE',
  ilike: 'ILIKE',
  nilike: 'NOT ILIKE',
  regexp: 'REGEXP',
};

/*
 * Normalize the filter object and throw errors if invalid values are detected
 * @param {Object} filter The query filter object
 * @options {Object} [options] Optional options to use.
 * @property {Boolean} allowExtendedOperators.
 * @returns {Object} The normalized filter object
 * @private
 */
ModelUtils._normalize = function(filter, options) {
  if (!filter) {
    return undefined;
  }
  let err = null;
  if ((typeof filter !== 'object') || Array.isArray(filter)) {
    err = new Error(g.f('The query filter %j is not an {{object}}', filter));
    err.statusCode = 400;
    throw err;
  }
  if (filter.limit || filter.skip || filter.offset) {
    const limit = Number(filter.limit || 100);
    const offset = Number(filter.skip || filter.offset || 0);
    if (isNaN(limit) || limit <= 0 || Math.ceil(limit) !== limit) {
      err = new Error(g.f('The {{limit}} parameter %j is not valid',
        filter.limit));
      err.statusCode = 400;
      throw err;
    }
    if (isNaN(offset) || offset < 0 || Math.ceil(offset) !== offset) {
      err = new Error(g.f('The {{offset/skip}} parameter %j is not valid',
        filter.skip || filter.offset));
      err.statusCode = 400;
      throw err;
    }
    filter.limit = limit;
    filter.offset = offset;
    filter.skip = offset;
  }

  if (filter.order) {
    let order = filter.order;
    if (!Array.isArray(order)) {
      order = [order];
    }
    const fields = [];
    for (let i = 0, m = order.length; i < m; i++) {
      if (typeof order[i] === 'string') {
        // Normalize 'f1 ASC, f2 DESC, f3' to ['f1 ASC', 'f2 DESC', 'f3']
        const tokens = order[i].split(/(?:\s*,\s*)+/);
        for (let t = 0, n = tokens.length; t < n; t++) {
          let token = tokens[t];
          if (token.length === 0) {
            // Skip empty token
            continue;
          }
          const parts = token.split(/\s+/);
          if (parts.length >= 2) {
            const dir = parts[1].toUpperCase();
            if (dir === 'ASC' || dir === 'DESC') {
              token = parts[0] + ' ' + dir;
            } else {
              err = new Error(g.f('The {{order}} %j has invalid direction', token));
              err.statusCode = 400;
              throw err;
            }
          }
          fields.push(token);
        }
      } else {
        err = new Error(g.f('The order %j is not valid', order[i]));
        err.statusCode = 400;
        throw err;
      }
    }
    if (fields.length === 1 && typeof filter.order === 'string') {
      filter.order = fields[0];
    } else {
      filter.order = fields;
    }
  }

  // normalize fields as array of included property names
  if (filter.fields) {
    filter.fields = fieldsToArray(filter.fields,
      Object.keys(this.definition.properties), this.settings.strict);
  }

  filter = this._sanitizeQuery(filter, options);
  this._coerce(filter.where, options);
  return filter;
};

function DateType(arg) {
  const d = new Date(arg);
  if (isNaN(d.getTime())) {
    throw new Error(g.f('Invalid date: %s', arg));
  }
  return d;
}

function BooleanType(arg) {
  if (typeof arg === 'string') {
    switch (arg) {
      case 'true':
      case '1':
        return true;
      case 'false':
      case '0':
        return false;
    }
  }
  if (arg == null) {
    return null;
  }
  return Boolean(arg);
}

function NumberType(val) {
  const num = Number(val);
  return !isNaN(num) ? num : val;
}

function coerceArray(val) {
  if (Array.isArray(val)) {
    return val;
  }

  if (!isPlainObject(val)) {
    throw new Error(g.f('Value is not an {{array}} or {{object}} with sequential numeric indices'));
  }

  // It is an object, check if empty
  const props = Object.keys(val);

  if (props.length === 0) {
    throw new Error(g.f('Value is an empty {{object}}'));
  }

  const arrayVal = new Array(props.length);
  for (let i = 0; i < arrayVal.length; ++i) {
    if (!val.hasOwnProperty(i)) {
      throw new Error(g.f('Value is not an {{array}} or {{object}} with sequential numeric indices'));
    }

    arrayVal[i] = val[i];
  }

  return arrayVal;
}

function _normalizeAsArray(result) {
  if (typeof result === 'string') {
    result = [result];
  }
  if (Array.isArray(result)) {
    return result;
  } else {
    // See https://github.com/strongloop/loopback-datasource-juggler/issues/1646
    // `ModelBaseClass` normalize the properties to an object such as `{secret: true}`
    const keys = [];
    for (const k in result) {
      if (result[k]) keys.push(k);
    }
    return keys;
  }
}

/**
 * Get an array of hidden property names
 */
ModelUtils._getHiddenProperties = function() {
  const settings = this.definition.settings || {};
  const result = settings.hiddenProperties || settings.hidden || [];
  return _normalizeAsArray(result);
};

/**
 * Get an array of protected property names
 */
ModelUtils._getProtectedProperties = function() {
  const settings = this.definition.settings || {};
  const result = settings.protectedProperties || settings.protected || [];
  return _normalizeAsArray(result);
};

/**
 * Get the maximum depth of a query object
 */
ModelUtils._getMaxDepthOfQuery = function(options, defaultValue) {
  options = options || {};
  // See https://github.com/strongloop/loopback-datasource-juggler/issues/1651
  let maxDepth = this._getSetting('maxDepthOfQuery', options);
  if (maxDepth == null) {
    maxDepth = defaultValue || 32;
  }
  return +maxDepth;
};

/**
 * Get the maximum depth of a data object
 */
ModelUtils._getMaxDepthOfData = function(options, defaultValue) {
  options = options || {};
  // See https://github.com/strongloop/loopback-datasource-juggler/issues/1651
  let maxDepth = this._getSetting('maxDepthOfData', options);
  if (maxDepth == null) {
    maxDepth = defaultValue || 64;
  }
  return +maxDepth;
};

/**
 * Get the prohibitHiddenPropertiesInQuery flag
 */
ModelUtils._getProhibitHiddenPropertiesInQuery = function(options, defaultValue) {
  const flag = this._getSetting('prohibitHiddenPropertiesInQuery', options);
  if (flag == null) return !!defaultValue;
  return !!flag;
};

/**
 * Sanitize the query object
 */
ModelUtils._sanitizeQuery = function(query, options) {
  options = options || {};

  // Get settings to normalize `undefined` values
  const normalizeUndefinedInQuery = this._getSetting('normalizeUndefinedInQuery', options);
  // Get setting to prohibit hidden/protected properties in query
  const prohibitHiddenPropertiesInQuery = this._getProhibitHiddenPropertiesInQuery(options);

  // See https://github.com/strongloop/loopback-datasource-juggler/issues/1651
  const maxDepthOfQuery = this._getMaxDepthOfQuery(options);

  let prohibitedKeys = [];
  // Check violation of keys
  if (prohibitHiddenPropertiesInQuery) {
    prohibitedKeys = this._getHiddenProperties();
    if (options.prohibitProtectedPropertiesInQuery) {
      prohibitedKeys = prohibitedKeys.concat(this._getProtectedProperties());
    }
  }
  return sanitizeQueryOrData(query,
    Object.assign({
      maxDepth: maxDepthOfQuery,
      prohibitedKeys: prohibitedKeys,
      normalizeUndefinedInQuery: normalizeUndefinedInQuery,
    }, options));
};

/**
 * Sanitize the data object
 */
ModelUtils._sanitizeData = function(data, options) {
  options = options || {};
  return sanitizeQueryOrData(data,
    Object.assign({
      maxDepth: this._getMaxDepthOfData(options),
    }, options));
};

/*
 * Coerce values based the property types
 * @param {Object} where The where clause
 * @options {Object} [options] Optional options to use.
 * @param {Object} Optional model definition to use.
 * @property {Boolean} allowExtendedOperators.
 * @returns {Object} The coerced where clause
 * @private
 */
ModelUtils._coerce = function(where, options, modelDef) {
  const self = this;
  if (where == null) {
    return where;
  }
  options = options || {};

  let err;
  if (typeof where !== 'object' || Array.isArray(where)) {
    err = new Error(g.f('The where clause %j is not an {{object}}', where));
    err.statusCode = 400;
    throw err;
  }
  let props;
  if (modelDef && modelDef.properties) {
    props = modelDef.properties;
  } else {
    props = self.definition.properties;
  }

  for (const p in where) {
    // Handle logical operators
    if (p === 'and' || p === 'or' || p === 'nor') {
      let clauses = where[p];
      try {
        clauses = coerceArray(clauses);
      } catch (e) {
        err = new Error(g.f('The %s operator has invalid clauses %j: %s', p, clauses, e.message));
        err.statusCode = 400;
        throw err;
      }

      for (let k = 0; k < clauses.length; k++) {
        self._coerce(clauses[k], options);
      }

      where[p] = clauses;

      continue;
    }
    let DataType = props[p] && props[p].type;
    if (!DataType) {
      continue;
    }

    if ((Array.isArray(DataType) || DataType === Array) && !isNestedModel(DataType)) {
      DataType = DataType[0];
    }
    if (DataType === Date) {
      DataType = DateType;
    } else if (DataType === Boolean) {
      DataType = BooleanType;
    } else if (DataType === Number) {
      // This fixes a regression in mongodb connector
      // For numbers, only convert it produces a valid number
      // LoopBack by default injects a number id. We should fix it based
      // on the connector's input, for example, MongoDB should use string
      // while RDBs typically use number
      DataType = NumberType;
    }

    if (!DataType) {
      continue;
    }

    if (DataType === geo.GeoPoint) {
      // Skip the GeoPoint as the near operator breaks the assumption that
      // an operation has only one property
      // We should probably fix it based on
      // http://docs.mongodb.org/manual/reference/operator/query/near/
      // The other option is to make operators start with $
      continue;
    }

    let val = where[p];
    if (val === null || val === undefined) {
      continue;
    }
    // Check there is an operator
    let operator = null;
    const exp = val;
    if (val.constructor === Object) {
      for (const op in operators) {
        if (op in val) {
          val = val[op];
          operator = op;
          switch (operator) {
            case 'inq':
            case 'nin':
            case 'between':
              try {
                val = coerceArray(val);
              } catch (e) {
                err = new Error(g.f('The %s property has invalid clause %j: %s', p, where[p], e));
                err.statusCode = 400;
                throw err;
              }

              if (operator === 'between' && val.length !== 2) {
                err = new Error(g.f(
                  'The %s property has invalid clause %j: Expected precisely 2 values, received %d',
                  p,
                  where[p],
                  val.length,
                ));
                err.statusCode = 400;
                throw err;
              }
              break;
            case 'like':
            case 'nlike':
            case 'ilike':
            case 'nilike':
              if (!(typeof val === 'string' || val instanceof RegExp)) {
                err = new Error(g.f(
                  'The %s property has invalid clause %j: Expected a string or RegExp',
                  p,
                  where[p],
                ));
                err.statusCode = 400;
                throw err;
              }
              break;
            case 'regexp':
              val = toRegExp(val);
              if (val instanceof Error) {
                val.statusCode = 400;
                throw val;
              }
              break;
          }
          break;
        }
      }
    }

    try {
      // Coerce val into an array if it resembles an array-like object
      val = coerceArray(val);
    } catch (e) {
      // NOOP when not coercable into an array.
    }

    const allowExtendedOperators = self._allowExtendedOperators(options);
    // Coerce the array items
    if (Array.isArray(val) && !isNestedModel(DataType)) {
      for (let i = 0; i < val.length; i++) {
        if (val[i] !== null && val[i] !== undefined) {
          if (!(val[i] instanceof RegExp)) {
            val[i] = isClass(DataType) ? new DataType(val[i]) : DataType(val[i]);
          }
        }
      }
    } else {
      if (val != null) {
        if (operator === null && val instanceof RegExp) {
          // Normalize {name: /A/} to {name: {regexp: /A/}}
          operator = 'regexp';
        } else if (operator === 'regexp' && val instanceof RegExp) {
          // Do not coerce regex literals/objects
        } else if ((operator === 'like' || operator === 'nlike' ||
            operator === 'ilike' || operator === 'nilike') && val instanceof RegExp) {
          // Do not coerce RegExp operator value
        } else if (allowExtendedOperators && typeof val === 'object') {
          // Do not coerce object values when extended operators are allowed
        } else {
          if (!allowExtendedOperators) {
            const extendedOperators = Object.keys(val).filter(function(k) {
              return k[0] === '$';
            });
            if (extendedOperators.length) {
              const msg = g.f('Operators "' + extendedOperators.join(', ') + '" are not allowed in query');
              const err = new Error(msg);
              err.code = 'OPERATOR_NOT_ALLOWED_IN_QUERY';
              err.statusCode = 400;
              err.details = {
                operators: extendedOperators,
                where: where,
              };
              throw err;
            }
          }
          if (isNestedModel(DataType)) {
            if (Array.isArray(DataType) && Array.isArray(val)) {
              if (val === null || val === undefined) continue;
              for (const it of val) {
                self._coerce(it, options, DataType[0].definition);
              }
            } else {
              self._coerce(val, options, DataType.definition);
            }
            continue;
          } else {
            val = isClass(DataType) ? new DataType(val) : DataType(val);
          }
        }
      }
    }
    // Rebuild {property: {operator: value}}
    if (operator && operator !== 'eq') {
      const value = {};
      value[operator] = val;
      if (exp.options) {
        // Keep options for operators
        value.options = exp.options;
      }
      val = value;
    }
    where[p] = val;
  }
  return where;
};

/**
* A utility function which checks for nested property definitions
*
* @param {*} propType Property type metadata
*
*/
function isNestedModel(propType) {
  if (!propType) return false;
  if (Array.isArray(propType)) return isNestedModel(propType[0]);
  return propType.hasOwnProperty('definition') && propType.definition.hasOwnProperty('properties');
}