/*!
 * Module exports class Model
 */
module.exports = ModelBaseClass;

/*!
 * Module dependencies
 */

var util = require('util');
var jutil = require('./jutil');
var List = require('./list');
var Hookable = require('./hooks');
var validations = require('./validations.js');

// Set up an object for quick lookup
var BASE_TYPES = {
  'String': true,
  'Boolean': true,
  'Number': true,
  'Date': true,
  'Text': true,
  'ObjectID': true
};

/**
 * Model class: base class for all persistent objects.
 *
 * `ModelBaseClass` mixes `Validatable` and `Hookable` classes methods
 *
 * @class
 * @param {Object} data Initial object data
 */
function ModelBaseClass(data, options) {
  options = options || {};
  if(!('applySetters' in options)) {
    // Default to true
    options.applySetters = true;
  }
  this._initProperties(data, options);
}

/**
 * Initialize the model instance with a list of properties
 * @param {Object} data The data object
 * @param {Object} options An object to control the instantiation
 * @property {Boolean} applySetters Controls if the setters will be applied
 * @property {Boolean} strict Set the instance level strict mode
 * @private
 */
ModelBaseClass.prototype._initProperties = function (data, options) {
  var self = this;
  var ctor = this.constructor;

  if(data instanceof ctor) {
    // Convert the data to be plain object to avoid pollutions
    data = data.toObject(false);
  }
  var properties = ctor.definition.properties;
  data = data || {};

  options = options || {};
  var applySetters = options.applySetters;
  var strict = options.strict;

  if(strict === undefined) {
    strict = ctor.definition.settings.strict;
  }

  if (ctor.hideInternalProperties) {
    // Object.defineProperty() is expensive. We only try to make the internal
    // properties hidden (non-enumerable) if the model class has the
    // `hideInternalProperties` set to true
    Object.defineProperties(this, {
      __cachedRelations: {
        writable: true,
        enumerable: false,
        configurable: true,
        value: {}
      },

      __data: {
        writable: true,
        enumerable: false,
        configurable: true,
        value: {}
      },

      // Instance level data source
      __dataSource: {
        writable: true,
        enumerable: false,
        configurable: true,
        value: options.dataSource
      },

      // Instance level strict mode
      __strict: {
        writable: true,
        enumerable: false,
        configurable: true,
        value: strict
      }
    });
  } else {
    this.__cachedRelations = {};
    this.__data = {};
    this.__dataSource = options.dataSource;
    this.__strict = strict;
  }

  if (data.__cachedRelations) {
    this.__cachedRelations = data.__cachedRelations;
  }

  var keys = Object.keys(data);
  var size = keys.length;
  var p, propVal;
  for (var k = 0; k < size; k++) {
    p = keys[k];
    propVal = data[p];
    if (typeof propVal === 'function') {
      continue;
    }
    if (properties[p]) {
      // Managed property
      if (applySetters) {
        self[p] = propVal;
      } else {
        self.__data[p] = propVal;
      }
    } else if (ctor.relations[p]) {
      // Relation
      if (ctor.relations[p].type === 'belongsTo' && propVal != null) {
        // If the related model is populated
        self.__data[ctor.relations[p].keyFrom] = propVal[ctor.relations[p].keyTo];
      }
      self.__cachedRelations[p] = propVal;
    } else {
      // Un-managed property
      if (strict === false) {
        self[p] = self.__data[p] = propVal;
      } else if (strict === 'throw') {
        throw new Error('Unknown property: ' + p);
      }
    }
  }

  keys = Object.keys(properties);
  size = keys.length;

  for (k = 0; k < size; k++) {
    p = keys[k];
    propVal = self.__data[p];

    // Set default values
    if (propVal === undefined) {
      var def = properties[p]['default'];
      if (def !== undefined) {
        if (typeof def === 'function') {
          if (def === Date) {
            // FIXME: We should coerce the value in general
            // This is a work around to {default: Date}
            // Date() will return a string instead of Date
            def = new Date();
          } else {
            def = def();
          }
        }
        // FIXME: We should coerce the value
        // will implement it after we refactor the PropertyDefinition
        self.__data[p] = def;
      }
    }

    // Handle complex types (JSON/Object)
    var type = properties[p].type;
    if (! BASE_TYPES[type.name]) {
      if (typeof self.__data[p] !== 'object' && self.__data[p]) {
        try {
          self.__data[p] = JSON.parse(self.__data[p] + '');
        } catch (e) {
          self.__data[p] = String(self.__data[p]);
        }
      }
      if (type.name === 'Array' || Array.isArray(type)) {
        if (!(self.__data[p] instanceof List)
          && self.__data[p] !== undefined
          && self.__data[p] !== null ) {
          self.__data[p] = List(self.__data[p], type, self);
        }
      }
    }
  }
  this.trigger('initialize');
};

/**
 * Define a property on the model.
 * @param {String} prop Property name
 * @param {Object} params Various property configuration
 */
ModelBaseClass.defineProperty = function (prop, params) {
  this.dataSource.defineProperty(this.modelName, prop, params);
};

ModelBaseClass.getPropertyType = function (propName) {
  var prop = this.definition.properties[propName];
  if (!prop) {
    // The property is not part of the definition
    return null;
  }
  if (!prop.type) {
    throw new Error('Type not defined for property ' + this.modelName + '.' + propName);
    // return null;
  }
  return prop.type.name;
};

