/* global Node */
var schema = require('./schema');
var scenes = require('./scene/scenes');
var systems = require('./system');
var utils = require('../utils/');

var components = module.exports.components = {};  // Keep track of registered components.
var parseProperties = schema.parseProperties;
var parseProperty = schema.parseProperty;
var processSchema = schema.process;
var isSingleProp = schema.isSingleProperty;
var stringifyProperties = schema.stringifyProperties;
var stringifyProperty = schema.stringifyProperty;
var styleParser = utils.styleParser;
var warn = utils.debug('core:component:warn');

var aframeScript = document.currentScript;
var upperCaseRegExp = new RegExp('[A-Z]+');

// Object pools by component, created upon registration.
var objectPools = {};

/**
 * Component class definition.
 *
 * Components configure appearance, modify behavior, or add functionality to
 * entities. The behavior and appearance of an entity can be changed at runtime
 * by adding, removing, or updating components. Entities do not share instances
 * of components.
 *
 * @member {object} el - Reference to the entity element.
 * @member {string} attrValue - Value of the corresponding HTML attribute.
 * @member {object} data - Component data populated by parsing the
 *         mapped attribute of the component plus applying defaults and mixins.
 */
var Component = module.exports.Component = function (el, attrValue, id) {
  var self = this;
  this.el = el;
  this.id = id;
  this.attrName = this.name + (id ? '__' + id : '');
  this.evtDetail = {id: this.id, name: this.name};
  this.initialized = false;
  this.isSingleProperty = isSingleProp(this.schema);
  this.isSinglePropertyObject = this.isSingleProperty &&
                                isObject(parseProperty(undefined, this.schema)) &&
                                !(this.schema.default instanceof window.HTMLElement);
  this.isObjectBased = !this.isSingleProperty || this.isSinglePropertyObject;
  this.el.components[this.attrName] = this;
  this.objectPool = objectPools[this.name];

  eventsBind(this, this.events);

  // Store component data from previous update call.
  this.attrValue = undefined;
  if (this.isObjectBased) {
    this.nextData = this.objectPool.use();
    // Drop any properties added by dynamic schemas in previous use
    utils.objectPool.removeUnusedKeys(this.nextData, this.schema);
    this.oldData = this.objectPool.use();
    utils.objectPool.removeUnusedKeys(this.oldData, this.schema);
    this.previousOldData = this.objectPool.use();
    utils.objectPool.removeUnusedKeys(this.previousOldData, this.schema);
    this.parsingAttrValue = this.objectPool.use();
    utils.objectPool.removeUnusedKeys(this.parsingAttrValue, this.schema);
  } else {
    this.nextData = undefined;
    this.oldData = undefined;
    this.previousOldData = undefined;
    this.parsingAttrValue = undefined;
  }

  // Last value passed to updateProperties.
  this.throttledEmitComponentChanged = utils.throttle(function emitChange () {
    el.emit('componentchanged', self.evtDetail, false);
  }, 200);
  this.updateProperties(attrValue);
};

