import {
    devAssert
} from '#core/assert/dev';
import {
    Observable
} from '#core/data-structures/observable';
import {
    supportsPassiveEventListener
} from '#core/dom/event-helper-listen';
import {
    findIndex
} from '#core/types/array';
import {
    getWin,
    toWin
} from '#core/window';

import {
    Pass
} from './pass';

const PROP_ = '__AMP_Gestures';

/**
 * A gesture object contains the type and data of the gesture such as
 * a tap or a double-tap or a swipe. See {@link GestureRecognizer} for
 * more details.
 * @struct
 * @const
 * @template DATA
 */
export class Gesture {
    /**
     * @param {string} type The gesture's string type.
     * @param {DATA} data The data of the gesture.
     * @param {time} time The time that the gesture has been emitted.
     * @param {?Event} event An optional browser event that resulted in the
     *   gesture.
     */
    constructor(type, data, time, event) {
        /** @const {string} */
        this.type = type;
        /** @const {DATA} */
        this.data = data;
        /** @const {number} */
        this.time = time;
        /** @const {?Event} */
        this.event = event;
    }
}

/**
 * Gestures object manages all gestures on a particular element. It listens
 * to all pointer events and delegates them to individual gesture recognizers.
 * When a recognizer has recognized a gesture and ready to start emitting it
 * it requests permission to do so from this class which resolves conflicts
 * between competing recognizers to decide which gesture should go forward.
 */
export class Gestures {
    /**
     * Creates if not yet created and returns the shared Gestures instance for
     * the specified element.
     * @param {!Element} element
     * @param {boolean=} opt_shouldNotPreventDefault
     * @param {boolean=} opt_shouldStopPropagation
     * @return {!Gestures}
     */
    static get(
        element,
        opt_shouldNotPreventDefault = false,
        opt_shouldStopPropagation = false
    ) {
        let res = element[PROP_];
        if (!res) {
            res = new Gestures(
                element,
                opt_shouldNotPreventDefault,
                opt_shouldStopPropagation
            );
            element[PROP_] = res;
        }
        return res;
    }

    /**
     * @param {!Element} element
     * @param {boolean} shouldNotPreventDefault
     * @param {boolean} shouldStopPropagation
     */
    constructor(
        element,
        shouldNotPreventDefault = false,
        shouldStopPropagation = false
    ) {
        /** @private {!Element} */
        this.element_ = element;

        /** @private {!Array<!GestureRecognizer>} */
        this.recognizers_ = [];

        /** @private {!Array<boolean>} */
        this.tracking_ = [];

        /** @private {!Array<time>} */
        this.ready_ = [];

        /** @private {!Array<time>} */
        this.pending_ = [];

        /** @private {?GestureRecognizer} */
        this.eventing_ = null;

        const win = element.ownerDocument.defaultView;
        const passiveSupported = supportsPassiveEventListener(toWin(win));

        /** @private {boolean} */
        this.shouldNotPreventDefault_ = shouldNotPreventDefault || passiveSupported;

        /** @private {boolean} */
        this.shouldStopPropagation_ = shouldStopPropagation;

        /**
         * This variable indicates that the eventing has stopped on this
         * event cycle.
         * @private {boolean}
         */
        this.wasEventing_ = false;

        /** @private {!Pass} */
        this.pass_ = new Pass(getWin(element), this.doPass_.bind(this));

        /** @private {!Observable} */
        this.pointerDownObservable_ = new Observable();

        /**
         * Observers for each type of registered gesture types.
         * @private {!Object<string, !Observable<!Gesture>>}
         */
        this.overservers_ = Object.create(null);

        /** @private @const {function(!Event)} */
        this.boundOnTouchStart_ = this.onTouchStart_.bind(this);
        /** @private @const {function(!Event)} */
        this.boundOnTouchEnd_ = this.onTouchEnd_.bind(this);
        /** @private @const {function(!Event)} */
        this.boundOnTouchMove_ = this.onTouchMove_.bind(this);
        /** @private @const {function(!Event)} */
        this.boundOnTouchCancel_ = this.onTouchCancel_.bind(this);

        this.element_.addEventListener(
            'touchstart',
            this.boundOnTouchStart_,
            passiveSupported ? {
                passive: true
            } : false
        );
        this.element_.addEventListener('touchend', this.boundOnTouchEnd_);
        this.element_.addEventListener(
            'touchmove',
            this.boundOnTouchMove_,
            passiveSupported ? {
                passive: true
            } : false
        );
        this.element_.addEventListener('touchcancel', this.boundOnTouchCancel_);

        /** @private {boolean} */
        this.passAfterEvent_ = false;
    }