ModelBaseClass.prototype.getPropertyType = function (propName) {
  return this.constructor.getPropertyType(propName);
};

/**
 * Return string representation of class
 * This overrides the default `toString()` method
 */
ModelBaseClass.toString = function () {
  return '[Model ' + this.modelName + ']';
};

/**
 * Convert model instance to a plain JSON object.
 * Returns a canonical object representation (no getters and setters).
 *
 * @param {Boolean} onlySchema Restrict properties to dataSource only.  Default is false.  If true, the function returns only properties defined in the schema;  Otherwise it returns all enumerable properties.
 */
ModelBaseClass.prototype.toObject = function (onlySchema, removeHidden) {
  if (onlySchema === undefined) {
    onlySchema = true;
  }
  var data = {};
  var self = this;
  var Model = this.constructor;

  // if it is already an Object
  if (Model === Object) {
    return self;
  }

  var strict = this.__strict;
  var schemaLess = (strict === false) || !onlySchema;

  var props = Model.definition.properties;
  var keys = Object.keys(props);
  var propertyName, val;
  for (var i = 0; i < keys.length; i++) {
    propertyName = keys[i];
    val = self[propertyName];

    // Exclude functions
    if (typeof val === 'function') {
      continue;
    }
    // Exclude hidden properties
    if (removeHidden && Model.isHiddenProperty(propertyName)) {
      continue;
    }

    if (val instanceof List) {
      data[propertyName] = val.toObject(!schemaLess, removeHidden);
    } else {
      if (val !== undefined && val !== null && val.toObject) {
        data[propertyName] = val.toObject(!schemaLess, removeHidden);
      } else {
        data[propertyName] = val;
      }
    }
  }

  if (schemaLess) {
    // Find its own properties which can be set via myModel.myProperty = 'myValue'.
    // If the property is not declared in the model definition, no setter will be
    // triggered to add it to __data
    keys = Object.keys(self);
    var size = keys.length;
    for (i = 0; i < size; i++) {
      propertyName = keys[i];
      if (props[propertyName]) {
        continue;
      }
      if (propertyName.indexOf('__') === 0) {
        continue;
      }
      if (removeHidden && Model.isHiddenProperty(propertyName)) {
        continue;
      }
      val = self[propertyName];
      if (val !== undefined && data[propertyName] === undefined) {
        if (typeof val === 'function') {
          continue;
        }
        if (val !== null && val.toObject) {
          data[propertyName] = val.toObject(!schemaLess, removeHidden);
        } else {
          data[propertyName] = val;
        }
      }
    }
    // Now continue to check __data
    keys = Object.keys(self.__data);
    size = keys.length;
    for (i = 0; i < size; i++) {
      propertyName = keys[i];
      if (propertyName.indexOf('__') === 0) {
        continue;
      }
      if (data[propertyName] === undefined) {
        if (removeHidden && Model.isHiddenProperty(propertyName)) {
          continue;
        }
        var ownVal = self[propertyName];
        // The ownVal can be a relation function
        val = (ownVal !== undefined && (typeof ownVal !== 'function'))
          ? ownVal : self.__data[propertyName];
        if (typeof val === 'function') {
          continue;
        }

        if (val !== undefined && val !== null && val.toObject) {
          data[propertyName] = val.toObject(!schemaLess, removeHidden);
        } else {
          data[propertyName] = val;
        }
      }
    }
  }

  return data;
};

ModelBaseClass.isHiddenProperty = function (propertyName) {
  var Model = this;
  var settings = Model.definition && Model.definition.settings;
  var hiddenProperties = settings && (settings.hiddenProperties || settings.hidden);
  if (Array.isArray(hiddenProperties)) {
    // Cache the hidden properties as an object for quick lookup
    settings.hiddenProperties = {};
    for (var i = 0; i < hiddenProperties.length; i++) {
      settings.hiddenProperties[hiddenProperties[i]] = true;
    }
    hiddenProperties = settings.hiddenProperties;
  }
  if (hiddenProperties) {
    return hiddenProperties[propertyName];
  } else {
    return false;
  }
}

ModelBaseClass.prototype.toJSON = function () {
  return this.toObject(false, true);
};

ModelBaseClass.prototype.fromObject = function (obj) {
  for (var key in obj) {
    this[key] = obj[key];
  }
};

/**
 * Reset dirty attributes.
 * This method does not perform any database operations; it just resets the object to its
 * initial state.
 */
ModelBaseClass.prototype.reset = function () {
  var obj = this;
  for (var k in obj) {
    if (k !== 'id' && !obj.constructor.dataSource.definitions[obj.constructor.modelName].properties[k]) {
      delete obj[k];
    }
  }
};

ModelBaseClass.prototype.inspect = function () {
  return util.inspect(this.__data, false, 4, true);
};

ModelBaseClass.mixin = function (anotherClass, options) {
  return jutil.mixin(this, anotherClass, options);
};

ModelBaseClass.prototype.getDataSource = function () {
  return this.__dataSource || this.constructor.dataSource;
};

ModelBaseClass.getDataSource = function () {
  return this.dataSource;
};

ModelBaseClass.prototype.setStrict = function (strict) {
  this.__strict = strict;
};

jutil.mixin(ModelBaseClass, Hookable);
jutil.mixin(ModelBaseClass, validations.Validatable);