'use strict';

const _ = require('lodash');
const Utils = require('./utils');
const sequelizeError = require('./errors');
const Promise = require('./promise');
const DataTypes = require('./data-types');
const BelongsTo = require('./associations/belongs-to');
const validator = require('./utils/validator-extras').validator;

/**
 * Instance Validator.
 *
 * @param {Instance} modelInstance The model instance.
 * @param {Object} options A dictionary with options.
 *
 * @private
 */
class InstanceValidator {
  constructor(modelInstance, options) {
    options = _.clone(options) || {};

    if (options.fields && !options.skip) {
      options.skip = _.difference(Object.keys(modelInstance.constructor.rawAttributes), options.fields);
    }

    // assign defined and default options
    this.options = _.defaults(options, {
      skip: [],
      hooks: true
    });

    this.modelInstance = modelInstance;

    /**
     * Exposes a reference to validator.js. This allows you to add custom validations using `validator.extend`
     * @name validator
     * @private
     */
    this.validator = validator;

    /**
     *  All errors will be stored here from the validations.
     *
     * @type {Array} Will contain keys that correspond to attributes which will
     *   be Arrays of Errors.
     * @private
     */
    this.errors = [];

    /**
     * @type {boolean} Indicates if validations are in progress
     * @private
     */
    this.inProgress = false;
  }

  /**
   * The main entry point for the Validation module, invoke to start the dance.
   *
   * @returns {Promise}
   * @private
   */
  _validate() {
    if (this.inProgress) throw new Error('Validations already in progress.');

    this.inProgress = true;

    return Promise.all([
      this._perAttributeValidators().reflect(),
      this._customValidators().reflect()
    ]).then(() => {
      if (this.errors.length) {
        throw new sequelizeError.ValidationError(null, this.errors);
      }
    });
  }

  /**
   * Invoke the Validation sequence and run validation hooks if defined
   *   - Before Validation Model Hooks
   *   - Validation
   *   - On validation success: After Validation Model Hooks
   *   - On validation failure: Validation Failed Model Hooks
   *
   * @returns {Promise}
   * @private
   */
  validate() {
    return this.options.hooks ? this._validateAndRunHooks() : this._validate();
  }

  /**
   * Invoke the Validation sequence and run hooks
   *   - Before Validation Model Hooks
   *   - Validation
   *   - On validation success: After Validation Model Hooks
   *   - On validation failure: Validation Failed Model Hooks
   *
   * @returns {Promise}
   * @private
   */
  _validateAndRunHooks() {
    const runHooks = this.modelInstance.constructor.runHooks.bind(this.modelInstance.constructor);
    return runHooks('beforeValidate', this.modelInstance, this.options)
      .then(() =>
        this._validate()
          .catch(error => runHooks('validationFailed', this.modelInstance, this.options, error)
            .then(newError => { throw newError || error; }))
      )
      .then(() => runHooks('afterValidate', this.modelInstance, this.options))
      .return(this.modelInstance);
  }

  /**
   * Will run all the validators defined per attribute (built-in validators and custom validators)
   *
   * @returns {Promise<Array.<Promise.PromiseInspection>>} A promise from .reflect().
   * @private
   */
  _perAttributeValidators() {
    // promisify all attribute invocations
    const validators = [];

    _.forIn(this.modelInstance.rawAttributes, (rawAttribute, field) => {
      if (this.options.skip.includes(field)) {
        return;
      }

      const value = this.modelInstance.dataValues[field];

      if (value instanceof Utils.SequelizeMethod) {
        return;
      }

      if (!rawAttribute._autoGenerated && !rawAttribute.autoIncrement) {
        // perform validations based on schema
        this._validateSchema(rawAttribute, field, value);
      }

      if (Object.prototype.hasOwnProperty.call(this.modelInstance.validators, field)) {
        validators.push(this._singleAttrValidate(value, field, rawAttribute.allowNull).reflect());
      }
    });

    return Promise.all(validators);
  }

  /**
   * Will run all the custom validators defined in the model's options.
   *
   * @returns {Promise<Array.<Promise.PromiseInspection>>} A promise from .reflect().
   * @private
   */
  _customValidators() {
    const validators = [];
    _.each(this.modelInstance._modelOptions.validate, (validator, validatorType) => {
      if (this.options.skip.includes(validatorType)) {
        return;
      }

      const valprom = this._invokeCustomValidator(validator, validatorType)
        // errors are handled in settling, stub this
        .catch(() => {})
        .reflect();

      validators.push(valprom);
    });

    return Promise.all(validators);
  }