    /**
     * Unsubscribes from all pointer events and removes the shared cache instance.
     */
    cleanup() {
        this.element_.removeEventListener('touchstart', this.boundOnTouchStart_);
        this.element_.removeEventListener('touchend', this.boundOnTouchEnd_);
        this.element_.removeEventListener('touchmove', this.boundOnTouchMove_);
        this.element_.removeEventListener('touchcancel', this.boundOnTouchCancel_);
        delete this.element_[PROP_];
        this.pass_.cancel();
    }

    /**
     * Subscribes to a gesture emitted by the specified recognizer. For a first
     * gesture handler registered in this method the recognizer is installed
     * and from that point on it participates in the event processing.
     *
     * @param {function(new:GestureRecognizer, !Gestures)} recognizerConstr
     * @param {function(!Gesture)} handler
     * @return {!UnlistenDef}
     * @template DATA
     */
    onGesture(recognizerConstr, handler) {
        const recognizer = new recognizerConstr(this);
        const type = recognizer.getType();
        let overserver = this.overservers_[type];
        if (!overserver) {
            this.recognizers_.push(recognizer);
            overserver = new Observable();
            this.overservers_[type] = overserver;
        }
        return overserver.add(handler);
    }

    /**
     * Unsubscribes all handlers from the given gesture recognizer. Returns
     * true if anything was done. Returns false if there were no handlers
     * registered on the given gesture recognizer in first place.
     *
     * @param {function(new:GestureRecognizer, !Gestures)} recognizerConstr
     * @return {boolean}
     */
    removeGesture(recognizerConstr) {
        const type = new recognizerConstr(this).getType();
        const overserver = this.overservers_[type];
        if (overserver) {
            overserver.removeAll();
            const index = findIndex(this.recognizers_, (e) => e.getType() == type);
            if (index < 0) {
                return false;
            }
            // Remove the recognizer as well as all associated tracking state
            this.recognizers_.splice(index, 1);
            this.ready_.splice(index, 1);
            this.pending_.splice(index, 1);
            this.tracking_.splice(index, 1);
            delete this.overservers_[type];
            return true;
        } else {
            return false;
        }
    }

    /**
     * Subscribes to pointer down events, such as "touchstart" or "mousedown".
     * @param {!Function} handler
     * @return {!UnlistenDef}
     */
    onPointerDown(handler) {
        return this.pointerDownObservable_.add(handler);
    }

    /**
     * Handles all "touchstart" events and dispatches them to the tracking
     * recognizers.
     * @param {!Event} event
     * @private
     */
    onTouchStart_(event) {
        const now = Date.now();
        this.wasEventing_ = false;

        this.pointerDownObservable_.fire(event);

        for (let i = 0; i < this.recognizers_.length; i++) {
            if (this.ready_[i]) {
                // If the recognizer is in the "ready" state, it won't receive
                // any more touch series until it's allowed to emit.
                continue;
            }
            if (this.pending_[i] && this.pending_[i] < now) {
                // Pending state expired. Reset.
                this.stopTracking_(i);
            }
            if (this.recognizers_[i].onTouchStart(event)) {
                // When a recognizer is interested in the touch series it returns "true"
                // from its onTouchStart method. For this recognizer we start tracking
                // the whole series of touch events from touchstart to touchend. Other
                // recognizers will not receive them unless they return "true" from
                // onTouchStart.
                this.startTracking_(i);
            }
        }

        this.afterEvent_(event);
    }

