Show:
/**
 * Copyright 2016, Yahoo! Inc.
 * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
 */

import Ember from 'ember';
import getOwner from 'ember-getowner-polyfill';
import flatten from '../utils/flatten';
import assign from '../utils/assign';
import ValidationResult from './result';
import ValidationResultCollection from './result-collection';
import BaseValidator from '../validators/base';
import cycleBreaker from '../utils/cycle-breaker';
import shouldCallSuper from '../utils/should-call-super';

const {
  get,
  set,
  run,
  RSVP,
  isNone,
  guidFor,
  isEmpty,
  isArray,
  computed,
  makeArray,
  canInvoke,
  getWithDefault,
  A: emberArray
} = Ember;

const merge = Ember.assign || Ember.merge;

const {
  Promise
} = RSVP;

const {
  and,
  or,
  not
} = computed;

/**
 * ## Running Manual Validations
 *
 * Although validations are lazily computed, there are times where we might want to force all or
 * specific validations to happen. For this reason we have exposed two methods:
 * - {{#crossLink "Factory/validateSync:method"}}{{/crossLink}}: Should only be used if all validations are synchronous. It will throw an error if any of the validations are asynchronous
 * - {{#crossLink "Factory/validate:method"}}{{/crossLink}}: Will always return a promise and should be used if asynchronous validations are present
 *
 * ## Inspecting Validations
 *
 * All validations can be accessed via the `validations` object created on your model/object.
 * Each attribute also has its own validation which has the same properties.
 * An attribute validation can be accessed via `validations.attrs.<ATTRIBUTE>` which will return a {{#crossLink "ResultCollection"}}{{/crossLink}}.
 *
 * ### Global Validations
 *
 * Global validations exist on the `validations` object that resides on the object that is being validated.
 * To see all possible properties, please checkout the docs for {{#crossLink "ResultCollection"}}{{/crossLink}}.
 *
 * ```js
 * model.get('validations.isValid');
 * model.get('validations.errors');
 * model.get('validations.messages');
 * // etc...
 * ```
 *
 * ### Attribute Validations
 *
 * The `validations` object also contains an `attrs` object which holds a {{#crossLink "ResultCollection"}}{{/crossLink}}
 * for each attribute specified in your validation rules.
 *
 * ```js
 * model.get('validations.attrs.username.isValid');
 * model.get('validations.attrs.password.errors');
 * model.get('validations.attrs.email.messages');
 * // etc...
 * ```
 *
 * @module Validations
 * @main Validations
 * @class Factory
 */

/**
 * Top level method that will ultimately return a mixin with all CP validations
 * @method  buildValidations
 * @param  {Object} validations  Validation rules
 * @return {Ember.Mixin}
 */
export
default

function buildValidations(validations = {}, globalOptions = {}) {
  normalizeOptions(validations, globalOptions);

  let Validations, validationMixinCount;

  return Ember.Mixin.create({
    init() {
      this._super(...arguments);

      // Count number of mixins to bypass super check if there is more than 1
      this.__validationsMixinCount__ = this.__validationsMixinCount__ || 0;
      validationMixinCount = ++this.__validationsMixinCount__;
    },
    __validationsClass__: computed(function () {
      if (!Validations) {
        let inheritedClass;
        if(shouldCallSuper(this, '__validationsClass__') || validationMixinCount > 1) {
          inheritedClass = this._super();
        }

        Validations = createValidationsClass(inheritedClass, validations, getOwner(this));
      }
      return Validations;
    }).readOnly(),
    validations: computed(function () {
      return this.get('__validationsClass__').create({
        model: this
      });
    }).readOnly(),
    validate() {
      return get(this, 'validations').validate(...arguments);
    },
    validateSync() {
      return get(this, 'validations').validateSync(...arguments);
    },
    destroy() {
      this._super(...arguments);
      get(this, 'validations').destroy();
    }
  });
}

/**
 * Validation rules can be created with default and global options
 * {
 *   description: 'Username',
 *   validators: [...]
 * }
 * This method generate the default options pojo, applies it to each validation rule, and flattens the object
 * @method normalizeOptions
 * @private
 * @param  {Object} validations
 * @return
 */
