(function (root, factory) {
    if (typeof exports === 'object') {
      module.exports = factory();
    } else if (typeof define === 'function' && define.amd) {
      define([], factory);
    } else {
      root.Draggable = factory();
    }
}(this, function () {

  'use strict';

  var defaults = {

    // settings
    grid: 0,                // grid cell size for snapping to on drag
    filterTarget: null,     // disallow drag when target passes this test
    limit: {                // limit the drag bounds
      x: null,              // [minimum position, maximum position] || position
      y: null               // [minimum position, maximum position] || position
    },
    threshold: 0,           // threshold to move before drag begins (in px)

    // flags
    setCursor: false,       // change cursor to reflect draggable?
    setPosition: true,      // change draggable position to absolute?
    smoothDrag: true,       // snap to grid when dropped, but not during
    useGPU: true,           // move graphics calculation/composition to the GPU

    // event hooks
    onDrag: noop,           // function(element, X, Y, event)
    onDragStart: noop,      // function(element, X, Y, event)
    onDragEnd: noop         // function(element, X, Y, event)

  };

  var env = {

    // CSS vendor-prefixed transform property
    transform: (function(){

      var prefixes = ' -o- -ms- -moz- -webkit-'.split(' ');
      var style = document.body.style;

      for (var n = prefixes.length; n--;) {
        var property = prefixes[n] + 'transform';
        if (property in style) {
          return property;
        }
      }

    })()

  };

  var util = {

    assign: function () {

      var obj = arguments[0];
      var count = arguments.length;

      for ( var n = 1; n < count; n++ ) {
        var argument = arguments[n];
        for ( var key in argument ) {
          obj[key] = argument[key];
        }
      }

      return obj;

    },

    bind: function (fn, context) {
      return function() {
        fn.apply(context, arguments);
      }
    },

    on: function (element, e, fn) {
      if (e && fn) {
        util.addEvent (element, e, fn);
      } else if (e) {
        for (var ee in e) {
          util.addEvent (element, ee, e[ee]);
        }
      }
    },

    off: function (element, e, fn) {
      if (e && fn) {
        util.removeEvent (element, e, fn);
      } else if (e) {
        for (var ee in e) {
          util.removeEvent (element, ee, e[ee]);
        }
      }
    },

    // Example:
    //
    //     util.limit(x, limit.x)
    limit: function (n, limit) {
      // {Array} limit.x
      if (isArray(limit)) {
        limit = [+limit[0], +limit[1]];
        if (n < limit[0]) n = limit[0];
        else if (n > limit[1]) n = limit[1];
      // {Number} limit.x
      } else {
        n = +limit;
      }

      return n;
    },

    addEvent: ('attachEvent' in Element.prototype)
      ? function (element, e, fn) { element.attachEvent('on'+e, fn) }
      : function (element, e, fn) { element.addEventListener(e, fn, false) },

    removeEvent: ('attachEvent' in Element.prototype)
      ? function (element, e, fn) { element.detachEvent('on'+e, fn) }
      : function (element, e, fn) { element.removeEventListener(e, fn) }

  };

  /*
    usage:

    new Draggable (element, options)
      - or -
    new Draggable (element)
  */

  function Draggable (element, options) {

    var me = this,
      start = util.bind(me.start, me),
      drag = util.bind(me.drag, me),
      stop = util.bind(me.stop, me);

    // sanity check
    if (!isElement(element)) {
      throw new TypeError('Draggable expects argument 0 to be an Element');
    }

    options = util.assign({}, defaults, options);

    // set instance properties
    util.assign(me, {

      // DOM element
      element: element,
      handle: (options.handle && isElement(options.handle))
              ? options.handle
              : element,

      // DOM event handlers
      handlers: {
        start: {
          mousedown: start,
          touchstart: start
        },
        move: {
          mousemove: drag,
          mouseup: stop,
          touchmove: drag,
          touchend: stop
        }
      },

      // options
      options: options

    });

    // initialize
    me.initialize();

  }

  util.assign (Draggable.prototype, {

    // public

    setOption: function (property, value) {

      var me = this;

      me.options[property] = value;
      me.initialize();

      return me;

    },

    get: function() {

      var dragEvent = this.dragEvent;

      return {
        x: dragEvent.x,
        y: dragEvent.y
      };

    },

    set: function (x, y) {

      var me = this,
        dragEvent = me.dragEvent;

      dragEvent.original = {
        x: dragEvent.x,
        y: dragEvent.y
      };

      me.move(x, y);

      return me;

    },

    // internal

    dragEvent: {
      started: false,
      x: 0,
      y: 0
    },

    initialize: function() {

      var me = this,
        element = me.element,
        handle = me.handle,
        style = element.style,
        compStyle = getStyle(element),
        options = me.options,
        transform = env.transform,
        oldTransform;

      // cache element dimensions (for performance)

      var _dimensions = me._dimensions = {
        height: element.offsetHeight,
        left: element.offsetLeft,
        top: element.offsetTop,
        width: element.offsetWidth
      };

      // shift compositing over to the GPU if the browser supports it (for performance)

      if (options.useGPU && transform) {

        // concatenate to any existing transform
        // so we don't accidentally override it
        oldTransform = compStyle[transform];

        if (oldTransform === 'none') {
          oldTransform = '';
        }

        style[transform] = oldTransform + ' translate3d(0,0,0)';
      }

      // optional styling

      if (options.setPosition) {
        style.display = 'block';
        style.left = _dimensions.left + 'px';
        style.top = _dimensions.top + 'px';
        style.bottom = style.right = 'auto';
        style.margin = 0;
        style.position = 'absolute';
      }

      if (options.setCursor) {
        style.cursor = 'move';
      }

      // set limit
      me.setLimit(options.limit);

      // set position in model
      util.assign(me.dragEvent, {
        x: _dimensions.left,
        y: _dimensions.top
      });

      // attach mousedown event
      util.on(me.handle, me.handlers.start);

    },

    start: function (e) {

      var me = this;
      var cursor = me.getCursor(e);
      var element = me.element;

      // filter the target?
      if (!me.useTarget(e.target || e.srcElement)) {
        return;
      }

      // prevent browsers from visually dragging the element's outline
      if (e.preventDefault) {
        e.preventDefault();
      } else {
        e.returnValue = false; // IE10
      }

      // set a high z-index, just in case
      me.dragEvent.oldZindex = element.style.zIndex;
      element.style.zIndex = 10000;

      // set initial position
      me.setCursor(cursor);
      me.setPosition();
      me.setZoom();

      // add event listeners
      util.on(document, me.handlers.move);

    },

    drag: function (e) {

      var me = this,
        dragEvent = me.dragEvent,
        element = me.element,
        initialCursor = me._cursor,
        initialPosition = me._dimensions,
        options = me.options,
        zoom = initialPosition.zoom,
        cursor = me.getCursor(e),
        threshold = options.threshold,
        x = (cursor.x - initialCursor.x)/zoom + initialPosition.left,
        y = (cursor.y - initialCursor.y)/zoom + initialPosition.top;

      // check threshold
      if (!dragEvent.started && threshold &&
        (Math.abs(initialCursor.x - cursor.x) < threshold) &&
        (Math.abs(initialCursor.y - cursor.y) < threshold)
      ) {
        return;
      }

      // save original position?
      if (!dragEvent.original) {
        dragEvent.original = { x: x, y: y };
      }

      // trigger start event?
      if (!dragEvent.started) {
        options.onDragStart(element, x, y, e);
        dragEvent.started = true;
      }

      // move the element
      if (me.move(x, y)) {

        // trigger drag event
        options.onDrag(element, dragEvent.x, dragEvent.y, e);
      }

    },

    move: function (x, y) {

      var me = this,
        dragEvent = me.dragEvent,
        options = me.options,
        grid = options.grid,
        style = me.element.style,
        pos = me.limit(x, y, dragEvent.original.x, dragEvent.original.y);

      // snap to grid?
      if (!options.smoothDrag && grid) {
        pos = me.round (pos, grid);
      }

      // move it
      if (pos.x !== dragEvent.x || pos.y !== dragEvent.y) {

        dragEvent.x = pos.x;
        dragEvent.y = pos.y;
        style.left = pos.x + 'px';
        style.top = pos.y + 'px';

        return true;
      }

      return false;

    },

    stop: function (e) {

      var me = this,
        dragEvent = me.dragEvent,
        element = me.element,
        options = me.options,
        grid = options.grid,
        pos;

      // remove event listeners
      util.off(document, me.handlers.move);

      // resent element's z-index
      element.style.zIndex = dragEvent.oldZindex;

      // snap to grid?
      if (options.smoothDrag && grid) {
        pos = me.round({ x: dragEvent.x, y: dragEvent.y }, grid);
        me.move(pos.x, pos.y);
        util.assign(me.dragEvent, pos);
      }

      // trigger dragend event
      if (me.dragEvent.started) {
        options.onDragEnd(element, dragEvent.x, dragEvent.y, e);
      }

      // clear temp vars
      me.reset();

    },

    reset: function() {

      this.dragEvent.started = false;

    },

    round: function (pos) {

      var grid = this.options.grid;

      return {
        x: grid * Math.round(pos.x/grid),
        y: grid * Math.round(pos.y/grid)
      };

    },

    getCursor: function (e) {

      return {
        x: (e.targetTouches ? e.targetTouches[0] : e).clientX,
        y: (e.targetTouches ? e.targetTouches[0] : e).clientY
      };

    },

    setCursor: function (xy) {

      this._cursor = xy;

    },

    setLimit: function (limit) {

      var me = this,
        _true = function (x, y) {
          return { x:x, y:y };
        };

      // limit is a function
      if (isFunction(limit)) {

        me.limit = limit;

      }

      // limit is an element
      else if (isElement(limit)) {

        var draggableSize = me._dimensions,
          height = limit.scrollHeight - draggableSize.height,
          width = limit.scrollWidth - draggableSize.width;

        me.limit = function (x, y) {
          return {
            x: util.limit(x, [0, width]),
            y: util.limit(y, [0, height])
          }
        };

      }

      // limit is defined
      else if (limit) {

        var defined = {
          x: isDefined(limit.x),
          y: isDefined(limit.y)
        };
        var _x, _y;

        // {Undefined} limit.x, {Undefined} limit.y
        if (!defined.x && !defined.y) {

          me.limit = _true;

        } else {

          me.limit = function (x, y) {
            return {
              x: defined.x ? util.limit(x, limit.x) : x,
              y: defined.y ? util.limit(y, limit.y) : y
            };
          };

        }
      }

      // limit is `null` or `undefined`
      else {

        me.limit = _true;

      }

    },

    setPosition: function() {

      var me = this,
        element = me.element,
        style = element.style;

      util.assign(me._dimensions, {
        left: parse(style.left) || element.offsetLeft,
        top: parse(style.top) || element.offsetTop
      });

    },

    setZoom: function() {

      var me = this;
      var element = me.element;
      var zoom = 1;

      while (element = element.offsetParent) {

        var z = getStyle(element).zoom;

        if (z && z !== 'normal') {
          zoom = z;
          break;
        }

      }

      me._dimensions.zoom = zoom;

    },

    useTarget: function (element) {

      var filterTarget = this.options.filterTarget;

      if (filterTarget instanceof Function) {
        return filterTarget(element);
      }

      return true;

    },

    destroy: function () {

      util.off(this.handle, this.handlers.start);
      util.off(document, this.handlers.move);

    }

  });

  // helpers

  function parse (string) {
    return parseInt(string, 10);
  }

  function getStyle (element) {
    return 'currentStyle' in element ? element.currentStyle : getComputedStyle(element);
  }

  function isArray (thing) {
    return thing instanceof Array; // HTMLElement
  }

  function isDefined (thing) {
    return thing !== void 0 && thing !== null;
  }

  function isElement (thing) {
    return thing instanceof Element || typeof HTMLDocument !== 'undefined' && thing instanceof HTMLDocument;
  }

  function isFunction (thing) {
    return thing instanceof Function;
  }

  function noop (){};

  return Draggable;

}));
