import {
    PriorityQueue
} from '#core/data-structures/priority-queue';

import {
    Services
} from '#service';

import {
    getData
} from '#utils/event-helper';
import {
    dev
} from '#utils/log';

import {
    getServiceForDoc,
    registerServiceBuilderForDoc,
} from './service-helpers';
import {
    makeBodyVisibleRecovery
} from './style-installer';

/**
 * @const {string}
 */
const TAG = 'CHUNK';

/**
 * @type {boolean}
 */
let deactivated = /nochunking=1/.test(self.location.hash);
let allowLongTasks = false;

/**
 * @const {!Promise}
 */
const resolved = Promise.resolve();

/**
 * @param {!Element|!ShadowRoot|!./service/ampdoc-impl.AmpDoc} elementOrAmpDoc
 * @return {!Chunks}
 * @private
 */
function chunkServiceForDoc(elementOrAmpDoc) {
    registerServiceBuilderForDoc(elementOrAmpDoc, 'chunk', Chunks);
    return getServiceForDoc(elementOrAmpDoc, 'chunk');
}

/**
 * Run the given function. For visible documents the function will be
 * called in a micro task (Essentially ASAP). If the document is
 * not visible, tasks will yield to the event loop (to give the browser
 * time to do other things) and may even be further delayed until
 * there is time.
 *
 * @param {!Document|!./service/ampdoc-impl.AmpDoc} doc
 * @param {function(?IdleDeadline)} fn
 * @param {boolean=} opt_makesBodyVisible Pass true if this service makes
 *     the body visible. This is relevant because it may influence the
 *     task scheduling strategy.
 */
export function startupChunk(doc, fn, opt_makesBodyVisible) {
    if (deactivated) {
        resolved.then(fn);
        return;
    }
    const service = chunkServiceForDoc(doc.documentElement || doc);
    service.runForStartup(fn);
    if (opt_makesBodyVisible) {
        service.runForStartup(() => {
            service.bodyIsVisible_ = true;
        });
    }
}

/**
 * Run the given function sometime in the future without blocking UI.
 *
 * Higher priority tasks are executed before lower priority tasks.
 * Tasks with the same priority are executed in FIFO order.
 *
 * Uses `requestIdleCallback` if available and passes the `IdleDeadline`
 * object to the function, which can be used to perform a variable amount
 * of work depending on the remaining amount of idle time.
 *
 * @param {!Element|!ShadowRoot|!./service/ampdoc-impl.AmpDoc} elementOrAmpDoc
 * @param {function(?IdleDeadline)} fn
 * @param {ChunkPriority_Enum} priority
 */
export function chunk(elementOrAmpDoc, fn, priority) {
    if (deactivated) {
        resolved.then(fn);
        return;
    }
    const service = chunkServiceForDoc(elementOrAmpDoc);
    service.run(fn, priority);
}

/**
 * @param {!Element|!./service/ampdoc-impl.AmpDoc} elementOrAmpDoc
 * @return {!Chunks}
 */
export function chunkInstanceForTesting(elementOrAmpDoc) {
    return chunkServiceForDoc(elementOrAmpDoc);
}

/**
 * Use a standard micro task for every invocation. This should only
 * be called from the AMP bootstrap script if it is known that
 * chunking makes no sense. In particular this is the case when
 * AMP runs in the `amp-shadow` multi document mode.
 */
export function deactivateChunking() {
    deactivated = true;
}

/**
 * Allow continuing macro tasks after a long task (>5ms).
 * In particular this is the case when AMP runs in the `amp-inabox` ads mode.
 */
export function allowLongTasksInChunking() {
    allowLongTasks = true;
}

/**
 * @visibleForTesting
 */
export function activateChunkingForTesting() {
    deactivated = false;
}

/**
 * Runs all currently scheduled chunks.
 * Independent of errors it will unwind the queue. Will afterwards
 * throw the first encountered error.
 * @param {!Element|!./service/ampdoc-impl.AmpDoc} elementOrAmpDoc
 */