function normalizeOptions(validations = {}, globalOptions = {}) {
  const validatableAttrs = Object.keys(validations);

  validatableAttrs.forEach(attribute => {
    const rules = validations[attribute];

    if (rules && typeof rules === 'object' && isArray(rules.validators)) {
      const options = Object.keys(rules).reduce((o, k) => {
        if (k !== 'validators') {
          o[k] = rules[k];
        }
        return o;
      }, {});

      const validators = rules.validators;

      validators.forEach(v => {
        v.defaultOptions = options;
      });
      validations[attribute] = validators;
    }
    validations[attribute] = makeArray(validations[attribute]);
    validations[attribute].forEach(v => {
      v.globalOptions = globalOptions;
    });
  });
}

/**
 * Creates the validations class that will become `model.validations`.
 *   - Setup parent validation inheritance
 *   - Normalize nested keys (i.e. 'details.dob') into objects (i.e { details: { dob: validator() }})
 *   - Merge normalized validations with parent
 *   - Create global CPs (i.e. 'isValid', 'messages', etc...)
 *
 * @method createValidationsClass
 * @private
 * @param  {Object} inheritedValidationsClass
 * @param  {Object} validations
 * @param  {Object} owner
 * @return {Ember.Object}
 */
function createValidationsClass(inheritedValidationsClass, validations, owner) {
  let validationRules = {};
  let validatableAttributes = Object.keys(validations);

  // Setup validation inheritance
  if (inheritedValidationsClass && inheritedValidationsClass.__isCPValidationsClass__) {
    const inheritedValidations = inheritedValidationsClass.create();

    validationRules = merge(validationRules, inheritedValidations.get('_validationRules'));
    validatableAttributes = emberArray(inheritedValidations.get('validatableAttributes').concat(validatableAttributes)).uniq();
  }

  // Normalize nested keys into actual objects and merge them with parent object
  Object.keys(validations).reduce((obj, key) => {
    assign(obj, key, validations[key]);
    return obj;
  }, validationRules);

  // Create the mixin that holds all the top level validation props (isValid, messages, etc)
  const TopLevelProps = createTopLevelPropsMixin(validatableAttributes);

  // Create the `attrs` class which will add the current model reference once instantiated
  const AttrsClass = createAttrsClass(validatableAttributes, validationRules, owner);

  // Create `validations` class
  const ValidationsClass = Ember.Object.extend(TopLevelProps, {
    model: null,
    attrs: null,
    isValidations: true,

    validatableAttributes: computed(function () {
      return validatableAttributes;
    }).readOnly(),

    // Caches
    _validators: null,
    _debouncedValidations: null,

    // Private
    _validationRules: computed(function () {
      return validationRules;
    }).readOnly(),

    validate,
    validateSync,

    init() {
      this._super(...arguments);
      this.setProperties({
        attrs: AttrsClass.create({
          _model: this.get('model')
        }),
        _validators: {},
        _debouncedValidations: {}
      });
    },

    destroy() {
      this._super(...arguments);
      const validatableAttrs = get(this, 'validatableAttributes');
      const debouncedValidations = get(this, '_debouncedValidations');

      // Initiate attrs destroy to cleanup any remaining model references
      this.get('attrs').destroy();

      // Cancel all debounced timers
      validatableAttrs.forEach(attr => {
        const attrCache = get(debouncedValidations, attr);

        if (!isNone(attrCache)) {
          // Itterate over each attribute and cancel all of its debounced validations
          Object.keys(attrCache).forEach(v => run.cancel(attrCache[v]));
        }
      });
    }
  });

  ValidationsClass.reopenClass({
    __isCPValidationsClass__: true
  });

  return ValidationsClass;
}

/**
 * Creates the `attrs` class which holds all the CP logic
 *
 * ```javascript
 * model.get('validations.attrs.username');
 * model.get('validations.attrs.nested.object.attribute');
 * ```
 *
 * @method createAttrsClass
 * @private
 * @param  {Object} validatableAttributes
 * @param  {Object} validationRules
 * @param  {Object} owner
 * @return {Ember.Object}
 */
