(function ($, window, undefined) {
    $.fn.watchLinger = function (options) {
        options = $.extend(true, {}, $.fn.watchLinger.defaults, options);
        var dataKey = [options.events.lingerchange, options.events.start].join('-');

        $.each(this, function (index, element) {
            if ($(element).data(dataKey)) return;

            var context = {
                element: $(element),
                options: options,
                events: {},
                timeouts: {},

                lingering: false,
                quickStart: false  // within quickstart window?

            };

            addHandlers(context);

            $(element).data(dataKey, context);
        });

        return this;
    };

    $.fn.watchLinger.defaults = {
        selector: null,
        events: {
            // Fire
            lingerchange: $.noop,
            lingerend: $.noop,

            // Listen
            start: 'mouseenter mousemove',
            change: 'mousemove',
            end: 'mouseleave touchend'
        },
        delays: {
            start: 0.5 * 1000,
            extended: 2.5 * 1000,
            end: 0 * 1000,
            quickStart: 0.2 * 1000,
            quickStartWindow: 2 * 1000 // after end during quickStartWindow time, use quickStart delay instead of regular start
        }
    };


    function addHandlers(context) {
        $(context.element)
			.on(context.options.events.start, onStart.bind(context))
	    	.on(context.options.events.change, onChange.bind(context))
	    	.on(context.options.events.end, onEnd.bind(context));
    }


    function onStart(event, data) {
        debugEvent(event, 'start');
        var context = this;

        context.events.start = { event: event, data: data };
    }

    function onChange(event, data) {
        debugEvent(event, 'change');
        var context = this;

        if (context.options.selector && !$(event.target).is(context.options.selector)) {
            onEnd.call(context, event, data);
            return;
        }

        cancelEndDelay(context);

        if (!context.events.start) {
            onStart.call(this, event, data);
        }

        context.events.change = { event: event, data: data }; // for lingerDelay 

        context.quickStart = context.events.end && (context.events.end.event.timeStamp > event.timeStamp - context.options.delays.quickStartWindow) || false;
        var delay = getDelay(context);

        var lingering = context.lingering
            || context.events.start && (context.events.start.event.timeStamp < (event.timeStamp - delay))
            || false;
        doLinger(context, lingering);
    }

    function onEnd(event, data) {
        debugEvent(event, 'end');
        var context = this;

        cancelLingerDelay(context);
        cancelExtendedDelay(context);

        if (context.lingering) {
            context.events.end = { event: event, data: data };

            startEndDelay(context);
        }
    }

    function onReset(event, data) {
        var context = this;

        doReset(context);
    }

    function doLinger(context, lingering) {
        debugState('lingering=' + lingering, 'quickStart=' + context.quickStart)
        var callback = doChange.bind(window, context);

        if (lingering) {
            // continues to linger (probably moving around inside element)
            callback();
        } else {
            // not lingering quite yet
            startLingerDelay(context, callback);
            if (!context.quickStart) clearExtendedLinger(context);
        }
    }

    function doChange(context) {
        cancelEndDelay(context);
        triggerChange(context);
        startExtendedDelay(context, triggerChange.bind(window, context));
    }

    function doEnd(context) {
        context.events.start = null;
        cancelEndDelay(context);
        triggerEnd(context);
    }

    function doReset(context) {
        context.lingering = false;
        context.extended = false;
    }


    function startLingerDelay(context, callback) {
        cancelLingerDelay(context);
        var delay = getDelay(context);
        context.timeouts.linger = setTimeout(callback, delay);

        debugTimeouts(context);
    }


    function cancelLingerDelay(context) {
        clearTimeout(context.timeouts.linger);
        context.timeouts.linger = false;

        debugTimeouts(context);
    }

    function startExtendedDelay(context, callback) {
        cancelExtendedDelay(context);
        var delay = context.options.delays.extended;
        context.timeouts.extended = setTimeout(setExtendedLinger.bind(window, context, callback), delay);

        debugTimeouts(context);
    }

    function cancelExtendedDelay(context) {
        clearTimeout(context.timeouts.extended);
        context.timeouts.extended = false;

        debugTimeouts(context);
    }


    function startEndDelay(context) {
        cancelEndDelay(context);
        context.timeouts.end = setTimeout(doEnd.bind(window, context), context.options.delays.end);

        debugTimeouts(context);
    }

    function cancelEndDelay(context) {
        clearTimeout(context.timeouts.end);
        context.timeouts.end = false;

        debugTimeouts(context);
    }


    function setExtendedLinger(context, callback) {
        context.extended = true;
        callback && callback();
    }

    function clearExtendedLinger(context) {
        clearTimeout(context.timeouts.extended);
        context.timeouts.extended = false;
        context.extended = false;

        debugState('cleared extended');
    }

    function triggerChange(context) {
        context.lingering = true;

        var handler = context.options.events.lingerchange;
        var eventInfo = context.events.change;

        var event = eventInfo.event;
        event.data = $.extend(true,
            event.data,
            eventInfo.data,
            {
                lingering: context.lingering,
                extended: context.extended,
                reset: onReset.bind(context, event)
            });

        handler.call(event.target, event);
    }

    function triggerEnd(context) {
        context.lingering = false;

        var handler = context.options.events.lingerend;
        var eventInfo = context.events.end;

        var event = eventInfo.event;
        event.data = $.extend(true,
            event.data,
            eventInfo.data,
            {
                lingering: context.lingering,
                extended: context.extended,
                reset: onReset.bind(context, event)
            });

        handler.call(event.target, event);
    }

    function getDelay(context) {
        var delay = context.quickStart ? context.options.delays.quickStart : context.options.delays.start;

        return delay;
    }



    function debug() {
        return;
        
        if (!window.console) return;
        if (window.console.log.apply) {
            window.console.log.apply(window.console, [].slice.call(arguments));
        } else {
            var args = JSON.stringify([].slice.call(arguments));
            window.console.log(args);
        }
    }

    function debugEvent(event, label) {
        var args = [ '(' + event.type + ')', '@', event.timeStamp, '(on:)', event.target.className, '(caught:)', event.delegateTarget.className ];
        if (label != void 0) args.unshift(label);
        debug.apply(this, args);
    }

    function debugState() {
        debug.apply(this, Array.prototype.slice.call(arguments));
    }

    function debugTimeouts(context) {
        debug('timeouts', context.timeouts, context.options.events.change);
    }
})(jQuery, this);