export function runChunksForTesting(elementOrAmpDoc) {
    const service = chunkInstanceForTesting(elementOrAmpDoc);
    const errors = [];
    while (true) {
        try {
            if (!service.execute_( /* idleDeadline */ null)) {
                break;
            }
        } catch (e) {
            errors.push(e);
        }
    }
    if (errors.length) {
        throw errors[0];
    }
}

/**
 * The priority of a chunk task. Higher priority tasks have higher values.
 * @enum {number}
 */
export const ChunkPriority_Enum = {
    HIGH: 20,
    LOW: 10,
    BACKGROUND: 0,
};

/** @enum {string} */
const TaskState_Enum = {
    NOT_RUN: 'not_run',
    RUN: 'run',
};

/**
 * A default chunkable task.
 * @private
 */
class Task {
    /**
     * @param {function(?IdleDeadline)} fn
     */
    constructor(fn) {
        /** @public {TaskState_Enum} */
        this.state = TaskState_Enum.NOT_RUN;

        /** @private @const {!function(?IdleDeadline)} */
        this.fn_ = fn;
    }

    /**
     * Executes the wrapped function.
     * @param {?IdleDeadline} idleDeadline
     * @throws {Error}
     * @protected
     */
    runTask_(idleDeadline) {
        if (this.state == TaskState_Enum.RUN) {
            return;
        }
        this.state = TaskState_Enum.RUN;
        try {
            this.fn_(idleDeadline);
        } catch (e) {
            this.onTaskError_(e);
            throw e;
        }
    }

    /**
     * @return {string}
     * @protected
     */
    getName_() {
        return this.fn_.displayName || this.fn_.name;
    }

    /**
     * Optional handling when a task run throws an error.
     * @param {Error} unusedError
     * @private
     */
    onTaskError_(unusedError) {
        // By default, no-op.
    }

    /**
     * Returns true if this task should be run without delay.
     * @return {boolean}
     * @protected
     */
    immediateTriggerCondition_() {
        // By default, there are no immediate trigger conditions.
        return false;
    }

    /**
     * Returns true if this task should be scheduled using `requestIdleCallback`.
     * Otherwise, task is scheduled as macro-task on next event loop.
     * @return {boolean}
     * @protected
     */
    useRequestIdleCallback_() {
        // By default, never use requestIdleCallback.
        return false;
    }
}

/**
 * A task that's run as part of AMP's startup sequence.
 * @private
 */
class StartupTask extends Task {
    /**
     * @param {function(?IdleDeadline)} fn
     * @param {!Window} win
     * @param {!Chunks} chunks
     */
    constructor(fn, win, chunks) {
        super(fn);

        /** @private @const */
        this.chunks_ = chunks;
    }

    /** @override */
    onTaskError_(unusedError) {
        // Startup tasks run early in init. All errors should show the doc.
        makeBodyVisibleRecovery(self.document);
    }

    /** @override */
    immediateTriggerCondition_() {
        // Run in a micro task when the doc is visible. Otherwise, run after
        // having yielded to the event queue once.
        return this.isVisible_();
    }

    /** @override */
    useRequestIdleCallback_() {
        // We only start using requestIdleCallback when the core runtime has
        // been initialized. Otherwise we risk starving ourselves
        // before the render-critical work is done.
        return this.chunks_.coreReady_;
    }

    /**
     * @return {boolean}
     * @private
     */
    isVisible_() {
        return this.chunks_.ampdoc.isVisible();
    }
}

/**
 * Handles queueing, scheduling and executing tasks.
 */