  /**
   * Validate a single attribute with all the defined built-in validators and custom validators.
   *
   * @private
   *
   * @param {*} value Anything.
   * @param {string} field The field name.
   * @param {boolean} allowNull Whether or not the schema allows null values
   *
   * @returns {Promise} A promise, will always resolve, auto populates error on this.error local object.
   */
  _singleAttrValidate(value, field, allowNull) {
    // If value is null and allowNull is false, no validators should run (see #9143)
    if ((value === null || value === undefined) && !allowNull) {
      // The schema validator (_validateSchema) has already generated the validation error. Nothing to do here.
      return Promise.resolve();
    }

    // Promisify each validator
    const validators = [];
    _.forIn(this.modelInstance.validators[field], (test, validatorType) => {

      if (validatorType === 'isUrl' || validatorType === 'isURL' || validatorType === 'isEmail') {
        // Preserve backwards compat. Validator.js now expects the second param to isURL and isEmail to be an object
        if (typeof test === 'object' && test !== null && test.msg) {
          test = {
            msg: test.msg
          };
        } else if (test === true) {
          test = {};
        }
      }

      // Custom validators should always run, except if value is null and allowNull is false (see #9143)
      if (typeof test === 'function') {
        validators.push(this._invokeCustomValidator(test, validatorType, true, value, field).reflect());
        return;
      }

      // If value is null, built-in validators should not run (only custom validators have to run) (see #9134).
      if (value === null || value === undefined) {
        return;
      }

      const validatorPromise = this._invokeBuiltinValidator(value, test, validatorType, field);
      // errors are handled in settling, stub this
      validatorPromise.catch(() => {});
      validators.push(validatorPromise.reflect());
    });

    return Promise
      .all(validators)
      .then(results => this._handleReflectedResult(field, value, results));
  }

  /**
   * Prepare and invoke a custom validator.
   *
   * @private
   *
   * @param {Function} validator The custom validator.
   * @param {string} validatorType the custom validator type (name).
   * @param {boolean} optAttrDefined Set to true if custom validator was defined from the attribute
   * @param {*} optValue value for attribute
   * @param {string} optField field for attribute
   *
   * @returns {Promise} A promise.
   */
  _invokeCustomValidator(validator, validatorType, optAttrDefined, optValue, optField) {
    let validatorFunction = null; // the validation function to call
    let isAsync = false;

    const validatorArity = validator.length;
    // check if validator is async and requires a callback
    let asyncArity = 1;
    let errorKey = validatorType;
    let invokeArgs;
    if (optAttrDefined) {
      asyncArity = 2;
      invokeArgs = optValue;
      errorKey = optField;
    }
    if (validatorArity === asyncArity) {
      isAsync = true;
    }

    if (isAsync) {
      if (optAttrDefined) {
        validatorFunction = Promise.promisify(validator.bind(this.modelInstance, invokeArgs));
      } else {
        validatorFunction = Promise.promisify(validator.bind(this.modelInstance));
      }
      return validatorFunction()
        .catch(e => this._pushError(false, errorKey, e, optValue, validatorType));
    }
    return Promise
      .try(() => validator.call(this.modelInstance, invokeArgs))
      .catch(e => this._pushError(false, errorKey, e, optValue, validatorType));
  }

  /**
   * Prepare and invoke a build-in validator.
   *
   * @private
   *
   * @param {*} value Anything.
   * @param {*} test The test case.
   * @param {string} validatorType One of known to Sequelize validators.
   * @param {string} field The field that is being validated
   *
   * @returns {Object} An object with specific keys to invoke the validator.
   */
  _invokeBuiltinValidator(value, test, validatorType, field) {
    return Promise.try(() => {
      // Cast value as string to pass new Validator.js string requirement
      const valueString = String(value);
      // check if Validator knows that kind of validation test
      if (typeof validator[validatorType] !== 'function') {
        throw new Error(`Invalid validator function: ${validatorType}`);
      }

      const validatorArgs = this._extractValidatorArgs(test, validatorType, field);

      if (!validator[validatorType](valueString, ...validatorArgs)) {
        throw Object.assign(new Error(test.msg || `Validation ${validatorType} on ${field} failed`), { validatorName: validatorType, validatorArgs });
      }
    });
  }