    /**
     * Handles all "touchmove" events and dispatches them to the tracking
     * recognizers.
     * @param {!Event} event
     * @private
     */
    onTouchMove_(event) {
        const now = Date.now();

        for (let i = 0; i < this.recognizers_.length; i++) {
            if (!this.tracking_[i]) {
                // The whole touch series are ignored for non-tracking recognizers.
                continue;
            }
            if (this.pending_[i] && this.pending_[i] < now) {
                // Pending state expired. Reset.
                this.stopTracking_(i);
                continue;
            }
            if (!this.recognizers_[i].onTouchMove(event)) {
                // Recognizer lost interest in the series. Reset.
                this.stopTracking_(i);
            }
        }

        this.afterEvent_(event);
    }

    /**
     * Handles all "touchend" events and dispatches them to the tracking
     * recognizers.
     * @param {!Event} event
     * @private
     */
    onTouchEnd_(event) {
        const now = Date.now();

        for (let i = 0; i < this.recognizers_.length; i++) {
            if (!this.tracking_[i]) {
                // The whole touch series are ignored for non-tracking recognizers.
                continue;
            }
            if (this.pending_[i] && this.pending_[i] < now) {
                // Pending state expired. Reset.
                this.stopTracking_(i);
                continue;
            }

            this.recognizers_[i].onTouchEnd(event);

            const isReady = !this.pending_[i];
            const isExpired = this.pending_[i] < now;
            const isEventing = this.eventing_ == this.recognizers_[i];

            if (!isEventing && (isReady || isExpired)) {
                this.stopTracking_(i);
            }
        }

        this.afterEvent_(event);
    }

    /**
     * Handles all "touchcancel" events. Cancels all tracking/emitting
     * recognizers.
     * @param {!Event} event
     * @private
     */
    onTouchCancel_(event) {
        for (let i = 0; i < this.recognizers_.length; i++) {
            this.cancelEventing_(i);
        }
        this.afterEvent_(event);
    }

    /**
     * Callback for a gesture recognizer to communicate that it's ready to
     * start emitting gestures. Gestures instance may or may not allow the
     * recognizer to proceed.
     * @param {!GestureRecognizer} recognizer
     * @param {number} offset
     * @private
     * @restricted
     * @visibleForTesting
     */
    signalReady_(recognizer, offset) {
        // Somebody got here first.
        if (this.eventing_) {
            recognizer.acceptCancel();
            return;
        }

        // Set the recognizer as ready and wait for the pass to
        // make the decision.
        const now = Date.now();
        for (let i = 0; i < this.recognizers_.length; i++) {
            if (this.recognizers_[i] == recognizer) {
                this.ready_[i] = now + offset;
                this.pending_[i] = 0;
            }
        }
        this.passAfterEvent_ = true;
    }

    /**
     * Callback for a gesture recognizer to communicate that it's close to
     * start emitting gestures, but needs more time to see more events. Once
     * this time expires the recognizer should either signal readiness or it
     * will be canceled.
     * @param {!GestureRecognizer} recognizer
     * @param {number} timeLeft
     * @private
     * @restricted
     * @visibleForTesting
     */
    signalPending_(recognizer, timeLeft) {
        // Somebody got here first.
        if (this.eventing_) {
            recognizer.acceptCancel();
            return;
        }

        const now = Date.now();
        for (let i = 0; i < this.recognizers_.length; i++) {
            if (this.recognizers_[i] == recognizer) {
                this.pending_[i] = now + timeLeft;
            }
        }
    }

    /**
     * Callback for a gesture recognizer to communicate that it's done
     * emitting gestures.
     * @param {!GestureRecognizer} recognizer
     * @private
     * @restricted
     * @visibleForTesting
     */
    signalEnd_(recognizer) {
        if (this.eventing_ == recognizer) {
            this.eventing_ = null;
            this.wasEventing_ = true;
        }
    }