Component.prototype = {
  /**
   * Contains the type schema and defaults for the data values.
   * Data is coerced into the types of the values of the defaults.
   */
  schema: {},

  /**
   * Init handler. Similar to attachedCallback.
   * Called during component initialization and is only run once.
   * Components can use this to set initial state.
   */
  init: function () { /* no-op */ },

  /**
   * Map of event names to binded event handlers that will be lifecycle-handled.
   * Will be detached on pause / remove.
   * Will be attached on play.
   */
  events: {},

  /**
   * Update handler. Similar to attributeChangedCallback.
   * Called whenever component's data changes.
   * Also called on component initialization when the component receives initial data.
   *
   * @param {object} prevData - Previous attributes of the component.
   */
  update: function (prevData) { /* no-op */ },

  updateSchema: undefined,

  /**
   * Tick handler.
   * Called on each tick of the scene render loop.
   * Affected by play and pause.
   *
   * @param {number} time - Scene tick time.
   * @param {number} timeDelta - Difference in current render time and previous render time.
   */
  tick: undefined,

  /**
   * Tock handler.
   * Called on each tock of the scene render loop.
   * Affected by play and pause.
   *
   * @param {number} time - Scene tick time.
   * @param {number} timeDelta - Difference in current render time and previous render time.
   * @param {object} camera - Camera used to render the last frame.
   */
  tock: undefined,

  /**
   * Called to start any dynamic behavior (e.g., animation, AI, events, physics).
   */
  play: function () { /* no-op */ },

  /**
   * Called to stop any dynamic behavior (e.g., animation, AI, events, physics).
   */
  pause: function () { /* no-op */ },

  /**
   * Remove handler. Similar to detachedCallback.
   * Called whenever component is removed from the entity (i.e., removeAttribute).
   * Components can use this to reset behavior on the entity.
   */
  remove: function () { /* no-op */ },

  /**
   * Parses each property based on property type.
   * If component is single-property, then parses the single property value.
   *
   * @param {string} value - HTML attribute value.
   * @param {boolean} silent - Suppress warning messages.
   * @returns {object} Component data.
   */
  parse: function (value, silent) {
    var schema = this.schema;
    if (this.isSingleProperty) { return parseProperty(value, schema); }
    return parseProperties(styleParser.parse(value), schema, true, this.name, silent);
  },

  /**
   * Stringify properties if necessary.
   *
   * Only called from `Entity.setAttribute` for properties whose parsers accept a non-string
   * value (e.g., selector, vec3 property types).
   *
   * @param {object} data - Complete component data.
   * @returns {string}
   */
  stringify: function (data) {
    var schema = this.schema;
    if (typeof data === 'string') { return data; }
    if (this.isSingleProperty) { return stringifyProperty(data, schema); }
    data = stringifyProperties(data, schema);
    return styleParser.stringify(data);
  },

  /**
   * Update the cache of the pre-parsed attribute value.
   *
   * @param {string} value - New data.
   * @param {boolean } clobber - Whether to wipe out and replace previous data.
   */
  updateCachedAttrValue: function (value, clobber) {
    var newAttrValue;
    var tempObject;
    var property;

    if (value === undefined) { return; }

    // If null value is the new attribute value, make the attribute value falsy.
    if (value === null) {
      if (this.isObjectBased && this.attrValue) {
        this.objectPool.recycle(this.attrValue);
      }
      this.attrValue = undefined;
      return;
    }

    if (value instanceof Object && !(value instanceof window.HTMLElement)) {
      // If value is an object, copy it to our pooled newAttrValue object to use to update
      // the attrValue.
      tempObject = this.objectPool.use();
      newAttrValue = utils.extend(tempObject, value);
    } else {
      newAttrValue = this.parseAttrValueForCache(value);
    }

    // Merge new data with previous `attrValue` if updating and not clobbering.
    if (this.isObjectBased && !clobber && this.attrValue) {
      for (property in this.attrValue) {
        if (newAttrValue[property] === undefined) {
          newAttrValue[property] = this.attrValue[property];
        }
      }
    }

    // Update attrValue.
    if (this.isObjectBased && !this.attrValue) {
      this.attrValue = this.objectPool.use();
    }
    utils.objectPool.clearObject(this.attrValue);
    this.attrValue = extendProperties(this.attrValue, newAttrValue, this.isObjectBased);
    utils.objectPool.clearObject(tempObject);
  },

  /**
   * Given an HTML attribute value parses the string based on the component schema.
   * To avoid double parsings of strings into strings we store the original instead
   * of the parsed one
   *
   * @param {string} value - HTML attribute value
   */
  parseAttrValueForCache: function (value) {
    var parsedValue;
    if (typeof value !== 'string') { return value; }
    if (this.isSingleProperty) {
      parsedValue = this.schema.parse(value);
      /**
       * To avoid bogus double parsings. Cached values will be parsed when building
       * component data. For instance when parsing a src id to its url, we want to cache
       * original string and not the parsed one (#monster -> models/monster.dae)
       * so when building data we parse the expected value.
       */
      if (typeof parsedValue === 'string') { parsedValue = value; }
    } else {
      // Parse using the style parser to avoid double parsing of individual properties.
      utils.objectPool.clearObject(this.parsingAttrValue);
      parsedValue = styleParser.parse(value, this.parsingAttrValue);
    }
    return parsedValue;
  },

  /**
   * Write cached attribute data to the entity DOM element.
   *
   * @param {boolean} isDefault - Whether component is a default component. Always flush for
   *   default components.
   */
  flushToDOM: function (isDefault) {
    var attrValue = isDefault ? this.data : this.attrValue;
    if (attrValue === null || attrValue === undefined) { return; }
    window.HTMLElement.prototype.setAttribute.call(this.el, this.attrName,
                                                   this.stringify(attrValue));
  },

  /**
   * Apply new component data if data has changed (from setAttribute).
   *
   * @param {string} attrValue - HTML attribute value.
   *        If undefined, use the cached attribute value and continue updating properties.
   * @param {boolean} clobber - The previous component data is overwritten by the atrrValue
   */
  updateProperties: function (attrValue, clobber) {
    var el = this.el;

    // Just cache the attribute if the entity has not loaded
    // Components are not initialized until the entity has loaded
    if (!el.hasLoaded) {
      this.updateCachedAttrValue(attrValue);
      return;
    }

    // Parse the attribute value.
    // Cache current attrValue for future updates. Updates `this.attrValue`.
    // `null` means no value on purpose, do not set a default value, let mixins take over.
    if (attrValue !== null) {
      attrValue = this.parseAttrValueForCache(attrValue);
    }

    // Cache current attrValue for future updates.
    this.updateCachedAttrValue(attrValue, clobber);

    if (this.initialized) {
      this.updateComponent(attrValue, clobber);
      this.callUpdateHandler();
    } else {
      this.initComponent();
    }
  },

  initComponent: function () {
    var el = this.el;
    var initialOldData;

    // Build data.
    if (this.updateSchema) { this.updateSchema(this.buildData(this.attrValue, false, true)); }
    this.data = this.buildData(this.attrValue);

    // Component is being already initialized.
    if (el.initializingComponents[this.name]) { return; }

    // Prevent infinite loop in case of init method setting same component on the entity.
    el.initializingComponents[this.name] = true;
    // Initialize component.
    this.init();
    this.initialized = true;
    delete el.initializingComponents[this.name];

    // Store current data as previous data for future updates.
    this.oldData = extendProperties(this.oldData, this.data, this.isObjectBased);

    // For oldData, pass empty object to multiple-prop schemas or object single-prop schema.
    // Pass undefined to rest of types.
    initialOldData = this.isObjectBased ? this.objectPool.use() : undefined;
    this.update(initialOldData);
    if (this.isObjectBased) { this.objectPool.recycle(initialOldData); }

    // Play the component if the entity is playing.
    if (el.isPlaying) { this.play(); }
    el.emit('componentinitialized', this.evtDetail, false);
  },

  /**
   * @param attrValue - Passed argument from setAttribute.
   */
  updateComponent: function (attrValue, clobber) {
    var key;
    var mayNeedSchemaUpdate;

    if (clobber) {
      // Clobber. Rebuild.
      if (this.updateSchema) {
        this.updateSchema(this.buildData(this.attrValue, true, true));
      }
      this.data = this.buildData(this.attrValue, true, false);
      return;
    }

    // Apply new value to this.data in place since direct update.
    if (this.isSingleProperty) {
      if (this.isObjectBased) {
        parseProperty(attrValue, this.schema);
      }
      // Single-property (already parsed).
      this.data = attrValue;
      return;
    }

    parseProperties(attrValue, this.schema, true, this.name);

    // Check if we need to update schema.
    if (this.schemaChangeKeys.length) {
      for (key in attrValue) {
        if (this.schema[key].schemaChange) {
          mayNeedSchemaUpdate = true;
          break;
        }
      }
    }
    if (mayNeedSchemaUpdate) {
      // Rebuild data if need schema update.
      if (this.updateSchema) {
        this.updateSchema(this.buildData(this.attrValue, true, true));
      }
      this.data = this.buildData(this.attrValue, true, false);
      return;
    }

    // Normal update.
    for (key in attrValue) {
      if (attrValue[key] === undefined) { continue; }
      this.data[key] = attrValue[key];
    }
  },

  /**
   * Check if component should fire update and fire update lifecycle handler.
   */
  callUpdateHandler: function () {
    var hasComponentChanged;

    // Store the previous old data before we calculate the new oldData.
    if (this.previousOldData instanceof Object) {
      utils.objectPool.clearObject(this.previousOldData);
    }
    if (this.isObjectBased) {
      copyData(this.previousOldData, this.oldData);
    } else {
      this.previousOldData = this.oldData;
    }

    hasComponentChanged = !utils.deepEqual(this.oldData, this.data);

    // Don't update if properties haven't changed.
    // Always update rotation, position, scale.
    if (!this.isPositionRotationScale && !hasComponentChanged) { return; }

    // Store current data as previous data for future updates.
    // Reuse `this.oldData` object to try not to allocate another one.
    if (this.oldData instanceof Object) { utils.objectPool.clearObject(this.oldData); }
    this.oldData = extendProperties(this.oldData, this.data, this.isObjectBased);

    // Update component with the previous old data.
    this.update(this.previousOldData);

    this.throttledEmitComponentChanged();
  },

  handleMixinUpdate: function () {
    this.data = this.buildData(this.attrValue);
    this.callUpdateHandler();
  },

  /**
   * Reset value of a property to the property's default value.
   * If single-prop component, reset value to component's default value.
   *
   * @param {string} propertyName - Name of property to reset.
   */
  resetProperty: function (propertyName) {
    if (this.isObjectBased) {
      if (!(propertyName in this.attrValue)) { return; }
      delete this.attrValue[propertyName];
      this.data[propertyName] = this.schema[propertyName].default;
    } else {
      this.attrValue = this.schema.default;
      this.data = this.schema.default;
    }
    this.updateProperties(this.attrValue);
  },

  /**
   * Extend schema of component given a partial schema.
   *
   * Some components might want to mutate their schema based on certain properties.
   * e.g., Material component changes its schema based on `shader` to account for different
   * uniforms
   *
   * @param {object} schemaAddon - Schema chunk that extend base schema.
   */
  extendSchema: function (schemaAddon) {
    var extendedSchema;
    // Clone base schema.
    extendedSchema = utils.extend({}, components[this.name].schema);
    // Extend base schema with new schema chunk.
    utils.extend(extendedSchema, schemaAddon);
    this.schema = processSchema(extendedSchema);
    this.el.emit('schemachanged', this.evtDetail);
  },

  /**
   * Build component data from the current state of the entity.data.
   *
   * Precedence:
   * 1. Defaults data
   * 2. Mixin data.
   * 3. Attribute data.
   *
   * Finally coerce the data to the types of the defaults.
   *
   * @param {object} newData - Element new data.
   * @param {boolean} clobber - The previous data is completely replaced by the new one.
   * @param {boolean} silent - Suppress warning messages.
   * @return {object} The component data
   */
  buildData: function (newData, clobber, silent) {
    var componentDefined;
    var data;
    var defaultValue;
    var key;
    var mixinData;
    var nextData = this.nextData;
    var schema = this.schema;
    var i;
    var mixinEls = this.el.mixinEls;
    var previousData;

    // Whether component has a defined value. For arrays, treat empty as not defined.
    componentDefined = newData && newData.constructor === Array
      ? newData.length
      : newData !== undefined && newData !== null;

    if (this.isObjectBased) { utils.objectPool.clearObject(nextData); }

    // 1. Gather default values (lowest precendence).
    if (this.isSingleProperty) {
      if (this.isObjectBased) {
        // If object-based single-prop, then copy over the data to our pooled object.
        data = copyData(nextData, schema.default);
      } else {
        // If is plain single-prop, copy by value the default.
        data = isObjectOrArray(schema.default)
          ? utils.clone(schema.default)
          : schema.default;
      }
    } else {
      // Preserve previously set properties if clobber not enabled.
      previousData = !clobber && this.attrValue;

      // Clone default value if object so components don't share object
      data = previousData instanceof Object
        ? copyData(nextData, previousData)
        : nextData;

      // Apply defaults.
      for (key in schema) {
        defaultValue = schema[key].default;
        if (data[key] !== undefined) { continue; }
        // Clone default value if object so components don't share object
        data[key] = isObjectOrArray(defaultValue)
          ? utils.clone(defaultValue)
          : defaultValue;
      }
    }

    // 2. Gather mixin values.
    for (i = 0; i < mixinEls.length; i++) {
      mixinData = mixinEls[i].getAttribute(this.attrName);
      if (!mixinData) { continue; }
      data = extendProperties(data, mixinData, this.isObjectBased);
    }

    // 3. Gather attribute values (highest precendence).
    if (componentDefined) {
      if (this.isSingleProperty) {
        // If object-based, copy the value to not modify the original.
        if (isObject(newData)) {
          copyData(this.parsingAttrValue, newData);
          return parseProperty(this.parsingAttrValue, schema);
        }
        return parseProperty(newData, schema);
      }
      data = extendProperties(data, newData, this.isObjectBased);
    } else {
      // Parse and coerce using the schema.
      if (this.isSingleProperty) { return parseProperty(data, schema); }
    }

    return parseProperties(data, schema, undefined, this.name, silent);
  },

  /**
   * Attach events from component-defined events map.
   */
  eventsAttach: function () {
    var eventName;
    // Safety detach to prevent double-registration.
    this.eventsDetach();
    for (eventName in this.events) {
      this.el.addEventListener(eventName, this.events[eventName]);
    }
  },

  /**
   * Detach events from component-defined events map.
   */
  eventsDetach: function () {
    var eventName;
    for (eventName in this.events) {
      this.el.removeEventListener(eventName, this.events[eventName]);
    }
  },

  /**
   * Release and free memory.
   */
  destroy: function () {
    this.objectPool.recycle(this.attrValue);
    this.objectPool.recycle(this.oldData);
    this.objectPool.recycle(this.parsingAttrValue);
    this.attrValue = this.oldData = this.parsingAttrValue = undefined;
  }
};

