import {
    Deferred
} from '#core/data-structures/promise';
import {
    addDocumentVisibilityChangeListener,
    isDocumentHidden,
    removeDocumentVisibilityChangeListener,
} from '#core/document/visibility';
import {
    rethrowAsync
} from '#core/error';

import {
    Services
} from '#service';

import {
    dev,
    devAssert
} from '#utils/log';

import {
    installTimerService
} from './timer-impl';

import {
    cancellation
} from '../error-reporting';
import {
    Pass
} from '../pass';
import {
    getService,
    registerServiceBuilder
} from '../service-helpers';

/** @const {time} */
const FRAME_TIME = 16;

/**
 * @typedef {!Object<string, *>}
 */
let VsyncStateDef;

/**
 * @typedef {{
 *   measure: (function(!VsyncStateDef):undefined|undefined),
 *   mutate: (function(!VsyncStateDef):undefined|undefined)
 * }}
 */
let VsyncTaskSpecDef;

/**
 * Abstraction over requestAnimationFrame (rAF) that batches DOM read (measure)
 * and write (mutate) tasks in a single frame, to eliminate layout thrashing.
 *
 * NOTE: If the document is invisible due to prerendering (this includes
 * application-level prerendering where the doc is rendered in a hidden
 * iframe or webview), then no frame will be scheduled.
 * @package Visible for type.
 * @implements {../service.Disposable}
 */
export class Vsync {
    /**
     * @param {!Window} win
     */
    constructor(win) {
        /** @const {!Window} */
        this.win = win;

        /** @private @const {!./ampdoc-impl.AmpDocService} */
        this.ampdocService_ = Services.ampdocServiceFor(this.win);

        /** @private @const {function(function())}  */
        this.raf_ = this.getRaf_();

        /**
         * Tasks to run in the next frame.
         * @private {!Array<!VsyncTaskSpecDef>}
         */
        this.tasks_ = [];

        /**
         * Double buffer for tasks.
         * @private {!Array<!VsyncTaskSpecDef>}
         */
        this.nextTasks_ = [];

        /**
         * States for tasks in the next frame in the same order.
         * @private {!Array<!VsyncStateDef>}
         */
        this.states_ = [];

        /**
         * Double buffer for states.
         * @private {!Array<!VsyncStateDef>}
         */
        this.nextStates_ = [];

        /**
         * Whether a new animation frame has been scheduled.
         * @private {boolean}
         */
        this.scheduled_ = false;

        /** @private {?Promise} */
        this.nextFramePromise_ = null;

        /** @protected {?function()} */
        this.nextFrameResolver_ = null;

        /** @const {!Function} */
        this.boundRunScheduledTasks_ = this.runScheduledTasks_.bind(this);

        /**
         * If the doc is invisible we use this instead of rAF because rAF
         * does not run in that scenario.
         * However, we only do this for non-animation tasks as running
         * animations doesn't make sense when not visible.
         * @const {!Pass}
         */
        this.invisiblePass_ = new Pass(
            this.win,
            this.boundRunScheduledTasks_,
            FRAME_TIME
        );

        /**
         * Similar to this.invisiblePass_, but backing up a real rAF call. If we
         * somehow failed to know that we are throttled we use a timer (which
         * may also be throttled but at least runs eventually) to make sure
         * we continue to get work done.
         * @const {!Pass}
         */
        this.backupPass_ = new Pass(
            this.win,
            this.boundRunScheduledTasks_,
            // We cancel this when rAF fires and really only want it to fire
            // if rAF doesn't work at all.
            FRAME_TIME * 2.5
        );

        // When the document changes visibility, vsync has to reschedule the queue
        // processing.
        /** @private {function()} */
        this.boundOnVisibilityChanged_ = this.onVisibilityChanged_.bind(this);
        if (this.ampdocService_.isSingleDoc()) {
            // In a single-doc mode, the visibility of the doc == global visibility.
            // Thus, it's more efficient to only listen to it once.
            this.ampdocService_
                .getSingleDoc()
                .onVisibilityChanged(this.boundOnVisibilityChanged_);
        } else {
            // In multi-doc mode, we track separately the global visibility and
            // per-doc visibility when necessary.
            addDocumentVisibilityChangeListener(
                this.win.document,
                this.boundOnVisibilityChanged_
            );
        }
    }

    /** @override */
    dispose() {
        removeDocumentVisibilityChangeListener(
            this.win.document,
            this.boundOnVisibilityChanged_
        );
    }

    /** @private */
    onVisibilityChanged_() {
        if (this.scheduled_) {
            this.forceSchedule_();
        }
    }