    /**
     * Callback for a gesture emit the gesture. Only the currently emitting
     * recognizer is allowed to emit gestures.
     * @param {!GestureRecognizer} recognizer
     * @param {*} data
     * @param {?Event} event
     * @private
     * @restricted
     * @visibleForTesting
     */
    signalEmit_(recognizer, data, event) {
        devAssert(
            this.eventing_ == recognizer,
            'Recognizer is not currently allowed: %s',
            recognizer.getType()
        );
        const overserver = this.overservers_[recognizer.getType()];
        if (overserver) {
            overserver.fire(
                new Gesture(recognizer.getType(), data, Date.now(), event)
            );
        }
    }

    /**
     * @param {!Event} event
     * @private
     */
    afterEvent_(event) {
        let cancelEvent = !!this.eventing_ || this.wasEventing_;
        this.wasEventing_ = false;
        if (!cancelEvent) {
            const now = Date.now();
            for (let i = 0; i < this.recognizers_.length; i++) {
                if (this.ready_[i] || (this.pending_[i] && this.pending_[i] >= now)) {
                    cancelEvent = true;
                    break;
                }
            }
        }
        if (cancelEvent) {
            event.stopPropagation();
            if (!this.shouldNotPreventDefault_) {
                event.preventDefault();
            }
        } else if (this.shouldStopPropagation_) {
            event.stopPropagation();
        }
        if (this.passAfterEvent_) {
            this.passAfterEvent_ = false;
            this.doPass_();
        }
    }

    /**
     * The pass that decides which recognizers can start emitting and which
     * are canceled.
     * @private
     */
    doPass_() {
        const now = Date.now();

        // The "most ready" recognizer is the youngest in the "ready" set.
        // Otherwise we wouldn't wait for it at all.
        let readyIndex = -1;
        for (let i = 0; i < this.recognizers_.length; i++) {
            if (!this.ready_[i]) {
                if (this.pending_[i] && this.pending_[i] < now) {
                    // Pending state expired. Reset.
                    this.stopTracking_(i);
                }
                continue;
            }
            if (readyIndex == -1 || this.ready_[i] > this.ready_[readyIndex]) {
                readyIndex = i;
            }
        }

        if (readyIndex == -1) {
            // Nothing to do.
            return;
        }

        // Look for conflicts.
        let waitTime = 0;
        for (let i = 0; i < this.recognizers_.length; i++) {
            if (this.ready_[i] || !this.tracking_[i]) {
                continue;
            }
            waitTime = Math.max(waitTime, this.pending_[i] - now);
        }

        if (waitTime < 2) {
            // We waited long enough.
            this.startEventing_(readyIndex);
            return;
        }

        // Some conflicts: have to wait to see who wins.
        this.pass_.schedule(waitTime);
    }

    /**
     * This recognizer is given "go ahead" and all others are canceled.
     * @param {number} index
     * @private
     */
    startEventing_(index) {
        const recognizer = this.recognizers_[index];
        for (let i = 0; i < this.recognizers_.length; i++) {
            if (i != index) {
                this.cancelEventing_(i);
            }
        }
        this.ready_[index] = 0;
        this.pending_[index] = 0;
        this.eventing_ = recognizer;
        recognizer.acceptStart();
    }

    /**
     * @param {number} index
     * @private
     */
    startTracking_(index) {
        this.tracking_[index] = true;
        this.pending_[index] = 0;
    }

    /**
     * @param {number} index
     * @private
     */
    stopTracking_(index) {
        this.tracking_[index] = false;
        this.pending_[index] = 0;
        if (!this.ready_[index]) {
            this.recognizers_[index].acceptCancel();
        }
    }

    /**
     * @param {number} index
     * @private
     */
    cancelEventing_(index) {
        this.ready_[index] = 0;
        this.stopTracking_(index);
    }
}