function eventsBind (component, events) {
  var eventName;
  for (eventName in events) {
    events[eventName] = events[eventName].bind(component);
  }
}

// For testing.
if (window.debug) {
  var registrationOrderWarnings = module.exports.registrationOrderWarnings = {};
}

/**
 * Register a component to A-Frame.
 *
 * @param {string} name - Component name.
 * @param {object} definition - Component schema and lifecycle method handlers.
 * @returns {object} Component.
 */
module.exports.registerComponent = function (name, definition) {
  var NewComponent;
  var propertyName;
  var proto = {};
  var schema;
  var schemaIsSingleProp;

  // Warning if component is statically registered after the scene.
  if (document.currentScript && document.currentScript !== aframeScript) {
    scenes.forEach(function checkPosition (sceneEl) {
      // Okay to register component after the scene at runtime.
      if (sceneEl.hasLoaded) { return; }

      // Check that component is declared before the scene.
      if (document.currentScript.compareDocumentPosition(sceneEl) ===
          Node.DOCUMENT_POSITION_FOLLOWING) { return; }

      warn('The component `' + name + '` was registered in a <script> tag after the scene. ' +
           'Component <script> tags in an HTML file should be declared *before* the scene ' +
           'such that the component is available to entities during scene initialization.');

      // For testing.
      if (window.debug) { registrationOrderWarnings[name] = true; }
    });
  }

  if (upperCaseRegExp.test(name) === true) {
    warn('The component name `' + name + '` contains uppercase characters, but ' +
         'HTML will ignore the capitalization of attribute names. ' +
         'Change the name to be lowercase: `' + name.toLowerCase() + '`');
  }

  if (name.indexOf('__') !== -1) {
    throw new Error('The component name `' + name + '` is not allowed. ' +
                    'The sequence __ (double underscore) is reserved to specify an id' +
                    ' for multiple components of the same type');
  }

  // Format definition object to prototype object.
  Object.keys(definition).forEach(function (key) {
    proto[key] = {
      value: definition[key],
      writable: true
    };
  });

  if (components[name]) {
    throw new Error('The component `' + name + '` has been already registered. ' +
                    'Check that you are not loading two versions of the same component ' +
                    'or two different components of the same name.');
  }

  NewComponent = function (el, attr, id) {
    Component.call(this, el, attr, id);
  };

  NewComponent.prototype = Object.create(Component.prototype, proto);
  NewComponent.prototype.name = name;
  NewComponent.prototype.isPositionRotationScale =
    name === 'position' || name === 'rotation' || name === 'scale';
  NewComponent.prototype.constructor = NewComponent;
  NewComponent.prototype.system = systems && systems.systems[name];
  NewComponent.prototype.play = wrapPlay(NewComponent.prototype.play);
  NewComponent.prototype.pause = wrapPause(NewComponent.prototype.pause);

  schema = utils.extend(processSchema(NewComponent.prototype.schema,
                                      NewComponent.prototype.name));
  schemaIsSingleProp = isSingleProp(NewComponent.prototype.schema);

  // Keep track of keys that may potentially change the schema.
  if (!schemaIsSingleProp) {
    NewComponent.prototype.schemaChangeKeys = [];
    for (propertyName in schema) {
      if (schema[propertyName].schemaChange) {
        NewComponent.prototype.schemaChangeKeys.push(propertyName);
      }
    }
  }

  // Create object pool for class of components.
  objectPools[name] = utils.objectPool.createPool();

  components[name] = {
    Component: NewComponent,
    dependencies: NewComponent.prototype.dependencies,
    isSingleProp: schemaIsSingleProp,
    multiple: NewComponent.prototype.multiple,
    name: name,
    parse: NewComponent.prototype.parse,
    parseAttrValueForCache: NewComponent.prototype.parseAttrValueForCache,
    schema: schema,
    stringify: NewComponent.prototype.stringify,
    type: NewComponent.prototype.type
  };
  return NewComponent;
};