function createAttrsClass(validatableAttributes, validationRules, owner) {
  return Ember.Object.extend({
    init() {
      this._super(...arguments);
      const model = this.get('_model');

      // Create the CPs
      validatableAttributes.forEach(attribute => {
        const cp = createCPValidationFor(attribute, get(validationRules, attribute), owner);
        assign(this, attribute, cp, true);
      });

      validatableAttributes.forEach(attribute => {
        // Add a reference to the model in the deepest object
        const path = attribute.split('.');
        const lastObject = get(this, path.slice(0, path.length - 1).join('.'));

        if (isNone(get(lastObject, '_model'))) {
          set(lastObject, '_model', model);
        }
      });
    },

    destroy() {
      this._super(...arguments);
      validatableAttributes.forEach(attribute => {
        // Remove model reference from nested objects
        const path = attribute.split('.');
        const lastObject = get(this, path.slice(0, path.length - 1).join('.'));

        if (!isNone(get(lastObject, '_model'))) {
          set(lastObject, '_model', null);
        }
      });
    }
  });
}

/**
 * CP generator for the given attribute
 * @method createCPValidationFor
 * @private
 * @param  {String} attribute
 * @param  {Array / Object} validations
 * @return {Ember.computed} A computed property which is a ValidationResultCollection
 */
function createCPValidationFor(attribute, validations, owner) {
  const dependentKeys = getCPDependentKeysFor(attribute, validations, owner);

  return computed(...dependentKeys, cycleBreaker(function () {
    const model = get(this, '_model');
    const validators = !isNone(model) ? getValidatorsFor(attribute, model) : [];

    const validationResults = validators.map(validator => {
      const options = validator.processOptions();
      const debounce = getWithDefault(options, 'debounce', 0);
      const disabled = getWithDefault(options, 'disabled', false);
      let value;

      if (disabled) {
        value = true;
      } else if (debounce > 0) {
        const cache = getDebouncedValidationsCacheFor(attribute, model);

        // Return a promise and pass the resolve method to the debounce handler
        value = new Promise(resolve => {
          cache[guidFor(validator)] = run.debounce(validator, debouncedValidate, validator, model, attribute, resolve, debounce, false);
        });
      } else {
        value = validator.validate(validator.getValue(), options, model, attribute);
      }

      return validationReturnValueHandler(attribute, value, model, validator);
    });

    return ValidationResultCollection.create({
      attribute,
      content: flatten(validationResults)
    });
  })).readOnly();
}

/**
 * Create a mixin that will have all the top level CPs under the validations object.
 * These are computed collections on different properties of each attribute validations CP
 *
 * @method createTopLevelPropsMixin
 * @private
 * @param  {Object} validations
 */
function createTopLevelPropsMixin(validatableAttrs) {
  return Ember.Mixin.create({
    isValid: and(...validatableAttrs.map(attr => `attrs.${attr}.isValid`)).readOnly(),
    isValidating: or(...validatableAttrs.map(attr => `attrs.${attr}.isValidating`)).readOnly(),
    isDirty: or(...validatableAttrs.map(attr => `attrs.${attr}.isDirty`)).readOnly(),
    isAsync: or(...validatableAttrs.map(attr => `attrs.${attr}.isAsync`)).readOnly(),
    isNotValidating: not('isValidating').readOnly(),
    isInvalid: not('isValid').readOnly(),
    isTruelyValid: and('isValid', 'isNotValidating').readOnly(),

    messages: computed(...validatableAttrs.map(attr => `attrs.${attr}.messages`), function () {
      return emberArray(flatten(validatableAttrs.map(attr => get(this, `attrs.${attr}.messages`)))).compact();
    }).readOnly(),

    message: computed('messages.[]', cycleBreaker(function () {
      return get(this, 'messages.0');
    })).readOnly(),

    errors: computed(...validatableAttrs.map(attr => `attrs.${attr}.@each.errors`), function () {
      return emberArray(flatten(validatableAttrs.map(attr => get(this, `attrs.${attr}.errors`)))).compact();
    }).readOnly(),

    error: computed('errors.[]', cycleBreaker(function () {
      return get(this, 'errors.0');
    })).readOnly(),

    _promise: computed(...validatableAttrs.map(attr => `attrs.${attr}._promise`), function () {
      const promises = [];

      validatableAttrs.forEach(attr => {
        const validation = get(this, `attrs.${attr}`);

        if (get(validation, 'isAsync')) {
          promises.push(get(validation, '_promise'));
        }
      });
      return RSVP.Promise.all(flatten(promises));
    }).readOnly()
  });
}

