// @flow

import assert from 'assert';
import * as DOM from '../../util/dom.js';

import {ease as _ease, bindAll, bezier} from '../../util/util.js';
import browser from '../../util/browser.js';
import window from '../../util/window.js';
import {number as interpolate} from '../../style-spec/util/interpolate.js';
import Point from '@mapbox/point-geometry';

import type Map from '../map.js';
import type HandlerManager, {HandlerResult} from '../handler_manager.js';
import MercatorCoordinate from '../../geo/mercator_coordinate.js';

// deltaY value for mouse scroll wheel identification
const wheelZoomDelta = 4.000244140625;

// These magic numbers control the rate of zoom. Trackpad events fire at a greater
// frequency than mouse scroll wheel, so reduce the zoom rate per wheel tick
const defaultZoomRate = 1 / 100;
const wheelZoomRate = 1 / 450;

// upper bound on how much we scale the map in any single render frame; this
// is used to limit zoom rate in the case of very fast scrolling
const maxScalePerFrame = 2;

/**
 * The `ScrollZoomHandler` allows the user to zoom the map by scrolling.
 *
 * @see [Example: Toggle interactions](https://docs.mapbox.com/mapbox-gl-js/example/toggle-interaction-handlers/)
 * @see [Example: Disable scroll zoom](https://docs.mapbox.com/mapbox-gl-js/example/disable-scroll-zoom/)
 */
class ScrollZoomHandler {
    _map: Map;
    _el: HTMLElement;
    _enabled: boolean;
    _active: boolean;
    _zooming: boolean;
    _aroundCenter: boolean;
    _aroundPoint: Point;
    _aroundCoord: MercatorCoordinate;
    _type: 'wheel' | 'trackpad' | null;
    _lastValue: number;
    _timeout: ?TimeoutID; // used for delayed-handling of a single wheel movement
    _finishTimeout: ?TimeoutID; // used to delay final '{move,zoom}end' events

    _lastWheelEvent: any;
    _lastWheelEventTime: number;

    _startZoom: ?number;
    _targetZoom: ?number;
    _delta: number;
    _easing: ?((number) => number);
    _prevEase: ?{start: number, duration: number, easing: (_: number) => number};

    _frameId: ?boolean;
    _handler: HandlerManager;

    _defaultZoomRate: number;
    _wheelZoomRate: number;

    _alertContainer: HTMLElement; // used to display the scroll zoom blocker alert
    _alertTimer: TimeoutID;

    /**
     * @private
     */
    constructor(map: Map, handler: HandlerManager) {
        this._map = map;
        this._el = map.getCanvasContainer();
        this._handler = handler;

        this._delta = 0;

        this._defaultZoomRate = defaultZoomRate;
        this._wheelZoomRate = wheelZoomRate;

        bindAll(['_onTimeout', '_addScrollZoomBlocker', '_showBlockerAlert', '_isFullscreen'], this);

    }

    /**
     * Sets the zoom rate of a trackpad.
     *
     * @param {number} [zoomRate=1/100] The rate used to scale trackpad movement to a zoom value.
     * @example
     * // Speed up trackpad zoom
     * map.scrollZoom.setZoomRate(1 / 25);
     */
    setZoomRate(zoomRate: number) {
        this._defaultZoomRate = zoomRate;
    }

    /**
    * Sets the zoom rate of a mouse wheel.
     *
    * @param {number} [wheelZoomRate=1/450] The rate used to scale mouse wheel movement to a zoom value.
    * @example
    * // Slow down zoom of mouse wheel
    * map.scrollZoom.setWheelZoomRate(1 / 600);
    */
    setWheelZoomRate(wheelZoomRate: number) {
        this._wheelZoomRate = wheelZoomRate;
    }

    /**
     * Returns a Boolean indicating whether the "scroll to zoom" interaction is enabled.
     *
     * @returns {boolean} `true` if the "scroll to zoom" interaction is enabled.
     * @example
     * const isScrollZoomEnabled = map.scrollZoom.isEnabled();
     */
    isEnabled(): boolean {
        return !!this._enabled;
    }

    /*
    * Active state is turned on and off with every scroll wheel event and is set back to false before the map
    * render is called, so _active is not a good candidate for determining if a scroll zoom animation is in
    * progress.
    */
    isActive(): boolean {
        return !!this._active || this._finishTimeout !== undefined;
    }

    isZooming(): boolean {
        return !!this._zooming;
    }