    /**
     * Runs vsync task: measure followed by mutate.
     *
     * If state is not provided, the value passed to the measure and mutate
     * will be undefined.
     *
     * @param {!VsyncTaskSpecDef} task
     * @param {!VsyncStateDef=} opt_state
     */
    run(task, opt_state) {
        this.tasks_.push(task);
        this.states_.push(opt_state || undefined);
        this.schedule_();
    }

    /**
     * Runs vsync task: measure followed by mutate. Returns the promise that
     * will be resolved as soon as the task has been completed.
     *
     * If state is not provided, the value passed to the measure and mutate
     * will be undefined.
     *
     * @param {!VsyncTaskSpecDef} task
     * @param {!VsyncStateDef=} opt_state
     * @return {!Promise}
     */
    runPromise(task, opt_state) {
        this.run(task, opt_state);
        if (this.nextFramePromise_) {
            return this.nextFramePromise_;
        }
        const deferred = new Deferred();
        this.nextFrameResolver_ = deferred.resolve;
        return (this.nextFramePromise_ = deferred.promise);
    }

    /**
     * Creates a function that will call {@link run} method.
     * @param {!VsyncTaskSpecDef} task
     * @return {function(!VsyncStateDef=)}
     */
    createTask(task) {
        return /** @type {function(!VsyncStateDef=)} */ (
            (opt_state) => {
                this.run(task, opt_state);
            }
        );
    }

    /**
     * Runs the mutate operation via vsync.
     * @param {function():undefined} mutator
     */
    mutate(mutator) {
        this.run({
            measure: undefined, // For uniform hidden class.
            mutate: mutator,
        });
    }

    /**
     * Runs `mutate` wrapped in a promise.
     * @param {function():undefined} mutator
     * @return {!Promise}
     */
    mutatePromise(mutator) {
        return this.runPromise({
            measure: undefined,
            mutate: mutator,
        });
    }

    /**
     * Runs the measure operation via vsync.
     * @param {function():undefined} measurer
     */
    measure(measurer) {
        this.run({
            measure: measurer,
            mutate: undefined, // For uniform hidden class.
        });
    }

    /**
     * Runs `measure` wrapped in a promise.
     * @param {function():TYPE} measurer
     * @return {!Promise<TYPE>}
     * @template TYPE
     */
    measurePromise(measurer) {
        return new Promise((resolve) => {
            this.measure(() => {
                resolve(measurer());
            });
        });
    }

    /**
     * Whether the runtime is allowed to animate at this time.
     * @param {!Node} contextNode
     * @return {boolean}
     */
    canAnimate(contextNode) {
        return this.canAnimate_(devAssert(contextNode));
    }

    /**
     * @param {!Node=} opt_contextNode
     * @return {boolean}
     * @private
     */
    canAnimate_(opt_contextNode) {
        // Window level: animations allowed only when global window is visible.
        if (isDocumentHidden(this.win.document)) {
            return false;
        }

        // Single doc: animations allowed when single doc is visible.
        if (this.ampdocService_.isSingleDoc()) {
            return this.ampdocService_.getSingleDoc().isVisible();
        }

        // Multi-doc: animations depend on the state of the relevant doc.
        if (opt_contextNode) {
            const ampdoc = this.ampdocService_.getAmpDocIfAvailable(opt_contextNode);
            return !ampdoc || ampdoc.isVisible();
        }

        return true;
    }

    /**
     * Runs the animation vsync task. This operation can only run when animations
     * are allowed. Otherwise, this method returns `false` and exits.
     * @param {!Node} contextNode
     * @param {!VsyncTaskSpecDef} task
     * @param {!VsyncStateDef=} opt_state
     * @return {boolean}
     */
    runAnim(contextNode, task, opt_state) {
        // Do not request animation frames when the document is not visible.
        if (!this.canAnimate_(contextNode)) {
            dev().warn(
                'VSYNC',
                'Did not schedule a vsync request, because document was invisible'
            );
            return false;
        }
        this.run(task, opt_state);
        return true;
    }

    /**
     * Creates an animation vsync task. This operation can only run when
     * animations are allowed. Otherwise, this closure returns `false` and exits.
     * @param {!Node} contextNode
     * @param {!VsyncTaskSpecDef} task
     * @return {function(!VsyncStateDef=):boolean}
     */
    createAnimTask(contextNode, task) {
        return /** @type {function(!VsyncStateDef=):boolean} */ (
            (opt_state) => {
                return this.runAnim(contextNode, task, opt_state);
            }
        );
    }

