import { assert } from "../../assert.js";
import Signal from "../../events/signal/Signal.js";
import { IllegalStateException } from "../../fsm/exceptions/IllegalStateException.js";
import { objectKeyByValue } from "../../model/object/objectKeyByValue.js";
import { TaskSignal } from "../task/TaskSignal.js";
import TaskState from "../task/TaskState.js";

/**
 *
 * @param {TaskGroup|Task} t
 * @returns {boolean}
 */
function isGroupTask(t) {
    return t.children instanceof Array;
}

/**
 * @class
 */
class ConcurrentExecutor {
    /**
     * How many time-slice cycles have been executed this far. This is a monotonically increasing counter
     * @type {number}
     */
    #cycle_count = 0;

    /**
     * Handle of the last scheduled `setTimeout`
     * @type {number}
     */
    #timeout_handle = -1;

    /**
     *
     * @param {number} [quietTime] in milliseconds
     * @param {number} [workTime] in milliseconds
     * @constructor
     */
    constructor(quietTime = 1, workTime = 15) {
        /**
         *
         * @type {number}
         */
        this.quietTime = quietTime;
        /**
         *
         * @type {number}
         */
        this.workTime = workTime;

        /**
         * Tasks in state pending resolution or initial sate are put here
         * @type {Task[]}
         */
        this.queueUnresolved = [];

        /**
         * ready tasks are those who's dependencies have all been completed
         * @type {Task[]}
         */
        this.queueReady = [];

        this.on = {
            task_started: new Signal(),
            task_completed: new Signal(),
            completed: new Signal()
        };

        this.busy = false;

        /**
         *
         * @type {number|SchedulingPolicy}
         */
        this.policy = SchedulingPolicy.ROUND_ROBIN;
    }

    /**
     *
     * @param {TaskGroup} taskGroup
     */
    runGroup(taskGroup) {
        assert.equal(taskGroup.isTaskGroup, true, 'taskGroup.isTaskGroup !== true');

        const currentState = taskGroup.state.getValue();
        if (currentState !== TaskState.INITIAL) {
            throw new IllegalStateException(`Expected task state INITIAL, instead got ${objectKeyByValue(TaskState, currentState)}`);
        }

        const self = this;

        const children = taskGroup.children;
        const numChildren = children.length;
        let pendingCount = numChildren;

        let resolved = false;

        function signalSuccess() {
            if (!resolved) {
                resolved = true;

                taskGroup.state.set(TaskState.SUCCEEDED);
                taskGroup.on.completed.send0();

                self.prod();
            }
        }

        function signalFailure(reason) {
            if (!resolved) {
                resolved = true;

                taskGroup.state.set(TaskState.FAILED);
                taskGroup.on.failed.send1(reason);

                self.prod();
            }
        }

        function subTaskCompleted() {
            pendingCount--;
            if (pendingCount <= 0) {
                signalSuccess();
            }
        }

        function subTaskFailed(reason) {
            signalFailure(reason);
        }


        if (numChildren > 0) {
            taskGroup.state.set(TaskState.RUNNING);
            let i = 0;
            for (; i < numChildren; i++) {
                const child = children[i];
                child.on.completed.add(subTaskCompleted);
                child.on.failed.add(subTaskFailed);
                if (isGroupTask(child)) {
                    this.runGroup(child);
                } else {
                    this.run(child);
                }
            }
        } else {
            //no children, succeed immediately
            signalSuccess();
        }
    }

    /**
     *
     * @param {TaskGroup} group
     */
    removeGroup(group) {
        const children = group.children;
        const n = children.length;

        for (let i = 0; i < n; i++) {
            const child = children[i];

            if (isGroupTask(child)) {
                this.removeGroup(child);
            } else {
                this.removeTask(child);
            }
        }
    }

    /**
     *
     * @param {Task} task
     * @return {boolean}
     */
    removeTask(task) {
        //console.warn("Removing task:", task);

        const readyIndex = this.queueReady.indexOf(task);

        if (readyIndex !== -1) {
            //is in ready queue, remove
            this.queueReady.splice(readyIndex, 1);
            return true;
        }

        const unresolvedIndex = this.queueUnresolved.indexOf(task);

        if (unresolvedIndex !== -1) {
            //found in unresolved queue, remove
            this.queueUnresolved.splice(unresolvedIndex, 1);

            return true;
        }

        //not found
        return false;
    }