    /**
     * Enables the "scroll to zoom" interaction.
     *
     * @param {Object} [options] Options object.
     * @param {string} [options.around] If "center" is passed, map will zoom around center of map.
     *
     * @example
     * map.scrollZoom.enable();
     * @example
     * map.scrollZoom.enable({around: 'center'});
     */
    enable(options: ?{around?: 'center'}) {
        if (this.isEnabled()) return;
        this._enabled = true;
        this._aroundCenter = !!options && options.around === 'center';
        if (this._map._cooperativeGestures) this._addScrollZoomBlocker();
    }

    /**
     * Disables the "scroll to zoom" interaction.
     *
     * @example
     * map.scrollZoom.disable();
     */
    disable() {
        if (!this.isEnabled()) return;
        this._enabled = false;
        if (this._map._cooperativeGestures) {
            clearTimeout(this._alertTimer);
            this._alertContainer.remove();
        }
    }

    wheel(e: WheelEvent) {
        if (!this.isEnabled()) return;

        if (this._map._cooperativeGestures) {
            if (!e.ctrlKey && !e.metaKey && !this.isZooming() && !this._isFullscreen()) {
                this._showBlockerAlert();
                return;
            } else if (this._alertContainer.style.visibility !== 'hidden') {
                // immediately hide alert if it is visible when ctrl or ⌘ is pressed while scroll zooming.
                this._alertContainer.style.visibility = 'hidden';
                clearTimeout(this._alertTimer);
            }
        }

        // Remove `any` cast when https://github.com/facebook/flow/issues/4879 is fixed.
        let value = e.deltaMode === (window.WheelEvent: any).DOM_DELTA_LINE ? e.deltaY * 40 : e.deltaY;
        const now = browser.now(),
            timeDelta = now - (this._lastWheelEventTime || 0);

        this._lastWheelEventTime = now;

        if (value !== 0 && (value % wheelZoomDelta) === 0) {
            // This one is definitely a mouse wheel event.
            this._type = 'wheel';

        } else if (value !== 0 && Math.abs(value) < 4) {
            // This one is definitely a trackpad event because it is so small.
            this._type = 'trackpad';

        } else if (timeDelta > 400) {
            // This is likely a new scroll action.
            this._type = null;
            this._lastValue = value;

            // Start a timeout in case this was a singular event, and delay it by up to 40ms.
            this._timeout = setTimeout(this._onTimeout, 40, e);

        } else if (!this._type) {
            // This is a repeating event, but we don't know the type of event just yet.
            // If the delta per time is small, we assume it's a fast trackpad; otherwise we switch into wheel mode.
            this._type = (Math.abs(timeDelta * value) < 200) ? 'trackpad' : 'wheel';

            // Make sure our delayed event isn't fired again, because we accumulate
            // the previous event (which was less than 40ms ago) into this event.
            if (this._timeout) {
                clearTimeout(this._timeout);
                this._timeout = null;
                value += this._lastValue;
            }
        }

        // Slow down zoom if shift key is held for more precise zooming
        if (e.shiftKey && value) value = value / 4;

        // Only fire the callback if we actually know what type of scrolling device the user uses.
        if (this._type) {
            this._lastWheelEvent = e;
            this._delta -= value;
            if (!this._active) {
                this._start(e);
            }
        }

        e.preventDefault();
    }

    _onTimeout(initialEvent: WheelEvent) {
        this._type = 'wheel';
        this._delta -= this._lastValue;
        if (!this._active) {
            this._start(initialEvent);
        }
    }

    _start(e: WheelEvent) {
        if (!this._delta) return;

        if (this._frameId) {
            this._frameId = null;
        }

        this._active = true;
        if (!this.isZooming()) {
            this._zooming = true;
        }

        if (this._finishTimeout) {
            clearTimeout(this._finishTimeout);
            delete this._finishTimeout;
        }

        const pos = DOM.mousePos(this._el, e);
        this._aroundPoint = this._aroundCenter ? this._map.transform.centerPoint : pos;
        this._aroundCoord = this._map.transform.pointCoordinate3D(this._aroundPoint);
        this._targetZoom = undefined;

        if (!this._frameId) {
            this._frameId = true;
            this._handler._triggerRenderFrame();
        }
    }

