/**
 * Mapbox GL Mouse over event fix plugin
 * @module mapboxgl
 * @author Cyrille Pontvieux <cyrille@enialis.net>
 * @returns mapboxgl instance with an enhanced [Map](module-mapboxgl-Map.html)
 */

(function (root, factory) {
    // https://github.com/umdjs/umd/blob/master/templates/returnExports.js
    if (typeof define === "function" && define.amd) {
        // AMD. Register as an anonymous module.
        define(["mapbox-gl"], factory);
    } else if (typeof module === "object" && module.exports) {
        // Node. Does not work with strict CommonJS, but only CommonJS-like environments that support module.exports, like Node.
        module.exports = factory(require("mapbox-gl"));
    } else {
        // Browser globals (root is window)
        root.mapboxgl = factory(root.mapboxgl);
    }
}(typeof self !== "undefined" ? self : this, function (mapboxgl) {
    const isInteractiveEvent = eventType => eventType && (
        eventType.startsWith("mouse") ||
        eventType.startsWith("touch") ||
        ["click", "dblclick", "contextmenu"].includes(eventType)
    );

    ////////////////////////////////////////////////////////////
    /**
     * Following code is copied from MapboxGL because it's not publicly availlable in the library.
     */

    /**
     * Event class, copied from https://github.com/mapbox/mapbox-gl-js/blob/v0.45.0/src/util/evented.js#L22 because it's extended by MapMouseEvent.
     * @param {String} type
     * @param {Object} data
     */
    const Event = function Event(type, data) {
        if (data === undefined) {
            data = {};
        }
        extend(this, data);
        this.type = type;
    };

    /**
     * Code copied from : https://github.com/mapbox/mapbox-gl-js/blob/v0.45.0/src/util/dom.js#L104
     */
    const DOM = {};
    DOM.mousePos = function (el, e) {
        const rect = el.getBoundingClientRect();
        e = e.touches ? e.touches[0] : e;
        return new mapboxgl.Point(
            e.clientX - rect.left - el.clientLeft,
            e.clientY - rect.top - el.clientTop
        );
    };

    /**
     * Given a destination object and optionally many source objects,
     * copy all properties from the source objects into the destination.
     * The last source object given overrides properties from previous
     * source objects.
     *
     * Code copied from : https://github.com/mapbox/mapbox-gl-js/blob/v0.45.0/src/util/util.js#L152
     *
     * @param {Object} dest destination object
     * @returns {Object} dest
     * @private
     */
    const extend = function (dest) {
        const sources = [];
        let len = arguments.length - 1;
        while (len-- > 0) {
            sources[len] = arguments[len + 1];
        }
        for (let i = 0, list = sources; i < list.length; i += 1) {
            const src = list[i];
            for (const k in src) {
                dest[k] = src[k];
            }
        }
        return dest;
    };

    /**
     * `MapMouseEvent` is the event type for mouse-related map events.
     * https://github.com/mapbox/mapbox-gl-js/blob/v0.45.0/src/ui/events.js#L17
     * @extends {Object}
     */
    const MapMouseEvent = (function (Event) {
        const MapMouseEvent = function (type, map, originalEvent, data) {
            if (data === undefined) {
                data = {};
            }
            const point = DOM.mousePos(map.getCanvasContainer(), originalEvent);
            const lngLat = map.unproject(point);
            Event.call(this, type, extend({ point: point, lngLat: lngLat, originalEvent: originalEvent }, data));
            this._defaultPrevented = false;
            this.target = map;
        };
        if (Event) {
            MapMouseEvent.__proto__ = Event;
        }
        MapMouseEvent.prototype = Object.create(Event && Event.prototype);
        MapMouseEvent.prototype.constructor = MapMouseEvent;
        const prototypeAccessors = { defaultPrevented: { configurable: true } };
        MapMouseEvent.prototype.preventDefault = () => {
            this._defaultPrevented = true;
        };
        /**
         * `true` if `preventDefault` has been called.
         * @returns {Boolean}
         */
        prototypeAccessors.defaultPrevented.get = function ()          {
            return this._defaultPrevented;
        };
        Object.defineProperties(MapMouseEvent.prototype, prototypeAccessors);
        return MapMouseEvent;
    }(Event));
    ////////////////////////////////////////////////////////////

    /**
     * @exports mapboxgl-Map
     */
    const Map = mapboxgl.Map;
    const originalMapOnFunc = Map.prototype.on;
    const originalMapOffFunc = Map.prototype.off;
    const originalMapFireFunc = Map.prototype.fire;

    /**
     * Add a listener to the map concerning the layer `layerId`.
     * The listener should test whether the specific event is for the layer or not.
     *
     * Events will be fired from top to bottom (following pseudo-z-index)
     * and only if the [`originalEvent.cancelBubble` is falsy](https://developer.mozilla.org/en-US/docs/Web/API/Event/cancelBubble).
     * @param   {string}   layerId   the layer id.
     * @param   {string}   eventType the event type.
     * @param   {function} listener  the listener which will be called with a mapbox event.
     * @returns {function} a wrapped listener, original listener could be found in the `originalListener` property.
     */
    Map.prototype.addListenerForLayer = function (layerId, eventType, listener) {
        this._layersEvented = this._layersEvented || [];
        let evented = this._layersEvented[layerId] = this._layersEvented[layerId] || new mapboxgl.Evented();
        let wrappedListener = function (...args) {
            const ev = args[0] || event;
            args[0] = ev;
            (!ev.originalEvent.bubbles || !ev.originalEvent.cancelBubble) && listener.apply(this, args);
        };
        wrappedListener.originalListener = listener;
        evented.on(eventType, wrappedListener);
        return wrappedListener;
    };

    /**
     * Get all (wrapped) listeners for the specified layer id.
     * @param   {string} layerId the layer id
     * @returns {object} object with all listeners as property.
     *
     * `Evented._listeners` or empty object.
     * @see [`Evented` on *mapboxgl*](https://www.mapbox.com/mapbox-gl-js/api/#evented).
     */
    Map.prototype.getListenersForLayer = function (layerId) {
        let layersEvented = this._layersEvented || [];
        let evented = layersEvented[layerId] || null;
        return evented && evented._listeners || {};
    };

    /**
     * Remove a listener from a layer.
     * The listener could be a real or a wrapper listener.
     * @param   {string}   layerId   the layer id corresponding to the listener.
     * @param   {string}   eventType the event type.
     * @param   {function} listener  the listener to remove.
     */
    Map.prototype.removeListenerFromLayer = function (layerId, eventType, listener) {
        let evented = this._getLayerEventedForLayer(layerId) || null;
        let listeners = evented && evented._listeners && evented._listeners[eventType] || [];
        let wrappedListener = listeners.find(l => l === listener || l.originalListener === listener);
        if (wrappedListener) {
            evented.off(eventType, wrappedListener);
        }
    };

    Map.prototype._getLayerEventedForLayer = function (layerId) {
        return this._layersEvented && this._layersEvented[layerId];
    };

    Map.prototype._getLayersEventedByZIndexFor = function (type) {
        let layersEvented = [];
        let layersId = this.style && this.style._order && this.style._order.slice().reverse() || [];
        for (const layerId of layersId) {
            let layerEvented = this._getLayerEventedForLayer(layerId);
            if (layerEvented && layerEvented.listens(type)) {
                layersEvented.push(layerEvented);
            }
        }
        return layersEvented;
    };

    /**
     * Enhance `on` method for registering an event.
     *
     * The following special map event `type` could be registerd:
     * - *mousedown*
     * - *mouseup*
     * - *click*
     * - *dblclick*
     * - *mousemove*
     * - *mouseenter* / *mouseover*: the cursor enters a visible portion of the specified layer from outside that layer or outside the map canvas.
     * - *mouseleave*: the cursor leaves a visible portion of the specified layer or leaves the map canvas.
     * - *mouseout*: the cursor leaves the map canvas.
     * - *contextmenu*
     * - *touchstart*
     * - *touchend*
     * - *touchcancel*
     *
     * Events will be fired from **top** to **bottom** (following the [*bubble effect*](https://developer.mozilla.org/en-US/docs/Web/API/Event/bubbles))
     * and only if the `originalEvent` can propagate (`bubbles` getter is falsy or `stopPropagation` has not been called on it).
     * @param   {string}          type                     one of the available types.
     * @param   {string|function} [layerIdOrListener=null] A style layer id if you want to restrict events to the visible features of a layer.
     *
     * Otherwise, a listener which takes a mapboxgl event as argument.
     *
     * The event object has three more properties:
     * - `originalEvent`: the DOM event
     * - `target`: the DOM target
     * - `type`: the event type
     * @param   {function}        [layerListener=null]     The listener if not defined in the previous parameter.
     * @returns {Map} this
     */
    Map.prototype.on = function (type, layerIdOrListener = null, layerListener = null, ...args) {
        const layerId = layerListener == null ? null : layerIdOrListener;
        const listener = layerListener == null ? layerIdOrListener : layerListener;
        if (isInteractiveEvent(type) && layerId) {
            listener.delegates = [];
            if (type === "mouseenter" || type === "mouseover") {
                let mousein = false;
                let mousemove = (e) => {
                    const layer = this.getLayer(layerId);
                    const features = layer ? this.queryRenderedFeatures(e.point, { layers: [layerId] }) : [];
                    if (!features.length) {
                        mousein = false;
                    } else if (!mousein) {
                        mousein = true;
                        listener.call(this, new MapMouseEvent(type, this, e.originalEvent, { features }));
                        e.originalEvent.cancelMouseMove = true;
                    } else {
                        e.originalEvent.stopPropagation();
                    }
                };
                mousemove.event = "special:mousemove";
                let mouseout = () => {
                    mousein = false;
                };
                mouseout.event = "mouseout";
                listener.delegates.push(mousemove);
                listener.delegates.push(mouseout);
            } else if (type === "mouseleave" || type === "mouseout") {
                let mousein = false;
                let mousemove = (e) => {
                    const layer = this.getLayer(layerId);
                    const features = layer ? this.queryRenderedFeatures(e.point, { layers: [layerId] }) : [];
                    if (features.length) {
                        mousein = true;
                    } else if (mousein) {
                        mousein = false;
                        listener.call(this, new MapMouseEvent(type, this, e.originalEvent));
                        e.originalEvent.cancelMouseMove = true;
                    }
                };
                mousemove.event = "special:mousemove";
                let mouseout = (e) => {
                    if (mousein) {
                        mousein = false;
                        listener.call(this, new MapMouseEvent(type, this, e.originalEvent));
                    }
                };
                mouseout.event = "mouseout";
                listener.delegates.push(mousemove);
                listener.delegates.push(mouseout);
            } else {
                let delegate = (e) => {
                    const features = this.getLayer(layerId) ? this.queryRenderedFeatures(e.point, { layers: [layerId] }) : [];
                    if (features.length) {
                        // Here we need to mutate the original event, so that preventDefault works as expected.
                        e.features = features;
                        listener.call(this, e);
                        delete e.features;
                    }
                };
                delegate.event = type;
                listener.delegates.push(delegate);
            }
            for (let delegate of listener.delegates) {
                this.addListenerForLayer(layerId, delegate.event, delegate);
            }
            return this;
        } else {
            return originalMapOnFunc.call(this, type, listener, ...args);
        }
    };

    /**
     * Removes an event listener (eventually for layer-specific events) previously added with [on method](#on).
     * @param   {string}          type                     one of the available types.
     * @param   {string|function} [layerIdOrListener=null] A style layer id from which you want to remove the listener.
     *
     * Otherwise, the listener to remove.
     * @param   {function}        [layerListener=null]     The listener to remove if not defined in the previous parameter.
     * @returns {Map} this
     */
    Map.prototype.off = function (type, layerIdOrListener = null, layerListener = null, ...args) {
        const layerId = layerListener == null ? null : layerIdOrListener;
        const listener = layerListener == null ? layerIdOrListener : layerListener;
        if (isInteractiveEvent(type) && layerId) {
            if (listener.delegates) {
                for (let delegate of listener.delegates) {
                    this.removeListenerFromLayer(layerId, delegate.event, delegate);
                }
            } else {
                this.removeListenerFromLayer(layerId, type, listener);
            }
            return this;
        } else {
            return originalMapOffFunc.call(this, type, listener, ...args);
        }
    };

    /**
     * Fires an event of the specified type.
     *
     * Respect the *bubble effect*.
     * @param   {object} event event data
     * @returns {Map} this
     * @see [`on` method](#on).
     * @see [`Evented` on *mapboxgl*](https://www.mapbox.com/mapbox-gl-js/api/#evented).
     */
    Map.prototype.fire = function (event, ...args) {
        if (typeof event === "string") {
            event = new Event(event, arguments[1] || {});
        }
        let type = event.type;
        if (isInteractiveEvent(type)) {
            let types;
            if (type == "mousemove") {
                types = ["special:mousemove", type];
            } else {
                types = [type];
            }
            for (type of types) {
                if (!event.originalEvent.cancelMouseMove) {
                    for (let layerEvented of this._getLayersEventedByZIndexFor(type)) {
                        if (type == "special:mousemove") {
                            event.type = "special:mousemove";
                        } else if (type == "mousemove") {
                            event.type = "mousemove";
                        }
                        layerEvented.fire(event);
                    }
                } else {
                    delete event.originalEvent.cancelMouseMove;
                }
            }
            if (event.originalEvent && event.originalEvent.cancelBubble) {
                return this;
            } else {
                return originalMapFireFunc.call(this, event, ...args);
            }
        } else {
            return originalMapFireFunc.call(this, event, ...args);
        }
    };

    return mapboxgl;
}));