    /**
     * Runs the series of mutates until the mutator returns a false value.
     * @param {!Node} contextNode
     * @param {function(time, time, !VsyncStateDef):boolean} mutator The
     *   mutator callback. Only expected to do DOM writes, not reads. If the
     *   returned value is true, the vsync task will be repeated, otherwise it
     *   will be completed. The arguments are: timeSinceStart:time,
     *   timeSincePrev:time and state:VsyncStateDef.
     * @param {number=} opt_timeout Optional timeout that will force the series
     *   to complete and reject the promise.
     * @return {!Promise} Returns the promise that will either resolve on when
     *   the vsync series are completed or reject in case of failure, such as
     *   timeout.
     */
    runAnimMutateSeries(contextNode, mutator, opt_timeout) {
        if (!this.canAnimate_(contextNode)) {
            return Promise.reject(cancellation());
        }
        return new Promise((resolve, reject) => {
            const startTime = Date.now();
            let prevTime = 0;
            const task = this.createAnimTask(contextNode, {
                mutate: (state) => {
                    const timeSinceStart = Date.now() - startTime;
                    const res = mutator(timeSinceStart, timeSinceStart - prevTime, state);
                    if (!res) {
                        resolve();
                    } else if (opt_timeout && timeSinceStart > opt_timeout) {
                        reject(new Error('timeout'));
                    } else {
                        prevTime = timeSinceStart;
                        task(state);
                    }
                },
            });
            task({});
        });
    }

    /** @private */
    schedule_() {
        if (this.scheduled_) {
            return;
        }
        // Schedule actual animation frame and then run tasks.
        this.scheduled_ = true;
        this.forceSchedule_();
    }

    /** @private */
    forceSchedule_() {
        if (this.canAnimate_()) {
            this.raf_(this.boundRunScheduledTasks_);
            this.backupPass_.schedule();
        } else {
            this.invisiblePass_.schedule();
        }
    }

    /**
     * Runs all scheduled tasks. This is typically called in an RAF
     * callback. Tests may call this method to force execution of
     * tasks without waiting.
     * @private
     */
    runScheduledTasks_() {
        this.backupPass_.cancel();
        this.scheduled_ = false;

        const {
            nextFrameResolver_: resolver,
            states_: states,
            tasks_: tasks
        } = this;
        this.nextFrameResolver_ = null;
        this.nextFramePromise_ = null;
        // Double buffering
        this.tasks_ = this.nextTasks_;
        this.states_ = this.nextStates_;
        for (let i = 0; i < tasks.length; i++) {
            if (tasks[i].measure) {
                if (!callTask_(tasks[i].measure, states[i])) {
                    // Ensure that the mutate is not executed when measure fails.
                    tasks[i].mutate = undefined;
                }
            }
        }
        for (let i = 0; i < tasks.length; i++) {
            if (tasks[i].mutate) {
                callTask_(tasks[i].mutate, states[i]);
            }
        }
        // Swap last arrays into double buffer.
        this.nextTasks_ = tasks;
        this.nextStates_ = states;
        this.nextTasks_.length = 0;
        this.nextStates_.length = 0;
        if (resolver) {
            resolver();
        }
    }

    /**
     * @return {function(function())} requestAnimationFrame or polyfill.
     */
    getRaf_() {
        const raf =
            this.win.requestAnimationFrame || this.win.webkitRequestAnimationFrame;
        if (raf) {
            return raf.bind(this.win);
        }
        let lastTime = 0;
        return (fn) => {
            const now = Date.now();
            // By default we take 16ms between frames, but if the last frame is say
            // 10ms ago, we only want to wait 6ms.
            const timeToCall = Math.max(0, FRAME_TIME - (now - lastTime));
            lastTime = now + timeToCall;
            this.win.setTimeout(fn, timeToCall);
        };
    }
}

/**
 * For optimization reasons to stop try/catch from blocking optimization.
 * @param {function(!VsyncStateDef):undefined|undefined} callback
 * @param {!VsyncStateDef} state
 * @return {boolean}
 */
function callTask_(callback, state) {
    devAssert(callback);
    try {
        const ret = callback(state);
        if (ret !== undefined) {
            dev().error(
                'VSYNC',
                'callback returned a value but vsync cannot propogate it: %s',
                callback.toString()
            );
        }
    } catch (e) {
        rethrowAsync(e);
        return false;
    }
    return true;
}

/**
 * @param {!Window} window
 * @return {!Vsync}
 */
export function vsyncForTesting(window) {
    installVsyncService(window);
    return getService(window, 'vsync');
}

/**
 * @param {!Window} window
 */
export function installVsyncService(window) {
    installTimerService(window);
    registerServiceBuilder(window, 'vsync', Vsync);
}