/**
 * CP dependency generator for a give attribute depending on its relationships
 * @method getCPDependentKeysFor
 * @private
 * @param  {String} attribute
 * @param  {Array / Object} validations
 * @return {Array} Unique list of dependencies
 */
function getCPDependentKeysFor(attribute, validations, owner) {
  let dependentKeys = validations.map(validation => {
    const type = validation._type;
    const options = validation.options;
    const Validator = type === 'function' ? BaseValidator : lookupValidator(owner, type);
    const baseDependents = BaseValidator.getDependentsFor(attribute, options) || [];
    const dependents = Validator.getDependentsFor(attribute, options) || [];

    const specifiedDependents = [].concat(
      getWithDefault(options, 'dependentKeys', []),
      getWithDefault(validation, 'defaultOptions.dependentKeys', []),
      getWithDefault(validation, 'globalOptions.dependentKeys', [])
    );

    return baseDependents.concat(
      dependents,
      specifiedDependents.map(d => `_model.${d}`)
    );
  });

  dependentKeys = flatten(dependentKeys);
  dependentKeys.push(`_model.${attribute}`);

  return emberArray(dependentKeys).uniq();
}

/**
 * Debounce handler for running a validation for the specified options
 * @method debouncedValidate
 * @private
 * @param  {Validator} validator
 * @param  {Unknown} value
 * @param  {Object} options
 * @param  {Object} model
 * @param  {String} attribute
 * @param  {Function} resolve
 */
function debouncedValidate(validator, model, attribute, resolve) {
  const options = validator.processOptions();
  const value = validator.getValue();

  resolve(validator.validate(value, options, model, attribute));
}

/**
 * A handler used to create ValidationResult object from values returned from a validator
 * @method validationReturnValueHandler
 * @private
 * @param  {String} attribute
 * @param  {Unknown} value
 * @param  {Object} model
 * @return {ValidationResult}
 */
function validationReturnValueHandler(attribute, value, model, validator) {
  let result;

  if (canInvoke(value, 'then')) {
    result = ValidationResult.create({
      attribute,
      _promise: Promise.resolve(value),
      model,
      _validator: validator
    });
  } else {
    result = ValidationResult.create({
      attribute,
      model,
      _validator: validator
    });
    result.update(value);
  }

  return result;
}

/**
 * Get validators for the give attribute. If they are not in the cache, then create them.
 * @method getValidatorsFor
 * @private
 * @param  {String} attribute
 * @param  {Object} model
 * @return {Array}
 */
function getValidatorsFor(attribute, model) {
  const validators = get(model, `validations._validators.${attribute}`);

  if (!isNone(validators)) {
    return validators;
  }

  return createValidatorsFor(attribute, model);
}

/**
 * Get debounced validation cache for the given attribute. If it doesnt exist, create a new one.
 * @method getValidatorCacheFor
 * @private
 * @param  {String} attribute
 * @param  {Object} model
 * @return {Map}
 */
function getDebouncedValidationsCacheFor(attribute, model) {
  const debouncedValidations = get(model, 'validations._debouncedValidations');

  if (isNone(get(debouncedValidations, attribute))) {
    assign(debouncedValidations, attribute, {});
  }

  return get(debouncedValidations, attribute);
}