    /**
     *
     * @param {Task} task
     */
    run(task) {
        this.queueUnresolved.push(task);
        this.prod();
    }

    /**
     * Shortcut for {@link #run} method for scheduling multiple tasks at once
     * @param {Task[]} tasks
     */
    runMany(tasks) {
        Array.prototype.push.apply(this.queueUnresolved, tasks);

        this.prod();
    }

    /**
     * @private
     * @param {Task} task
     * @returns {boolean}
     */
    startTask(task) {
        const timeInitializationStart = performance.now();

        try {
            task.initialize(task, this);
        } catch (e) {
            console.error(`Task initialization failed`, task, e);

            task.state.set(TaskState.FAILED);

            task.on.failed.dispatch(e);

            return false;
        } finally {
            const timeInitializationEnd = performance.now();

            const timeInitializationDuration = timeInitializationEnd - timeInitializationStart;

            task.__executedCpuTime += timeInitializationDuration;
        }

        // console.log("Starting task", task);

        task.state.set(TaskState.RUNNING);

        //dispatch start notification
        task.on.started.send1(this);

        //add to the queue
        this.queueReady.push(task);

        this.on.task_started.send1(task);

        return true;
    }

    /**
     * Go through unresolved queue and move tasks whose dependencies have been completed to ready queue or fail them
     */
    resolveTasks() {
        const queueUnresolved = this.queueUnresolved;

        let i = 0, l = queueUnresolved.length;
        for (; i < l; i++) {
            const unresolvedTask = queueUnresolved[i];
            const resolution = tryResolve(unresolvedTask);
            switch (resolution) {
                case ResolutionType.READY:
                    //remove task from unresolved queue to prevent infinite recursion in case "resolveTasks" is attempted again inside task initializer
                    queueUnresolved.splice(i, 1);

                    //set state of task to READY
                    unresolvedTask.state.set(TaskState.READY);

                    //attempt to start the task
                    this.startTask(unresolvedTask);

                    //task start could have altered unresolved queue, re-initialize iteration
                    i = 0;
                    l = queueUnresolved.length;

                    break;
                case ResolutionType.FAILED:
                    queueUnresolved.splice(i, 1);
                    l--;
                    i--;
                    break;
            }
        }
    }

    /**
     *
     * @param {Task} task
     * @returns {boolean}
     */
    contains(task) {
        if (this.queueUnresolved.indexOf(task) !== -1) {
            return true;
        }

        if (this.queueReady.indexOf(task) !== -1) {
            return true;
        }

        return false;
    }

    /**
     *
     * @return {Task|undefined}
     */
    #pick_next_task() {
        const ready = this.queueReady;

        switch (this.policy) {
            case ConcurrentExecutor.POLICY.ROUND_ROBIN:
                return ready[this.#cycle_count % ready.length];
            default:
                console.warn('Unknown scheduling policy: ', this.policy, 'Defaulting to sequential');
            // fallthrough
            case ConcurrentExecutor.POLICY.SEQUENTIAL:
                return ready[0];
        }
    }

    /**
     *
     * @param {Task} task
     */
    #complete_task(task) {
        const readyTasks = this.queueReady;

        const taskIndex = readyTasks.indexOf(task);

        if (taskIndex !== -1) {
            readyTasks.splice(taskIndex, 1);
        } else {
            console.error("Failed to remove ready task, not found in the ready queue", task, readyTasks.slice());
        }


        task.state.set(TaskState.SUCCEEDED);
        task.on.completed.send1(this);

        this.resolveTasks();