  /**
   * Will extract arguments for the validator.
   *
   * @param {*} test The test case.
   * @param {string} validatorType One of known to Sequelize validators.
   * @param {string} field The field that is being validated.
   *
   * @private
   */
  _extractValidatorArgs(test, validatorType, field) {
    let validatorArgs = test.args || test;
    const isLocalizedValidator = typeof validatorArgs !== 'string' && (validatorType === 'isAlpha' || validatorType === 'isAlphanumeric' || validatorType === 'isMobilePhone');

    if (!Array.isArray(validatorArgs)) {
      if (validatorType === 'isImmutable') {
        validatorArgs = [validatorArgs, field, this.modelInstance];
      } else if (isLocalizedValidator || validatorType === 'isIP') {
        validatorArgs = [];
      } else {
        validatorArgs = [validatorArgs];
      }
    } else {
      validatorArgs = validatorArgs.slice(0);
    }
    return validatorArgs;
  }

  /**
   * Will validate a single field against its schema definition (isnull).
   *
   * @param {Object} rawAttribute As defined in the Schema.
   * @param {string} field The field name.
   * @param {*} value anything.
   *
   * @private
   */
  _validateSchema(rawAttribute, field, value) {
    if (rawAttribute.allowNull === false && (value === null || value === undefined)) {
      const association = _.values(this.modelInstance.constructor.associations).find(association => association instanceof BelongsTo && association.foreignKey === rawAttribute.fieldName);
      if (!association || !this.modelInstance.get(association.associationAccessor)) {
        const validators = this.modelInstance.validators[field];
        const errMsg = _.get(validators, 'notNull.msg', `${this.modelInstance.constructor.name}.${field} cannot be null`);

        this.errors.push(new sequelizeError.ValidationErrorItem(
          errMsg,
          'notNull Violation', // sequelizeError.ValidationErrorItem.Origins.CORE,
          field,
          value,
          this.modelInstance,
          'is_null'
        ));
      }
    }

    if (rawAttribute.type instanceof DataTypes.STRING || rawAttribute.type instanceof DataTypes.TEXT || rawAttribute.type instanceof DataTypes.CITEXT) {
      if (Array.isArray(value) || _.isObject(value) && !(value instanceof Utils.SequelizeMethod) && !Buffer.isBuffer(value)) {
        this.errors.push(new sequelizeError.ValidationErrorItem(
          `${field} cannot be an array or an object`,
          'string violation', // sequelizeError.ValidationErrorItem.Origins.CORE,
          field,
          value,
          this.modelInstance,
          'not_a_string'
        ));
      }
    }
  }


  /**
   * Handles the returned result of a Promise.reflect.
   *
   * If errors are found it populates this.error.
   *
   * @param {string} field The attribute name.
   * @param {string|number} value The data value.
   * @param {Array<Promise.PromiseInspection>} promiseInspections objects.
   *
   * @private
   */
  _handleReflectedResult(field, value, promiseInspections) {
    for (const promiseInspection of promiseInspections) {
      if (promiseInspection.isRejected()) {
        const rejection = promiseInspection.error();
        const isBuiltIn = !!rejection.validatorName;

        this._pushError(isBuiltIn, field, rejection, value, rejection.validatorName, rejection.validatorArgs);
      }
    }
  }

  /**
   * Signs all errors retaining the original.
   *
   * @param {boolean}       isBuiltin   - Determines if error is from builtin validator.
   * @param {string}        errorKey    - name of invalid attribute.
   * @param {Error|string}  rawError    - The original error.
   * @param {string|number} value       - The data that triggered the error.
   * @param {string}        fnName      - Name of the validator, if any
   * @param {Array}         fnArgs      - Arguments for the validator [function], if any
   *
   * @private
   */
  _pushError(isBuiltin, errorKey, rawError, value, fnName, fnArgs) {
    const message = rawError.message || rawError || 'Validation error';
    const error = new sequelizeError.ValidationErrorItem(
      message,
      'Validation error', // sequelizeError.ValidationErrorItem.Origins.FUNCTION,
      errorKey,
      value,
      this.modelInstance,
      fnName,
      isBuiltin ? fnName : undefined,
      isBuiltin ? fnArgs : undefined
    );

    error[InstanceValidator.RAW_KEY_NAME] = rawError;

    this.errors.push(error);
  }
}
/**
 * @define {string} The error key for arguments as passed by custom validators
 * @private
 */
InstanceValidator.RAW_KEY_NAME = 'original';

module.exports = InstanceValidator;
module.exports.InstanceValidator = InstanceValidator;
module.exports.default = InstanceValidator;