/**
 * The gesture recognizer receives the pointer events from Gestures instance.
 * Based on these events, it can "recognize" the gesture it's responsible for,
 * request to start emitting and emit gestures. Gestures instances manages
 * several competing recognizers and decides which ones get to emit gestures
 * and which do not.
 *
 * The recognizer can be in several main states:
 * 1. Tracking state. In this state the recognizer is receiving the series of
 *    touch events from touchstart to touchend. To get into this state the
 *    recognizer has to return "true" from the {@link onTouchStart}.
 * 2. Pending state (optional). The recognizer matched part of the gesture,
 *    but needs more time to get track more events. It requests more time
 *    by calling {@link signalPending}, By the end of this time the recognizer
 *    has either matched the gesture or has been canceled.
 * 3. Ready state. The recognizer matched the whole gesture and ready to start
 *    emitting. It communicates to the Gestures this readiness by calling
 *    {@link signalReady}.
 * 5. Emitting state. If Gestures decides to go ahead with this recognizer, it
 *    will call {@link acceptStart} method. Otherwise, it will call
 *    {@link acceptCancel} method. Once in the emitting state, the recognizer
 *    can emit any number of events by calling {@link signalEmit}.
 * 6. Complete state. Once done, the recognizer can call {@link signalEnd} to
 *    communicate that it's done.
 *
 * @template DATA
 */
export class GestureRecognizer {
    /**
     * @param {string} type
     * @param {!Gestures} manager
     */
    constructor(type, manager) {
        /** @private @const {string} */
        this.type_ = type;

        /** @private @const {!Gestures} */
        this.manager_ = manager;
    }

    /**
     * Returns the type of the gesture emitted by the instance of this class.
     * It has to be unique in the scope of the Gestures instance.
     * @return {string}
     */
    getType() {
        return this.type_;
    }

    /**
     * The recognizer can call this method to communicate that it's ready to
     * start emitting the gesture. Optionally it can pass a zero, positive or
     * negative offset - a time on how much the gesture should be penalized or
     * given advantage in conflict resolution. The recognizer at this point is
     * in the "ready" state.
     * @param {time} offset
     */
    signalReady(offset) {
        this.manager_.signalReady_(this, offset);
    }

    /**
     * The recognizer can call this method to communicate that it needs more
     * time (timeLeft) to match the gesture. By the end of this time the
     * recognizer has to either transit to the ready state using
     * {@link signalReady} or it will be canceled. The recognizer is in the
     * "pending" state.
     * @param {time} timeLeft
     */
    signalPending(timeLeft) {
        this.manager_.signalPending_(this, timeLeft);
    }

    /**
     * The recognizer can call this method to communicate that it's done
     * emitting the gestures. It will return to the waiting state. Recognizer
     * can only call this method if it has previously received the
     * {@link acceptStart} call.
     */
    signalEnd() {
        this.manager_.signalEnd_(this);
    }

    /**
     * The recognizer can call this method to emit the gestures while in the
     * "emitting" state. Recognizer can only call this method if it has
     * previously received the {@link acceptStart} call.
     * @param {DATA} data
     * @param {?Event} event
     */
    signalEmit(data, event) {
        this.manager_.signalEmit_(this, data, event);
    }

    /**
     * The Gestures instance calls this method to allow the recognizer to start
     * emitting the gestures. At this point the recognizer is in the "emitting"
     * state. It will be in this state until it calls {@link signalEnd} or
     * the {@link acceptCancel} is called by the Gestures instance.
     */
    acceptStart() {}

    /**
     * The Gestures instance calls this method to reset the recognizer. At this
     * point the recognizer is in the initial waiting state.
     */
    acceptCancel() {}

    /**
     * The Gestures instance calls this method for each "touchstart" event. If
     * the recognizer wants to receive other touch events in the series, it has
     * to return "true".
     * @param {!Event} unusedEvent
     * @return {boolean}
     */
    onTouchStart(unusedEvent) {
        return false;
    }

    /**
     * The Gestures instance calls this method for each "touchmove" event. If
     * the recognizer wants to continue receiving touch events in the series,
     * it has to return "true".
     * @param {!Event} unusedEvent
     * @return {boolean}
     */
    onTouchMove(unusedEvent) {
        return false;
    }

    /**
     * The Gestures instance calls this method for the "touchend" event.
     * Somewhere within this touch series the recognizer has to call
     * {@link signalReady} or {@link signalPending} or it will be reset for the
     * next touch series.
     * @param {!Event} unusedEvent
     */
    onTouchEnd(unusedEvent) {}
}