text: 'use strict';
  
  var SchemaType = require('./schematype');
  var Types = require('./types');
  var Promise = require('bluebird');
  var util = require('./util');
  var PopulationError = require('./error/population');
  var isPlainObject = require('is-plain-object');
  
  var getProp = util.getProp;
  var setProp = util.setProp;
  var delProp = util.delProp;
  var isArray = Array.isArray;
  
  var builtinTypes = {
    String: true,
    Number: true,
    Boolean: true,
    Array: true,
    Object: true,
    Date: true,
    Buffer: true
  };
  
  /**
   * Schema constructor.
   *
   * @class
   * @param {Object} schema
   */
  function Schema(schema) {
    this.paths = {};
    this.statics = {};
    this.methods = {};
  
    this.hooks = {
      pre: {
        save: [],
        remove: []
      },
      post: {
        save: [],
        remove: []
      }
    };
  
    this.stacks = {
      getter: [],
      setter: [],
      import: [],
      export: []
    };
  
    if (schema) {
      this.add(schema);
    }
  }
  
  /**
   * Adds paths.
   *
   * @param {Object} schema
   * @param {String} prefix
   */
  Schema.prototype.add = function(schema, prefix_) {
    var prefix = prefix_ || '';
    var keys = Object.keys(schema);
    var len = keys.length;
    var key, value;
  
    if (!len) return;
  
    for (var i = 0; i < len; i++) {
      key = keys[i];
      value = schema[key];
  
      this.path(prefix + key, value);
    }
  };
  
  function getSchemaType(name, options) {
    var Type = options.type || options;
    var typeName = Type.name;
  
    if (builtinTypes[typeName]) {
      return new Types[typeName](name, options);
    }
  
    return new Type(name, options);
  }
  
  /**
   * Gets/Sets a path.
   *
   * @param {String} name
   * @param {*} obj
   * @return {SchemaType}
   */
  Schema.prototype.path = function(name, obj) {
    if (obj == null) {
      return this.paths[name];
    }
  
    var type;
    var nested = false;
  
    if (obj instanceof SchemaType) {
      type = obj;
    } else {
      switch (typeof obj){
        case 'function':
          type = getSchemaType(name, {type: obj});
          break;
  
        case 'object':
          if (obj.type) {
            type = getSchemaType(name, obj);
          } else if (isArray(obj)) {
            type = new Types.Array(name, {
              child: obj.length ? getSchemaType(name, obj[0]) : new SchemaType(name)
            });
          } else {
            type = new Types.Object();
            nested = Object.keys(obj).length > 0;
          }
  
          break;
  
        default:
          throw new TypeError('Invalid value for schema path `' + name + '`');
      }
    }
  
    this.paths[name] = type;
    this._updateStack(name, type);
  
    if (nested) this.add(obj, name + '.');
  };
  
  /**
   * Updates cache stacks.
   *
   * @param {String} name
   * @param {SchemaType} type
   * @private
   */
  Schema.prototype._updateStack = function(name, type) {
    var stacks = this.stacks;
  
    stacks.getter.push(function(data) {
      var value = getProp(data, name);
      var result = type.cast(value, data);
  
      if (result !== undefined) {
        setProp(data, name, result);
      }
    });
  
    stacks.setter.push(function(data) {
      var value = getProp(data, name);
      var result = type.validate(value, data);
  
      if (result !== undefined) {
        setProp(data, name, result);
      } else {
        delProp(data, name);
      }
    });
  
    stacks.import.push(function(data) {
      var value = getProp(data, name);
      var result = type.parse(value, data);
  
      if (result !== undefined) {
        setProp(data, name, result);
      }
    });
  
    stacks.export.push(function(data) {
      var value = getProp(data, name);
      var result = type.value(value, data);
  
      if (result !== undefined) {
        setProp(data, name, result);
      } else {
        delProp(data, name);
      }
    });
  };
  
  /**
   * Adds a virtual path.
   *
   * @param {String} name
   * @param {Function} [getter]
   * @return {SchemaType.Virtual}
   */
  Schema.prototype.virtual = function(name, getter) {
    var virtual = new Types.Virtual(name, {});
    if (getter) virtual.get(getter);
  
    this.path(name, virtual);
  
    return virtual;
  };
  
  function checkHookType(type) {
    if (type !== 'save' && type !== 'remove') {
      throw new TypeError('Hook type must be `save` or `remove`!');
    }
  }
  
  function hookWrapper(fn) {
    if (fn.length > 1) {
      return Promise.promisify(fn);
    }
  
    return Promise.method(fn);
  }
  
  /**
   * Adds a pre-hook.
   *
   * @param {String} type Hook type. One of `save` or `remove`.
   * @param {Function} fn
   */
  Schema.prototype.pre = function(type, fn) {
    checkHookType(type);
    if (typeof fn !== 'function') throw new TypeError('Hook must be a function!');
  
    this.hooks.pre[type].push(hookWrapper(fn));
  };
  
  /**
   * Adds a post-hook.
   *
   * @param {String} type Hook type. One of `save` or `remove`.
   * @param {Function} fn
   */
  Schema.prototype.post = function(type, fn) {
    checkHookType(type);
    if (typeof fn !== 'function') throw new TypeError('Hook must be a function!');
  
    this.hooks.post[type].push(hookWrapper(fn));
  };
  
  /**
   * Adds a instance method.
   *
   * @param {String} name
   * @param {Function} fn
   */
  Schema.prototype.method = function(name, fn) {
    if (!name) throw new TypeError('Method name is required!');
  
    if (typeof fn !== 'function') {
      throw new TypeError('Instance method must be a function!');
    }
  
    this.methods[name] = fn;
  };
  
  /**
   * Adds a static method.
   *
   * @param {String} name
   * @param {Function} fn
   */
  Schema.prototype.static = function(name, fn) {
    if (!name) throw new TypeError('Method name is required!');
  
    if (typeof fn !== 'function') {
      throw new TypeError('Static method must be a function!');
    }
  
    this.statics[name] = fn;
  };
  
  /**
   * Apply getters.
   *
   * @param {Object} data
   * @return {*}
   * @private
   */
  Schema.prototype._applyGetters = function(data) {
    var stack = this.stacks.getter;
  
    for (var i = 0, len = stack.length; i < len; i++) {
      stack[i](data);
    }
  };
  
  /**
   * Apply setters.
   *
   * @param {Object} data
   * @return {*}
   * @private
   */
  Schema.prototype._applySetters = function(data) {
    var stack = this.stacks.setter;
  
    for (var i = 0, len = stack.length; i < len; i++) {
      stack[i](data);
    }
  };
  
  /**
   * Parses database.
   *
   * @param {Object} data
   * @return {Object}
   * @private
   */
  Schema.prototype._parseDatabase = function(data) {
    var stack = this.stacks.import;
  
    for (var i = 0, len = stack.length; i < len; i++) {
      stack[i](data);
    }
  
    return data;
  };
  
  /**
   * Exports database.
   *
   * @param {Object} data
   * @return {Object}
   * @private
   */
  Schema.prototype._exportDatabase = function(data) {
    var stack = this.stacks.export;
  
    for (var i = 0, len = stack.length; i < len; i++) {
      stack[i](data);
    }
  
    return data;
  };
  
  function updateStackNormal(key, update) {
    return function(data) {
      setProp(data, key, update);
    };
  }
  
  function updateStackOperator(path_, ukey, key, update) {
    var path = path_ || new SchemaType(key);
  
    return function(data) {
      var result = path[ukey](getProp(data, key), update, data);
      setProp(data, key, result);
    };
  }
  
  /**
   * Parses updating expressions and returns a stack.
   *
   * @param {Object} updates
   * @param {String} [prefix]
   * @return {Array}
   * @private
   */
  Schema.prototype._parseUpdate = function(updates, prefix_) {
    var prefix = prefix_ || '';
    var paths = this.paths;
    var stack = [];
    var keys = Object.keys(updates);
    var key, update, ukey, name, path, fields, field, j, fieldLen, prefixNoDot;
  
    if (prefix) {
      prefixNoDot = prefix.substring(0, prefix.length - 1);
      path = paths[prefixNoDot];
    }
  
    for (var i = 0, len = keys.length; i < len; i++) {
      key = keys[i];
      update = updates[key];
      name = prefix + key;
  
      // Update operators
      if (key[0] === '$') {
        ukey = 'u' + key;
  
        // First-class update operators
        if (prefix) {
          stack.push(updateStackOperator(path, ukey, prefixNoDot, update));
        } else { // Inline update operators
          fields = Object.keys(update);
          fieldLen = fields.length;
  
          for (j = 0; j < fieldLen; j++) {
            field = fields[i];
            stack.push(
              updateStackOperator(paths[field], ukey, field, update[field]));
          }
        }
      } else if (isPlainObject(update)) {
        stack = stack.concat(this._parseUpdate(update, name + '.'));
      } else {
        stack.push(updateStackNormal(name, update));
      }
    }
  
    return stack;
  };
  
  function queryStackNormal(path_, key, query) {
    var path = path_ || new SchemaType(key);
  
    return function(data) {
      return path.match(getProp(data, key), query, data);
    };
  }
  
  function queryStackOperator(path_, qkey, key, query) {
    var path = path_ || new SchemaType(key);
  
    return function(data) {
      return path[qkey](getProp(data, key), query, data);
    };
  }
  
  function execQueryStack(stack) {
    var len = stack.length;
    var i;
  
    return function(data) {
      for (i = 0; i < len; i++) {
        if (!stack[i](data)) return false;
      }
  
      return true;
    };
  }
  
  function $or(stack) {
    var len = stack.length;
    var i;
  
    return function(data) {
      for (i = 0; i < len; i++) {
        if (stack[i](data)) return true;
      }
  
      return false;
    };
  }
  
  function $nor(stack) {
    var len = stack.length;
    var i;
  
    return function(data) {
      for (i = 0; i < len; i++) {
        if (stack[i](data)) return false;
      }
  
      return true;
    };
  }
  
  function $not(stack) {
    var fn = execQueryStack(stack);
  
    return function(data) {
      return !fn(data);
    };
  }
  
  function $where(fn) {
    return function(data) {
      return fn.call(data);
    };
  }
  
  /**
   * Parses array of query expressions and returns a stack.
   *
   * @param {Array} arr
   * @return {Array}
   * @private
   */
  Schema.prototype._parseQueryArray = function(arr) {
    var stack = [];
  
    for (var i = 0, len = arr.length; i < len; i++) {
      stack.push(execQueryStack(this._parseQuery(arr[i])));
    }
  
    return stack;
  };
  
  /**
   * Parses normal query expressions and returns a stack.
   *
   * @param {Array} queries
   * @param {String} [prefix]
   * @return {Array}
   * @private
   */
  Schema.prototype._parseNormalQuery = function(queries, prefix_) {
    var prefix = prefix_ || '';
    var paths = this.paths;
    var stack = [];
    var keys = Object.keys(queries);
    var key, query, name, path, prefixNoDot;
  
    if (prefix) {
      prefixNoDot = prefix.substring(0, prefix.length - 1);
      path = paths[prefixNoDot];
    }
  
    for (var i = 0, len = keys.length; i < len; i++) {
      key = keys[i];
      query = queries[key];
      name = prefix + key;
  
      if (key[0] === '$') {
        stack.push(queryStackOperator(path, 'q' + key, prefixNoDot, query));
      } else if (isPlainObject(query)) {
        stack = stack.concat(this._parseNormalQuery(query, name + '.'));
      } else {
        stack.push(queryStackNormal(paths[name], name, query));
      }
    }
  
    return stack;
  };
  
  /**
   * Parses query expressions and returns a stack.
   *
   * @param {Array} queries
   * @return {Array}
   * @private
   */
  Schema.prototype._parseQuery = function(queries) {
    var stack = [];
    var paths = this.paths;
    var keys = Object.keys(queries);
    var key, query;
  
    for (var i = 0, len = keys.length; i < len; i++) {
      key = keys[i];
      query = queries[key];
  
      switch (key){
        case '$and':
          stack = stack.concat(this._parseQueryArray(query));
          break;
  
        case '$or':
          stack.push($or(this._parseQueryArray(query)));
          break;
  
        case '$nor':
          stack.push($nor(this._parseQueryArray(query)));
          break;
  
        case '$not':
          stack.push($not(this._parseQuery(query)));
          break;
  
        case '$where':
          stack.push($where(query));
          break;
  
        default:
          if (isPlainObject(query)) {
            stack = stack.concat(this._parseNormalQuery(query, key + '.'));
          } else {
            stack.push(queryStackNormal(paths[key], key, query));
          }
      }
    }
  
    return stack;
  };
  
  /**
   * Returns a function for querying.
   *
   * @param {Object} query
   * @return {Function}
   * @private
   */
  Schema.prototype._execQuery = function(query) {
    var stack = this._parseQuery(query);
    return execQueryStack(stack);
  };
  
  function execSortStack(stack) {
    var len = stack.length;
    var i;
  
    return function(a, b) {
      var result;
  
      for (i = 0; i < len; i++) {
        result = stack[i](a, b);
        if (result) break;
      }
  
      return result;
    };
  }
  
  function sortStack(path_, key, sort) {
    var path = path_ || new SchemaType(key);
    var descending = sort === 'desc' || sort === -1;
  
    return function(a, b) {
      var result = path.compare(getProp(a, key), getProp(b, key));
      return descending && result ? result * -1 : result;
    };
  }
  
  /**
   * Parses sorting expressions and returns a stack.
   *
   * @param {Object} sorts
   * @param {String} [prefix]
   * @return {Array}
   * @private
   */
  Schema.prototype._parseSort = function(sorts, prefix_) {
    var prefix = prefix_ || '';
    var paths = this.paths;
    var stack = [];
    var keys = Object.keys(sorts);
    var key, sort, name;
  
    for (var i = 0, len = keys.length; i < len; i++) {
      key = keys[i];
      sort = sorts[key];
      name = prefix + key;
  
      if (typeof sort === 'object') {
        stack = stack.concat(this._parseSort(sort, name + '.'));
      } else {
        stack.push(sortStack(paths[name], name, sort));
      }
    }
  
    return stack;
  };
  
  /**
   * Returns a function for sorting.
   *
   * @param {Object} sorts
   * @return {Function}
   * @private
   */
  Schema.prototype._execSort = function(sorts) {
    var stack = this._parseSort(sorts);
    return execSortStack(stack);
  };
  
  /**
   * Parses population expression and returns a stack.
   *
   * @param {String|Object} expr
   * @return {Array}
   * @private
   */
  Schema.prototype._parsePopulate = function(expr) {
    var paths = this.paths;
    var arr, i, len, item, path, key, ref;
  
    if (typeof expr === 'string') {
      var split = expr.split(' ');
      arr = [];
  
      for (i = 0, len = split.length; i < len; i++) {
        arr.push({
          path: split[i]
        });
      }
    } else if (isArray(expr)) {
      for (i = 0, len = expr.length; i < len; i++) {
        item = expr[i];
  
        if (typeof item === 'string') {
          arr.push({
            path: item
          });
        } else {
          arr.push(item);
        }
      }
    } else {
      arr = [expr];
    }
  
    for (i = 0, len = arr.length; i < len; i++) {
      item = arr[i];
      key = item.path;
  
      if (!key) {
        throw new PopulationError('path is required');
      }
  
      if (!item.model) {
        path = paths[key];
        ref = path.child ? path.child.options.ref : path.options.ref;
  
        if (ref) {
          item.model = ref;
        } else {
          throw new PopulationError('model is required');
        }
      }
    }
  
    return arr;
  };
  
  Schema.Types = Schema.prototype.Types = Types;
  
  module.exports = Schema;
  