/**
 * Create validators for the give attribute and store them in a cache
 * @method createValidatorsFor
 * @private
 * @param  {String} attribute
 * @param  {Object} model
 * @return {Array}
 */
function createValidatorsFor(attribute, model) {
  const validations = get(model, 'validations');
  const validationRules = makeArray(get(validations, `_validationRules.${attribute}`));
  const validatorCache = get(validations, '_validators');
  const owner = getOwner(model);
  const validators = [];
  let validator;

  // We must have an owner to be able to lookup our validators
  if (isNone(owner)) {
    throw new TypeError(`[ember-cp-validations] ${model.toString()} is missing a container or owner.`);
  }

  validationRules.forEach(v => {
    v.attribute = attribute;
    v.model = model;

    // If validate function exists, that means validator was created with a function so use the base class
    if (v._type === 'function') {
      validator = BaseValidator.create(owner.ownerInjection(), v);
    } else {
      validator = lookupValidator(owner, v._type).create(v);
    }
    validators.push(validator);
  });

  // Add validators to model instance cache
  assign(validatorCache, attribute, validators);

  return validators;
}

/**
 * Lookup a validators of a specific type on the owner
 * @method lookupValidator
 * @throws {Error} Validator not found
 * @private
 * @param  {Ember.Owner} owner
 * @param  {String} type
 * @return {Class} Validator class or undefined if not found
 */
function lookupValidator(owner, type) {
  const validatorClass = owner._lookupFactory(`validator:${type}`);

  if (isNone(validatorClass)) {
    throw new Error(`[ember-cp-validations] Validator not found of type: ${type}.`);
  }
  return validatorClass;
}

/**
 * ### Options
 * - `on` (**Array**): Only validate the given attributes. If empty, will validate over all validatable attribute
 * - `excludes` (**Array**): Exclude validation on the given attributes
 *
 * ```javascript
 * model.validate({ on: ['username', 'email'] }).then(({ m, validations }) => {
 *   validations.get('isValid'); // true or false
 *   validations.get('isValidating'); // false
 *
 *   let usernameValidations = m.get('validations.attrs.username');
 *   usernameValidations.get('isValid') // true or false
 * });
 * ```
 *
 * @method validate
 * @param  {Object}  options
 * @param  {Boolean} async      If `false`, will get all validations and will error if an async validations is found.
 *                              If `true`, will get all validations and wrap them in a promise hash
 * @return {Promise or Object}  Promise if async is true, object if async is false
 */
function validate(options = {}, async = true) {
  const model = get(this, 'model');
  const whiteList = makeArray(options.on);
  const blackList = makeArray(options.excludes);

  const validationResults = get(this, 'validatableAttributes').reduce((v, name) => {
    if (!isEmpty(blackList) && blackList.indexOf(name) !== -1) {
      return v;
    }

    if (isEmpty(whiteList) || whiteList.indexOf(name) !== -1) {
      const validationResult = get(this, `attrs.${name}`);

      // If an async validation is found, throw an error
      if (!async && get(validationResult, 'isAsync')) {
        throw new Error(`[ember-cp-validations] Synchronous validation failed due to ${name} being an async validation.`);
      }

      v.push(validationResult);
    }
    return v;
  }, []);

  const validationResultsCollection = ValidationResultCollection.create({
    content: validationResults
  });

  const resultObject = {
    model,
    validations: validationResultsCollection
  };

  if (async) {
    if (get(validationResultsCollection, 'isAsync')) {
      resultObject.promise = get(validationResultsCollection, 'value');
    }
    return RSVP.hash(resultObject);
  }

  return resultObject;
}

/**
 * ### Options
 * - `on` (**Array**): Only validate the given attributes. If empty, will validate over all validatable attribute
 * - `excludes` (**Array**): Exclude validation on the given attributes
 *
 * ```javascript
 * const { m, validations } = model.validateSync();
 * validations.get('isValid') // true or false
 * ```
 * @method validateSync
 * @param  {Object}  options
 * @return {Object}
 */
function validateSync(options) {
  return this.validate(options, false);
}