        this.on.task_completed.send1(task);
        // console.warn(`Task complete '${task.name}', cycles=${task.__executedCycleCount}, time=${task.__executedCpuTime}`);

    }

    /**
     *
     * @param {Task} task
     * @param {*} [reason]
     */
    #fail_task(task, reason) {
        const readyTasks = this.queueReady;
        const taskIndex = readyTasks.indexOf(task);

        if (taskIndex !== -1) {
            readyTasks.splice(taskIndex, 1);
        } else {
            console.error("Failed to remove ready task, not found in the ready queue", task, readyTasks.slice());
        }

        task.state.set(TaskState.FAILED);
        task.on.failed.send1(reason);

        if (!task.on.failed.hasHandlers()) {
            // no one is watching for task failure
            console.warn(`Task '${task.name}' failed.`, reason);
        }

        this.resolveTasks();

        this.on.task_completed.send1(task);
    }

    /**
     *
     * @param {Task} task
     * @param {number} time in milliseconds
     * @param {function} completionCallback
     * @param {function} failureCallback
     */
    #runTaskForTimeMonitored(task, time) {
        let cycle_count = 0;

        /**
         *
         * @type {function(): TaskSignal}
         */
        const cycle = task.cycle;

        const startTime = performance.now();

        const endTime = startTime + time;

        //We use tiny delta to avoid problems with timer accuracy on some systems that result in 0 measured execution time
        let t = startTime + 0.000001;

        let signal;

        while (t < endTime) {
            cycle_count++;

            signal = cycle();
            t = performance.now();

            if (signal === TaskSignal.Continue) {
                continue;
            }

            if (signal === TaskSignal.Yield) {
                //give up current quanta
                break;
            }

            if (signal === TaskSignal.EndSuccess) {
                break;
            } else if (signal === TaskSignal.EndFailure) {
                break;
            } else {
                throw new Error(`Task '${task.name}' produced unknown signal: ` + signal);
            }
        }

        const executionDuration = t - startTime;

        task.__executedCpuTime += executionDuration;
        task.__executedCycleCount += cycle_count;

        if (signal === TaskSignal.EndSuccess) {
            this.#complete_task(task);
        } else if (signal === TaskSignal.EndFailure) {
            this.#fail_task(task, "Task signalled failure");
        }

        return executionDuration;
    }

    #bound_executeTimeSlice = this.#executeTimeSlice.bind(this);

    #executeTimeSlice() {
        let sliceTimeLeft = this.workTime;

        let executionTime = 0;

        const queueReady = this.queueReady;

        while (queueReady.length > 0) {
            const task = this.#pick_next_task();

            if (task === undefined) {
                console.warn('Next task not found, likely result of removing task mid-execution');
                break;
            }

            try {
                executionTime = this.#runTaskForTimeMonitored(task, sliceTimeLeft);
            } catch (e) {
                const error = new Error("Task threw an exception");

                error.cause = e;

                // console.error(`Task threw an exception`, task, e);
                this.#fail_task(task, error);
            }

            this.#cycle_count++;

            //make sure that execution time that we subtract from current CPU slice is always reducing the slice
            sliceTimeLeft -= Math.max(executionTime, 1);

            if (sliceTimeLeft <= 0) {
                break;
            }
        }

        if (this.queueReady.length === 0) {
            this.busy = false;
            this.on.completed.send0();
        } else {
            //schedule next time slice
            this.#timeout_handle = setTimeout(this.#bound_executeTimeSlice, this.quietTime);
        }
    }

    /**
     * kicks the scheduler into action, this is an internal method and should not be called from outside
     * @private
     */
    prod() {
        this.resolveTasks();

        if (!this.busy && this.queueReady.length > 0) {
            this.busy = true;
            this.#executeTimeSlice();
        }

    }

    join(callback) {
        if (this.queueReady.length === 0 && this.queueUnresolved.length === 0) {
            callback();
        } else {
            this.on.completed.addOne(callback);
        }
    }
}

/**
 * @readonly
 * @enum {number}
 */
const SchedulingPolicy = {
    ROUND_ROBIN: 0,
    SEQUENTIAL: 1,
    TIME_SLICE: 2
};

ConcurrentExecutor.POLICY = SchedulingPolicy;


const ResolutionType = {
    READY: 0,
    FAILED: 1,
    UNRESOLVED: 2
};

/**
 *
 * @param {Task} unresolvedTask
 * @returns {number}
 */
function tryResolve(unresolvedTask) {
    const dependencies = unresolvedTask.dependencies;
    let j = 0;
    const jl = dependencies.length;
    for (; j < jl; j++) {
        const dependency = dependencies[j];
        switch (dependency.state.getValue()) {
            case TaskState.INITIAL:
            case TaskState.RUNNING:
            case TaskState.READY:
                return ResolutionType.UNRESOLVED;
            case TaskState.SUCCEEDED:
                break;
            default:
                console.error("Unknown dependency task state", dependency.state, "dependency:", dependency, "dependant:", unresolvedTask, "Failing the task.");
            //intended fallthrough
            case TaskState.FAILED:
                //fail dependant task
                return ResolutionType.FAILED;
        }
    }

    return ResolutionType.READY;
}

export default ConcurrentExecutor;
