define(
  [
    './utils',
    './registry',
    './debug'
  ],
  function(utils, registry, debug) {
  //common mixin allocates basic functionality - used by all component prototypes
  //callback context is bound to component
  var componentId = 0;

  function teardownInstance(instanceInfo){
    instanceInfo.events.slice().forEach(function(event) {
      var args = [event.type];

      event.element && args.unshift(event.element);
      (typeof event.callback == 'function') && args.push(event.callback);

      this.off.apply(this, args);
    }, instanceInfo.instance);
  }

  function checkSerializable(type, data) {
    try {
      window.postMessage(data, '*');
    } catch(e) {
      console.log('unserializable data for event',type,':',data);
      throw new Error(
        ["The event", type, "on component", this.toString(), "was triggered with non-serializable data"].join(" ")
      );
    }
  }

  function withBase() {

    // delegate trigger, bind and unbind to an element
    // if $element not supplied, use component's node
    // other arguments are passed on
    // event can be either a string specifying the type
    // of the event, or a hash specifying both the type
    // and a default function to be called.
    this.trigger = function() {
      var $element, type, data, event, defaultFn;
      var lastIndex = arguments.length - 1, lastArg = arguments[lastIndex];

      if (typeof lastArg != "string" && !(lastArg && lastArg.defaultBehavior)) {
        lastIndex--;
        data = lastArg;
      }

      if (lastIndex == 1) {
        $element = $(arguments[0]);
        event = arguments[1];
      } else {
        $element = this.$node;
        event = arguments[0];
      }

      if (event.defaultBehavior) {
        defaultFn = event.defaultBehavior;
        event = $.Event(event.type);
      }

      type = event.type || event;

      if (debug.enabled && window.postMessage) {
        checkSerializable.call(this, type, data);
      }

      if (typeof this.attr.eventData === 'object') {
        data = $.extend(true, {}, this.attr.eventData, data);
      }

      $element.trigger((event || type), data);

      if (defaultFn && !event.isDefaultPrevented()) {
        (this[defaultFn] || defaultFn).call(this);
      }

      return $element;
    };

    this.on = function() {
      var $element, type, callback, originalCb;
      var lastIndex = arguments.length - 1, origin = arguments[lastIndex];

      if (typeof origin == "object") {
        //delegate callback
        originalCb = utils.delegate(
          this.resolveDelegateRules(origin)
        );
      } else {
        originalCb = origin;
      }

      if (lastIndex == 2) {
        $element = $(arguments[0]);
        type = arguments[1];
      } else {
        $element = this.$node;
        type = arguments[0];
      }

      if (typeof originalCb != 'function' && typeof originalCb != 'object') {
        throw new Error("Unable to bind to '" + type + "' because the given callback is not a function or an object");
      }

      callback = originalCb.bind(this);
      callback.target = originalCb;

      // if the original callback is already branded by jQuery's guid, copy it to the context-bound version
      if (originalCb.guid) {
        callback.guid = originalCb.guid;
      }

      $element.on(type, callback);

      // get jquery's guid from our bound fn, so unbinding will work
      originalCb.guid = callback.guid;

      return callback;
    };

    this.off = function() {
      var $element, type, callback;
      var lastIndex = arguments.length - 1;

      if (typeof arguments[lastIndex] == "function") {
        callback = arguments[lastIndex];
        lastIndex -= 1;
      }

      if (lastIndex == 1) {
        $element = $(arguments[0]);
        type = arguments[1];
      } else {
        $element = this.$node;
        type = arguments[0];
      }

      return $element.off(type, callback);
    };

    this.resolveDelegateRules = function(ruleInfo) {
      var rules = {};

      Object.keys(ruleInfo).forEach(function(r) {
        if (!(r in this.attr)) {
          throw new Error('Component "' + this.toString() + '" wants to listen on "' + r + '" but no such attribute was defined.');
        }
        rules[this.attr[r]] = ruleInfo[r];
      }, this);

      return rules;
    };

    this.defaultAttrs = function(defaults) {
      utils.push(this.defaults, defaults, true) || (this.defaults = defaults);
    };

    this.select = function(attributeKey) {
      return this.$node.find(this.attr[attributeKey]);
    };

    this.initialize = function(node, attrs) {
      attrs = attrs || {};
      this.identity = componentId++;

      if (!node) {
        throw new Error("Component needs a node");
      }

      if (node.jquery) {
        this.node = node[0];
        this.$node = node;
      } else {
        this.node = node;
        this.$node = $(node);
      }

      //merge defaults with supplied options
      //put options in attr.__proto__ to avoid merge overhead
      var attr = Object.create(attrs);
      for (var key in this.defaults) {
        if (!attrs.hasOwnProperty(key)) {
          attr[key] = this.defaults[key];
        }
      }

      this.attr = attr;

      Object.keys(this.defaults || {}).forEach(function(key) {
        if (this.defaults[key] === null && this.attr[key] === null) {
          throw new Error('Required attribute "' + key + '" not specified in attachTo for component "' + this.toString() + '".');
        }
      }, this);

      return this;
    }

    this.teardown = function() {
      teardownInstance(registry.findInstanceInfo(this));
    };
  }

  return withBase;
});