class Chunks {
    /**
     * @param {!./service/ampdoc-impl.AmpDoc} ampDoc
     */
    constructor(ampDoc) {
        /** @protected @const {!./service/ampdoc-impl.AmpDoc} */
        this.ampdoc = ampDoc;
        /** @private @const {!Window} */
        this.win_ = ampDoc.win;
        /** @private @const {!PriorityQueue<Task>} */
        this.tasks_ = new PriorityQueue();
        /** @private @const {function(?IdleDeadline)} */
        this.boundExecute_ = this.execute_.bind(this);
        /** @private {number} */
        this.durationOfLastExecution_ = 0;
        /** @private @const {boolean} */
        this.supportsInputPending_ = !!(
            this.win_.navigator.scheduling &&
            this.win_.navigator.scheduling.isInputPending
        );

        /**
         * Set to true if we scheduled a macro or micro task to execute the next
         * task. If true, we don't schedule another one.
         * Not set to true if we use rIC, because we always want to transition
         * to immeditate invocation from that state.
         * @private {boolean}
         */
        this.scheduledImmediateInvocation_ = false;
        /** @private {boolean} Whether the document can actually be painted. */
        this.bodyIsVisible_ = this.win_.document.documentElement.hasAttribute(
            'i-amphtml-no-boilerplate'
        );

        this.win_.addEventListener('message', (e) => {
            if (getData(e) == 'amp-macro-task') {
                this.execute_( /* idleDeadline */ null);
            }
        });

        /** @protected {boolean} */
        this.coreReady_ = false;
        Services.viewerPromiseForDoc(ampDoc).then(() => {
            // Once the viewer has been resolved, most of core runtime has been
            // initialized as well.
            this.coreReady_ = true;
        });

        ampDoc.onVisibilityChanged(() => {
            if (ampDoc.isVisible()) {
                this.schedule_();
            }
        });
    }

    /**
     * Run fn as a "chunk".
     * @param {function(?IdleDeadline)} fn
     * @param {number} priority
     */
    run(fn, priority) {
        const t = new Task(fn);
        this.enqueueTask_(t, priority);
    }

    /**
     * Run a fn that's part of AMP's startup sequence as a "chunk".
     * @param {function(?IdleDeadline)} fn
     */
    runForStartup(fn) {
        const t = new StartupTask(fn, this.win_, this);
        this.enqueueTask_(t, Number.POSITIVE_INFINITY);
    }

    /**
     * Queues a task to be executed later with given priority.
     * @param {!Task} task
     * @param {number} priority
     * @private
     */
    enqueueTask_(task, priority) {
        this.tasks_.enqueue(task, priority);
        this.schedule_();
    }

    /**
     * Returns the next task that hasn't been run yet.
     * If `opt_dequeue` is true, remove the returned task from the queue.
     * @param {boolean=} opt_dequeue
     * @return {?Task}
     * @private
     */
    nextTask_(opt_dequeue) {
        let t = this.tasks_.peek();
        // Dequeue tasks until we find one that hasn't been run yet.
        while (t && t.state !== TaskState_Enum.NOT_RUN) {
            this.tasks_.dequeue();
            t = this.tasks_.peek();
        }
        // If `opt_dequeue` is true, remove this task from the queue.
        if (t && opt_dequeue) {
            this.tasks_.dequeue();
        }
        return t;
    }

    /**
     * Run a task.
     * Schedule the next round if there are more tasks.
     * @param {?IdleDeadline} idleDeadline
     * @return {boolean} Whether anything was executed.
     * @private
     */
    execute_(idleDeadline) {
        const t = this.nextTask_( /* opt_dequeue */ true);
        if (!t) {
            this.scheduledImmediateInvocation_ = false;
            this.durationOfLastExecution_ = 0;
            return false;
        }
        let before;
        try {
            before = Date.now();
            t.runTask_(idleDeadline);
        } finally {
            // We want to capture the time of the entire task duration including
            // scheduled immediate (from resolved promises) micro tasks.
            // Lacking a better way to do this we just scheduled 10 nested
            // micro tasks.
            resolved
                .then()
                .then()
                .then()
                .then()
                .then()
                .then()
                .then()
                .then()
                .then(() => {
                    this.scheduledImmediateInvocation_ = false;
                    this.durationOfLastExecution_ += Date.now() - before;
                    dev().fine(
                        TAG,
                        t.getName_(),
                        'Chunk duration',
                        Date.now() - before,
                        this.durationOfLastExecution_
                    );

                    this.schedule_();
                });
        }
        return true;
    }