    renderFrame(): ?HandlerResult {
        if (!this._frameId) return;
        this._frameId = null;

        if (!this.isActive()) return;

        const tr = this._map.transform;

        const startingZoom = () => {
            return (tr._terrainEnabled() && this._aroundCoord) ? tr.computeZoomRelativeTo(this._aroundCoord) : tr.zoom;
        };

        // if we've had scroll events since the last render frame, consume the
        // accumulated delta, and update the target zoom level accordingly
        if (this._delta !== 0) {
            // For trackpad events and single mouse wheel ticks, use the default zoom rate
            const zoomRate = (this._type === 'wheel' && Math.abs(this._delta) > wheelZoomDelta) ? this._wheelZoomRate : this._defaultZoomRate;
            // Scale by sigmoid of scroll wheel delta.
            let scale = maxScalePerFrame / (1 + Math.exp(-Math.abs(this._delta * zoomRate)));

            if (this._delta < 0 && scale !== 0) {
                scale = 1 / scale;
            }

            const startZoom = startingZoom();
            const startScale = Math.pow(2.0, startZoom);

            const fromScale = typeof this._targetZoom === 'number' ? tr.zoomScale(this._targetZoom) : startScale;
            this._targetZoom = Math.min(tr.maxZoom, Math.max(tr.minZoom, tr.scaleZoom(fromScale * scale)));

            // if this is a mouse wheel, refresh the starting zoom and easing
            // function we're using to smooth out the zooming between wheel
            // events
            if (this._type === 'wheel') {
                this._startZoom = startingZoom();
                this._easing = this._smoothOutEasing(200);
            }

            this._delta = 0;
        }
        const targetZoom = typeof this._targetZoom === 'number' ?
            this._targetZoom : startingZoom();
        const startZoom = this._startZoom;
        const easing = this._easing;

        let finished = false;
        let zoom;
        if (this._type === 'wheel' && startZoom && easing) {
            assert(easing && typeof startZoom === 'number');

            const t = Math.min((browser.now() - this._lastWheelEventTime) / 200, 1);
            const k = easing(t);
            zoom = interpolate(startZoom, targetZoom, k);
            if (t < 1) {
                if (!this._frameId) {
                    this._frameId = true;
                }
            } else {
                finished = true;
            }
        } else {
            zoom = targetZoom;
            finished = true;
        }

        this._active = true;

        if (finished) {
            this._active = false;
            this._finishTimeout = setTimeout(() => {
                this._zooming = false;
                this._handler._triggerRenderFrame();
                delete this._targetZoom;
                delete this._finishTimeout;
            }, 200);
        }

        return {
            noInertia: true,
            needsRenderFrame: !finished,
            zoomDelta: zoom - startingZoom(),
            around: this._aroundPoint,
            aroundCoord: this._aroundCoord,
            originalEvent: this._lastWheelEvent
        };
    }

    _smoothOutEasing(duration: number): (number) => number {
        let easing = _ease;

        if (this._prevEase) {
            const ease = this._prevEase,
                t = (browser.now() - ease.start) / ease.duration,
                speed = ease.easing(t + 0.01) - ease.easing(t),

                // Quick hack to make new bezier that is continuous with last
                x = 0.27 / Math.sqrt(speed * speed + 0.0001) * 0.01,
                y = Math.sqrt(0.27 * 0.27 - x * x);

            easing = bezier(x, y, 0.25, 1);
        }

        this._prevEase = {
            start: browser.now(),
            duration,
            easing
        };

        return easing;
    }

    blur() {
        this.reset();
    }

    reset() {
        this._active = false;
    }

    _addScrollZoomBlocker() {
        if (this._map && !this._alertContainer) {
            this._alertContainer = DOM.create('div', 'mapboxgl-scroll-zoom-blocker', this._map._container);

            if (/(Mac|iPad)/i.test(window.navigator.userAgent)) {
                this._alertContainer.textContent = this._map._getUIString('ScrollZoomBlocker.CmdMessage');
            } else {
                this._alertContainer.textContent = this._map._getUIString('ScrollZoomBlocker.CtrlMessage');
            }

            // dynamically set the font size of the scroll zoom blocker alert message
            this._alertContainer.style.fontSize = `${Math.max(10, Math.min(24, Math.floor(this._el.clientWidth * 0.05)))}px`;
        }
    }

    _isFullscreen(): boolean {
        return !!window.document.fullscreenElement || !!window.document.webkitFullscreenElement;
    }

    _showBlockerAlert() {
        if (this._alertContainer.style.visibility === 'hidden') this._alertContainer.style.visibility = 'visible';
        this._alertContainer.classList.add('mapboxgl-scroll-zoom-blocker-show');

        clearTimeout(this._alertTimer);

        this._alertTimer = setTimeout(() => {
            this._alertContainer.classList.remove('mapboxgl-scroll-zoom-blocker-show');
        }, 200);
    }

}

export default ScrollZoomHandler;