/**
* Clone component data.
* Clone only the properties that are plain objects while keeping a reference for the rest.
*
* @param data - Component data to clone.
* @returns Cloned data.
*/
function copyData (dest, sourceData) {
  var parsedProperty;
  var key;
  for (key in sourceData) {
    if (sourceData[key] === undefined) { continue; }
    parsedProperty = sourceData[key];
    dest[key] = isObjectOrArray(parsedProperty)
      ? utils.clone(parsedProperty)
      : parsedProperty;
  }
  return dest;
}

/**
* Object extending with checking for single-property schema.
*
* @param dest - Destination object or value.
* @param source - Source object or value
* @param {boolean} isObjectBased - Whether values are objects.
* @returns Overridden object or value.
*/
function extendProperties (dest, source, isObjectBased) {
  var key;
  if (isObjectBased && source.constructor === Object) {
    for (key in source) {
      if (source[key] === undefined) { continue; }
      if (source[key] && source[key].constructor === Object) {
        dest[key] = utils.clone(source[key]);
      } else {
        dest[key] = source[key];
      }
    }
    return dest;
  }
  return source;
}

/**
 * Checks if a component has defined a method that needs to run every frame.
 */
function hasBehavior (component) {
  return component.tick || component.tock;
}

/**
 * Wrapper for defined pause method.
 * Pause component by removing tick behavior and calling user's pause method.
 *
 * @param pauseMethod {function}
 */
function wrapPause (pauseMethod) {
  return function pause () {
    var sceneEl = this.el.sceneEl;
    if (!this.isPlaying) { return; }
    pauseMethod.call(this);
    this.isPlaying = false;
    this.eventsDetach();
    // Remove tick behavior.
    if (!hasBehavior(this)) { return; }
    sceneEl.removeBehavior(this);
  };
}

/**
 * Wrapper for defined play method.
 * Play component by adding tick behavior and calling user's play method.
 *
 * @param playMethod {function}
 */
function wrapPlay (playMethod) {
  return function play () {
    var sceneEl = this.el.sceneEl;
    var shouldPlay = this.el.isPlaying && !this.isPlaying;
    if (!this.initialized || !shouldPlay) { return; }
    playMethod.call(this);
    this.isPlaying = true;
    this.eventsAttach();
    // Add tick behavior.
    if (!hasBehavior(this)) { return; }
    sceneEl.addBehavior(this);
  };
}

function isObject (value) {
  return value && value.constructor === Object && !(value instanceof window.HTMLElement);
}

function isObjectOrArray (value) {
  return value && (value.constructor === Object || value.constructor === Array) &&
         !(value instanceof window.HTMLElement);
}