    /**
     * Calls `execute_()` asynchronously.
     * @param {?IdleDeadline} idleDeadline
     * @private
     */
    executeAsap_(idleDeadline) {
        // If the user-agent supports isInputPending, use it to break to a macro task as necessary.
        // Otherwise If we've spent over 5 millseconds executing the
        // last instruction yield back to the main thread.
        // 5 milliseconds is a magic number.
        if (!allowLongTasks &&
            this.bodyIsVisible_ &&
            (this.supportsInputPending_ ?
                /** @type {!{scheduling: {isInputPending: Function}}} */ (
                    this.win_.navigator
                ).scheduling.isInputPending() :
                this.durationOfLastExecution_ > 5)
        ) {
            this.durationOfLastExecution_ = 0;
            this.requestMacroTask_();
            return;
        }
        resolved.then(() => {
            this.boundExecute_(idleDeadline);
        });
    }

    /**
     * Schedule running the next queued task.
     * @private
     */
    schedule_() {
        if (this.scheduledImmediateInvocation_) {
            return;
        }
        const nextTask = this.nextTask_();
        if (!nextTask) {
            return;
        }
        if (nextTask.immediateTriggerCondition_()) {
            this.scheduledImmediateInvocation_ = true;
            this.executeAsap_( /* idleDeadline */ null);
            return;
        }
        // If requestIdleCallback exists, schedule a task with it, but
        // do not wait longer than two seconds.
        if (nextTask.useRequestIdleCallback_() && this.win_.requestIdleCallback) {
            onIdle(
                this.win_,
                // Wait until we have a budget of at least 15ms.
                // 15ms is a magic number. Budgets are higher when the user
                // is completely idle (around 40), but that occurs too
                // rarely to be usable. 15ms budgets can happen during scrolling
                // but only if the device is doing super, super well, and no
                // real processing is done between frames.
                15 /* minimumTimeRemaining */ ,
                2000 /* timeout */ ,
                this.boundExecute_
            );
            return;
        }
        this.requestMacroTask_();
    }

    /**
     * Requests executing of a macro task. Yields to the event queue
     * before executing the task.
     * Places task on browser message queue which then respectively
     * triggers dequeuing and execution of a chunk.
     */
    requestMacroTask_() {
        // The message doesn't actually matter.
        this.win_. /*OK*/ postMessage('amp-macro-task', '*');
    }
}

/**
 * Delays calling the given function until the browser is notifying us
 * about a certain minimum budget or the timeout is reached.
 * @param {!Window} win
 * @param {number} minimumTimeRemaining Minimum number of millis idle
 *     budget for callback to fire.
 * @param {number} timeout in millis for callback to fire.
 * @param {function(?IdleDeadline)} fn Callback.
 * @visibleForTesting
 */
export function onIdle(win, minimumTimeRemaining, timeout, fn) {
    const startTime = Date.now();
    /**
     * @param {!IdleDeadline} info
     */
    function rIC(info) {
        if (info.timeRemaining() < minimumTimeRemaining) {
            const remainingTimeout = timeout - (Date.now() - startTime);
            if (remainingTimeout <= 0 || info.didTimeout) {
                dev().fine(TAG, 'Timed out', timeout, info.didTimeout);
                fn(info);
            } else {
                dev().fine(
                    TAG,
                    'Rescheduling with',
                    remainingTimeout,
                    info.timeRemaining()
                );
                win.requestIdleCallback(rIC, {
                    timeout: remainingTimeout
                });
            }
        } else {
            dev().fine(TAG, 'Running idle callback with ', minimumTimeRemaining);
            fn(info);
        }
    }
    win.requestIdleCallback(rIC, {
        timeout
    });
}