/**
 * @author n1474335 [n1474335@gmail.com]
 * @author j433866 [j433866@gmail.com]
 * @copyright Crown Copyright 2016
 * @license Apache-2.0
 */

import Utils, { debounce } from "../../core/Utils.mjs";
import Dish from "../../core/Dish.mjs";
import FileSaver from "file-saver";
import ZipWorker from "worker-loader?inline=no-fallback!../workers/ZipWorker.mjs";

/**
  * Waiter to handle events related to the output
  */
class OutputWaiter {

    /**
     * OutputWaiter constructor.
     *
     * @param {App} app - The main view object for CyberChef.
     * @param {Manager} manager - The CyberChef event manager
     */
    constructor(app, manager) {
        this.app = app;
        this.manager = manager;

        this.outputs = {};
        this.zipWorker = null;
        this.maxTabs = this.manager.tabs.calcMaxTabs();
        this.tabTimeout = null;
    }

    /**
     * Calculates the maximum number of tabs to display
     */
    calcMaxTabs() {
        const numTabs = this.manager.tabs.calcMaxTabs();
        if (numTabs !== this.maxTabs) {
            this.maxTabs = numTabs;
            this.refreshTabs(this.manager.tabs.getActiveOutputTab(), "right");
        }
    }

    /**
     * Gets the dish object for an output.
     *
     * @param inputNum - The inputNum of the output to get the dish of
     * @returns {Dish}
     */
    getOutputDish(inputNum) {
        if (this.outputExists(inputNum) &&
            this.outputs[inputNum].data &&
            this.outputs[inputNum].data.dish) {
            return this.outputs[inputNum].data.dish;
        }
        return null;
    }

    /**
     * Checks if an output exists in the output dictionary
     *
     * @param {number} inputNum - The number of the output we're looking for
     * @returns {boolean}
     */
    outputExists(inputNum) {
        if (this.outputs[inputNum] === undefined ||
            this.outputs[inputNum] === null) {
            return false;
        }
        return true;
    }

    /**
     * Adds a new output to the output array.
     * Creates a new tab if we have less than maxtabs tabs open
     *
     * @param {number} inputNum - The inputNum of the new output
     * @param {boolean} [changeTab=true] - If true, change to the new output
     */
    addOutput(inputNum, changeTab = true) {
        // Remove the output (will only get removed if it already exists)
        this.removeOutput(inputNum);

        const newOutput = {
            data: null,
            inputNum: inputNum,
            statusMessage: `Input ${inputNum} has not been baked yet.`,
            error: null,
            status: "inactive",
            bakeId: -1,
            progress: false
        };

        this.outputs[inputNum] = newOutput;

        this.addTab(inputNum, changeTab);
    }

    /**
     * Updates the value for the output in the output array.
     * If this is the active output tab, updates the output textarea
     *
     * @param {ArrayBuffer | String} data
     * @param {number} inputNum
     * @param {boolean} set
     */
    updateOutputValue(data, inputNum, set=true) {
        if (!this.outputExists(inputNum)) {
            this.addOutput(inputNum);
        }

        if (Object.prototype.hasOwnProperty.call(data, "dish")) {
            data.dish = new Dish(data.dish);
        }

        this.outputs[inputNum].data = data;

        const tabItem = this.manager.tabs.getOutputTabItem(inputNum);
        if (tabItem) tabItem.style.background = "";

        if (set) this.set(inputNum);
    }

    /**
     * Updates the status message for the output in the output array.
     * If this is the active output tab, updates the output textarea
     *
     * @param {string} statusMessage
     * @param {number} inputNum
     * @param {boolean} [set=true]
     */
    updateOutputMessage(statusMessage, inputNum, set=true) {
        if (!this.outputExists(inputNum)) return;
        this.outputs[inputNum].statusMessage = statusMessage;
        if (set) this.set(inputNum);
    }

    /**
     * Updates the error value for the output in the output array.
     * If this is the active output tab, calls app.handleError.
     * Otherwise, the error will be handled when the output is switched to
     *
     * @param {Error} error
     * @param {number} inputNum
     * @param {number} [progress=0]
     */
    updateOutputError(error, inputNum, progress=0) {
        if (!this.outputExists(inputNum)) return;

        const errorString = error.displayStr || error.toString();

        this.outputs[inputNum].error = errorString;
        this.outputs[inputNum].progress = progress;
        this.updateOutputStatus("error", inputNum);
    }

    /**
     * Updates the status value for the output in the output array
     *
     * @param {string} status
     * @param {number} inputNum
     */
    updateOutputStatus(status, inputNum) {
        if (!this.outputExists(inputNum)) return;
        this.outputs[inputNum].status = status;

        if (status !== "error") {
            delete this.outputs[inputNum].error;
        }

        this.displayTabInfo(inputNum);
        this.set(inputNum);
    }

    /**
     * Updates the stored bake ID for the output in the output array
     *
     * @param {number} bakeId
     * @param {number} inputNum
     */
    updateOutputBakeId(bakeId, inputNum) {
        if (!this.outputExists(inputNum)) return;
        this.outputs[inputNum].bakeId = bakeId;
    }

    /**
     * Updates the stored progress value for the output in the output array
     *
     * @param {number} progress
     * @param {number} total
     * @param {number} inputNum
     */
    updateOutputProgress(progress, total, inputNum) {
        if (!this.outputExists(inputNum)) return;
        this.outputs[inputNum].progress = progress;

        if (progress !== false) {
            this.manager.tabs.updateOutputTabProgress(inputNum, progress, total);
        }

    }

    /**
     * Removes an output from the output array.
     *
     * @param {number} inputNum
     */
    removeOutput(inputNum) {
        if (!this.outputExists(inputNum)) return;

        delete (this.outputs[inputNum]);
    }

    /**
     * Removes all output tabs
     */
    removeAllOutputs() {
        this.outputs = {};

        this.resetSwitch();

        const tabsList = document.getElementById("output-tabs");
        const tabsListChildren = tabsList.children;

        tabsList.classList.remove("tabs-left");
        tabsList.classList.remove("tabs-right");
        for (let i = tabsListChildren.length - 1; i >= 0; i--) {
            tabsListChildren.item(i).remove();
        }
    }

    /**
     * Sets the output in the output textarea.
     *
     * @param {number} inputNum
     */
    async set(inputNum) {
        if (inputNum !== this.manager.tabs.getActiveOutputTab() ||
            !this.outputExists(inputNum)) return;
        this.toggleLoader(true);

        return new Promise(async function(resolve, reject) {
            const output = this.outputs[inputNum],
                activeTab = this.manager.tabs.getActiveOutputTab();
            if (typeof inputNum !== "number") inputNum = parseInt(inputNum, 10);

            const outputText = document.getElementById("output-text");
            const outputHtml = document.getElementById("output-html");
            const outputFile = document.getElementById("output-file");
            const outputHighlighter = document.getElementById("output-highlighter");
            const inputHighlighter = document.getElementById("input-highlighter");

            // If pending or baking, show loader and status message
            // If error, style the tab and handle the error
            // If done, display the output if it's the active tab
            // If inactive, show the last bake value (or blank)
            if (output.status === "inactive" ||
                output.status === "stale" ||
                (output.status === "baked" && output.bakeId < this.manager.worker.bakeId)) {
                this.manager.controls.showStaleIndicator();
            } else {
                this.manager.controls.hideStaleIndicator();
            }

            if (output.progress !== undefined && !this.app.baking) {
                this.manager.recipe.updateBreakpointIndicator(output.progress);
            } else {
                this.manager.recipe.updateBreakpointIndicator(false);
            }

            document.getElementById("show-file-overlay").style.display = "none";

            if (output.status === "pending" || output.status === "baking") {
                // show the loader and the status message if it's being shown
                // otherwise don't do anything
                document.querySelector("#output-loader .loading-msg").textContent = output.statusMessage;
            } else if (output.status === "error") {
                // style the tab if it's being shown
                this.toggleLoader(false);
                outputText.style.display = "block";
                outputText.classList.remove("blur");
                outputHtml.style.display = "none";
                outputFile.style.display = "none";
                outputHighlighter.display = "none";
                inputHighlighter.display = "none";

                if (output.error) {
                    outputText.value = output.error;
                } else {
                    outputText.value = output.data.result;
                }
                outputHtml.innerHTML = "";
            } else if (output.status === "baked" || output.status === "inactive") {
                document.querySelector("#output-loader .loading-msg").textContent = `Loading output ${inputNum}`;
                this.closeFile();
                let scriptElements, lines, length;

                if (output.data === null) {
                    outputText.style.display = "block";
                    outputHtml.style.display = "none";
                    outputFile.style.display = "none";
                    outputHighlighter.display = "block";
                    inputHighlighter.display = "block";

                    outputText.value = "";
                    outputHtml.innerHTML = "";

                    this.toggleLoader(false);
                    return;
                }

                switch (output.data.type) {
                    case "html":
                        outputText.style.display = "none";
                        outputHtml.style.display = "block";
                        outputFile.style.display = "none";
                        outputHighlighter.style.display = "none";
                        inputHighlighter.style.display = "none";

                        outputText.value = "";
                        outputHtml.innerHTML = output.data.result;

                        // Execute script sections
                        scriptElements = outputHtml.querySelectorAll("script");
                        for (let i = 0; i < scriptElements.length; i++) {
                            try {
                                eval(scriptElements[i].innerHTML); // eslint-disable-line no-eval
                            } catch (err) {
                                log.error(err);
                            }
                        }
                        break;
                    case "ArrayBuffer":
                        outputText.style.display = "block";
                        outputHtml.style.display = "none";
                        outputHighlighter.display = "none";
                        inputHighlighter.display = "none";

                        outputText.value = "";
                        outputHtml.innerHTML = "";

                        length = output.data.result.byteLength;
                        this.setFile(await this.getDishBuffer(output.data.dish), activeTab);
                        break;
                    case "string":
                    default:
                        outputText.style.display = "block";
                        outputHtml.style.display = "none";
                        outputFile.style.display = "none";
                        outputHighlighter.display = "block";
                        inputHighlighter.display = "block";

                        outputText.value = Utils.printable(output.data.result, true);
                        outputHtml.innerHTML = "";

                        lines = output.data.result.count("\n") + 1;
                        length = output.data.result.length;
                        break;
                }
                this.toggleLoader(false);

                if (output.data.type === "html") {
                    const dishStr = await this.getDishStr(output.data.dish);
                    length = dishStr.length;
                    lines = dishStr.count("\n") + 1;
                }

                this.setOutputInfo(length, lines, output.data.duration);
                debounce(this.backgroundMagic, 50, "backgroundMagic", this, [])();
            }
        }.bind(this));
    }

    /**
     * Shows file details
     *
     * @param {ArrayBuffer} buf
     * @param {number} activeTab
     */
    setFile(buf, activeTab) {
        if (activeTab !== this.manager.tabs.getActiveOutputTab()) return;
        // Display file overlay in output area with details
        const fileOverlay = document.getElementById("output-file"),
            fileSize = document.getElementById("output-file-size"),
            outputText = document.getElementById("output-text"),
            fileSlice = buf.slice(0, 4096);

        fileOverlay.style.display = "block";
        fileSize.textContent = buf.byteLength.toLocaleString() + " bytes";

        outputText.classList.add("blur");
        outputText.value = Utils.printable(Utils.arrayBufferToStr(fileSlice));
    }

    /**
     * Clears output file details
     */
    closeFile() {
        document.getElementById("output-file").style.display = "none";
        document.getElementById("output-text").classList.remove("blur");
    }

    /**
     * Retrieves the dish as a string, returning the cached version if possible.
     *
     * @param {Dish} dish
     * @returns {string}
     */
    async getDishStr(dish) {
        return await new Promise(resolve => {
            this.manager.worker.getDishAs(dish, "string", r => {
                resolve(r.value);
            });
        });
    }

    /**
     * Retrieves the dish as an ArrayBuffer, returning the cached version if possible.
     *
     * @param {Dish} dish
     * @returns {ArrayBuffer}
     */
    async getDishBuffer(dish) {
        return await new Promise(resolve => {
            this.manager.worker.getDishAs(dish, "ArrayBuffer", r => {
                resolve(r.value);
            });
        });
    }

    /**
     * Retrieves the title of the Dish as a string
     *
     * @param {Dish} dish
     * @param {number} maxLength
     * @returns {string}
     */
    async getDishTitle(dish, maxLength) {
        return await new Promise(resolve => {
            this.manager.worker.getDishTitle(dish, maxLength, r => {
                resolve(r.value);
            });
        });
    }

    /**
     * Save bombe object then remove it from the DOM so that it does not cause performance issues.
     */
    saveBombe() {
        this.bombeEl = document.getElementById("bombe");
        this.bombeEl.parentNode.removeChild(this.bombeEl);
    }

    /**
     * Shows or hides the output loading screen.
     * The animated Bombe SVG, whilst quite aesthetically pleasing, is reasonably CPU
     * intensive, so we remove it from the DOM when not in use. We only show it if the
     * recipe is taking longer than 200ms. We add it to the DOM just before that so that
     * it is ready to fade in without stuttering.
     *
     * @param {boolean} value - If true, show the loader
     */
    toggleLoader(value) {
        clearTimeout(this.appendBombeTimeout);
        clearTimeout(this.outputLoaderTimeout);

        const outputLoader = document.getElementById("output-loader"),
            outputElement = document.getElementById("output-text"),
            animation = document.getElementById("output-loader-animation");

        if (value) {
            this.manager.controls.hideStaleIndicator();

            // Don't add the bombe if it's already there!
            if (animation.children.length > 0) return;

            // Start a timer to add the Bombe to the DOM just before we make it
            // visible so that there is no stuttering
            this.appendBombeTimeout = setTimeout(function() {
                animation.appendChild(this.bombeEl);
            }.bind(this), 150);

            // Show the loading screen
            this.outputLoaderTimeout = setTimeout(function() {
                outputElement.disabled = true;
                outputLoader.style.visibility = "visible";
                outputLoader.style.opacity = 1;
            }, 200);
        } else {
            // Remove the Bombe from the DOM to save resources
            this.outputLoaderTimeout = setTimeout(function () {
                try {
                    animation.removeChild(this.bombeEl);
                } catch (err) {}
            }.bind(this), 500);
            outputElement.disabled = false;
            outputLoader.style.opacity = 0;
            outputLoader.style.visibility = "hidden";
        }
    }

    /**
     * Handler for save click events.
     * Saves the current output to a file.
     */
    saveClick() {
        this.downloadFile();
    }

    /**
     * Handler for file download events.
     */
    async downloadFile() {
        const dish = this.getOutputDish(this.manager.tabs.getActiveOutputTab());
        if (dish === null) {
            this.app.alert("Could not find any output data to download. Has this output been baked?", 3000);
            return;
        }
        const fileName = window.prompt("Please enter a filename: ", "download.dat");

        // Assume if the user clicks cancel they don't want to download
        if (fileName === null) return;

        const data = await dish.get(Dish.ARRAY_BUFFER),
            file = new File([data], fileName);
        FileSaver.saveAs(file, fileName, false);
    }

    /**
     * Handler for save all click event
     * Saves all outputs to a single archvie file
     */
    async saveAllClick() {
        const downloadButton = document.getElementById("save-all-to-file");
        if (downloadButton.firstElementChild.innerHTML === "archive") {
            this.downloadAllFiles();
        } else {
            const cancel = await new Promise(function(resolve, reject) {
                this.app.confirm(
                    "Cancel zipping?",
                    "The outputs are currently being zipped for download.<br>Cancel zipping?",
                    "Continue zipping",
                    "Cancel zipping",
                    resolve, this);
            }.bind(this));
            if (!cancel) {
                this.terminateZipWorker();
            }
        }
    }


    /**
     * Spawns a new ZipWorker and sends it the outputs so that they can
     * be zipped for download
     */
    async downloadAllFiles() {
        const inputNums = Object.keys(this.outputs);
        for (let i = 0; i < inputNums.length; i++) {
            const iNum = inputNums[i];
            if (this.outputs[iNum].status !== "baked" ||
            this.outputs[iNum].bakeId !== this.manager.worker.bakeId) {
                const continueDownloading = await new Promise(function(resolve, reject) {
                    this.app.confirm(
                        "Incomplete outputs",
                        "Not all outputs have been baked yet. Continue downloading outputs?",
                        "Download", "Cancel", resolve, this);
                }.bind(this));
                if (continueDownloading) {
                    break;
                } else {
                    return;
                }
            }
        }

        let fileName = window.prompt("Please enter a filename: ", "download.zip");

        if (fileName === null || fileName === "") {
            // Don't zip the files if there isn't a filename
            this.app.alert("No filename was specified.", 3000);
            return;
        }

        if (!fileName.match(/.zip$/)) {
            fileName += ".zip";
        }

        let fileExt = window.prompt("Please enter a file extension for the files, or leave blank to detect automatically.", "");

        if (fileExt === null) fileExt = "";

        if (this.zipWorker !== null) {
            this.terminateZipWorker();
        }

        const downloadButton = document.getElementById("save-all-to-file");

        downloadButton.classList.add("spin");
        downloadButton.title = `Zipping ${inputNums.length} files...`;
        downloadButton.setAttribute("data-original-title", `Zipping ${inputNums.length} files...`);

        downloadButton.firstElementChild.innerHTML = "autorenew";

        log.debug("Creating ZipWorker");
        this.zipWorker = new ZipWorker();
        this.zipWorker.postMessage({
            outputs: this.outputs,
            filename: fileName,
            fileExtension: fileExt
        });
        this.zipWorker.addEventListener("message", this.handleZipWorkerMessage.bind(this));
    }

    /**
     * Terminate the ZipWorker
     */
    terminateZipWorker() {
        if (this.zipWorker === null) return; // Already terminated

        log.debug("Terminating ZipWorker.");

        this.zipWorker.terminate();
        this.zipWorker = null;

        const downloadButton = document.getElementById("save-all-to-file");

        downloadButton.classList.remove("spin");
        downloadButton.title = "Save all outputs to a zip file";
        downloadButton.setAttribute("data-original-title", "Save all outputs to a zip file");

        downloadButton.firstElementChild.innerHTML = "archive";

    }


    /**
     * Handle messages sent back by the ZipWorker
     */
    handleZipWorkerMessage(e) {
        const r = e.data;
        if (!("zippedFile" in r)) {
            log.error("No zipped file was sent in the message.");
            this.terminateZipWorker();
            return;
        }
        if (!("filename" in r)) {
            log.error("No filename was sent in the message.");
            this.terminateZipWorker();
            return;
        }

        const file = new File([r.zippedFile], r.filename);
        FileSaver.saveAs(file, r.filename, false);

        this.terminateZipWorker();
    }

    /**
     * Adds a new output tab.
     *
     * @param {number} inputNum
     * @param {boolean} [changeTab=true]
     */
    addTab(inputNum, changeTab = true) {
        const tabsWrapper = document.getElementById("output-tabs");
        const numTabs = tabsWrapper.children.length;

        if (!this.manager.tabs.getOutputTabItem(inputNum) && numTabs < this.maxTabs) {
            // Create a new tab element
            const newTab = this.manager.tabs.createOutputTabElement(inputNum, changeTab);
            tabsWrapper.appendChild(newTab);
        } else if (numTabs === this.maxTabs) {
            // Can't create a new tab
            document.getElementById("output-tabs").lastElementChild.classList.add("tabs-right");
        }

        this.displayTabInfo(inputNum);

        if (changeTab) {
            this.changeTab(inputNum, false);
        }
    }

    /**
     * Changes the active tab
     *
     * @param {number} inputNum
     * @param {boolean} [changeInput = false]
     */
    changeTab(inputNum, changeInput = false) {
        if (!this.outputExists(inputNum)) return;
        const currentNum = this.manager.tabs.getActiveOutputTab();

        this.hideMagicButton();

        this.manager.highlighter.removeHighlights();
        getSelection().removeAllRanges();

        if (!this.manager.tabs.changeOutputTab(inputNum)) {
            let direction = "right";
            if (currentNum > inputNum) {
                direction = "left";
            }
            const newOutputs = this.getNearbyNums(inputNum, direction);

            const tabsLeft = (newOutputs[0] !== this.getSmallestInputNum());
            const tabsRight = (newOutputs[newOutputs.length - 1] !== this.getLargestInputNum());

            this.manager.tabs.refreshOutputTabs(newOutputs, inputNum, tabsLeft, tabsRight);

            for (let i = 0; i < newOutputs.length; i++) {
                this.displayTabInfo(newOutputs[i]);
            }
        }

        debounce(this.set, 50, "setOutput", this, [inputNum])();

        document.getElementById("output-html").scroll(0, 0);
        document.getElementById("output-text").scroll(0, 0);

        if (changeInput) {
            this.manager.input.changeTab(inputNum, false);
        }
    }

    /**
     * Handler for changing tabs event
     *
     * @param {event} mouseEvent
     */
    changeTabClick(mouseEvent) {
        if (!mouseEvent.target) return;
        const tabNum = mouseEvent.target.parentElement.getAttribute("inputNum");
        if (tabNum) {
            this.changeTab(parseInt(tabNum, 10), this.app.options.syncTabs);
        }
    }

    /**
     * Handler for scrolling on the output tabs area
     *
     * @param {event} wheelEvent
     */
    scrollTab(wheelEvent) {
        wheelEvent.preventDefault();

        if (wheelEvent.deltaY > 0) {
            this.changeTabLeft();
        } else if (wheelEvent.deltaY < 0) {
            this.changeTabRight();
        }
    }

    /**
     * Handler for mouse down on the next tab button
     */
    nextTabClick() {
        this.mousedown = true;
        this.changeTabRight();
        const time = 200;
        const func = function(time) {
            if (this.mousedown) {
                this.changeTabRight();
                const newTime = (time > 50) ? time - 10 : 50;
                setTimeout(func.bind(this, [newTime]), newTime);
            }
        };
        this.tabTimeout = setTimeout(func.bind(this, [time]), time);
    }

    /**
     * Handler for mouse down on the previous tab button
     */
    previousTabClick() {
        this.mousedown = true;
        this.changeTabLeft();
        const time = 200;
        const func = function(time) {
            if (this.mousedown) {
                this.changeTabLeft();
                const newTime = (time > 50) ? time - 10 : 50;
                setTimeout(func.bind(this, [newTime]), newTime);
            }
        };
        this.tabTimeout = setTimeout(func.bind(this, [time]), time);
    }

    /**
     * Handler for mouse up event on the tab buttons
     */
    tabMouseUp() {
        this.mousedown = false;

        clearTimeout(this.tabTimeout);
        this.tabTimeout = null;
    }

    /**
     * Handler for changing to the left tab
     */
    changeTabLeft() {
        const currentTab = this.manager.tabs.getActiveOutputTab();
        this.changeTab(this.getPreviousInputNum(currentTab), this.app.options.syncTabs);
    }

    /**
     * Handler for changing to the right tab
     */
    changeTabRight() {
        const currentTab = this.manager.tabs.getActiveOutputTab();
        this.changeTab(this.getNextInputNum(currentTab), this.app.options.syncTabs);
    }

    /**
     * Handler for go to tab button clicked
     */
    goToTab() {
        const min = this.getSmallestInputNum(),
            max = this.getLargestInputNum();

        let tabNum = window.prompt(`Enter tab number (${min} - ${max}):`, this.manager.tabs.getActiveOutputTab().toString());
        if (tabNum === null) return;
        tabNum = parseInt(tabNum, 10);

        if (this.outputExists(tabNum)) {
            this.changeTab(tabNum, this.app.options.syncTabs);
        }
    }

    /**
     * Generates a list of the nearby inputNums
     * @param inputNum
     * @param direction
     */
    getNearbyNums(inputNum, direction) {
        const nums = [];
        for (let i = 0; i < this.maxTabs; i++) {
            let newNum;
            if (i === 0 && this.outputs[inputNum] !== undefined) {
                newNum = inputNum;
            } else {
                switch (direction) {
                    case "left":
                        newNum = this.getNextInputNum(nums[i - 1]);
                        if (newNum === nums[i - 1]) {
                            direction = "right";
                            newNum = this.getPreviousInputNum(nums[0]);
                        }
                        break;
                    case "right":
                        newNum = this.getPreviousInputNum(nums[i - 1]);
                        if (newNum === nums[i - 1]) {
                            direction = "left";
                            newNum = this.getNextInputNum(nums[0]);
                        }
                }
            }
            if (!nums.includes(newNum) && (newNum > 0)) {
                nums.push(newNum);
            }
        }
        nums.sort(function(a, b) {
            return a - b;
        });
        return nums;
    }

    /**
     * Gets the largest inputNum
     *
     * @returns {number}
     */
    getLargestInputNum() {
        const inputNums = Object.keys(this.outputs);
        if (inputNums.length === 0) return -1;
        return Math.max(...inputNums);
    }

    /**
     * Gets the smallest inputNum
     *
     * @returns {number}
     */
    getSmallestInputNum() {
        const inputNums = Object.keys(this.outputs);
        if (inputNums.length === 0) return -1;
        return Math.min(...inputNums);
    }

    /**
     * Gets the previous inputNum
     *
     * @param {number} inputNum - The current input number
     * @returns {number}
     */
    getPreviousInputNum(inputNum) {
        const inputNums = Object.keys(this.outputs);
        if (inputNums.length === 0) return -1;
        let num = Math.min(...inputNums);
        for (let i = 0; i < inputNums.length; i++) {
            const iNum = parseInt(inputNums[i], 10);
            if (iNum < inputNum) {
                if (iNum > num) {
                    num = iNum;
                }
            }
        }
        return num;
    }

    /**
     * Gets the next inputNum
     *
     * @param {number} inputNum - The current input number
     * @returns {number}
     */
    getNextInputNum(inputNum) {
        const inputNums = Object.keys(this.outputs);
        if (inputNums.length === 0) return -1;
        let num = Math.max(...inputNums);
        for (let i = 0; i < inputNums.length; i++) {
            const iNum = parseInt(inputNums[i], 10);
            if (iNum > inputNum) {
                if (iNum < num) {
                    num = iNum;
                }
            }
        }
        return num;
    }

    /**
     * Removes a tab and it's corresponding output
     *
     * @param {number} inputNum
     */
    removeTab(inputNum) {
        if (!this.outputExists(inputNum)) return;

        const tabElement = this.manager.tabs.getOutputTabItem(inputNum);

        this.removeOutput(inputNum);

        if (tabElement !== null) {
            this.refreshTabs(this.getPreviousInputNum(inputNum), "left");
        }
    }

    /**
     * Redraw the entire tab bar to remove any outdated tabs
     * @param {number} activeTab
     * @param {string} direction - Either "left" or "right"
     */
    refreshTabs(activeTab, direction) {
        const newNums = this.getNearbyNums(activeTab, direction),
            tabsLeft = (newNums[0] !== this.getSmallestInputNum() && newNums.length > 0),
            tabsRight = (newNums[newNums.length - 1] !== this.getLargestInputNum() && newNums.length > 0);

        this.manager.tabs.refreshOutputTabs(newNums, activeTab, tabsLeft, tabsRight);

        for (let i = 0; i < newNums.length; i++) {
            this.displayTabInfo(newNums[i]);
        }

    }

    /**
     * Display output information in the tab header
     *
     * @param {number} inputNum
     */
    async displayTabInfo(inputNum) {
        if (!this.outputExists(inputNum)) return;

        const dish = this.getOutputDish(inputNum);
        let tabStr = "";

        if (dish !== null) {
            tabStr = await this.getDishTitle(this.getOutputDish(inputNum), 100);
            tabStr = tabStr.replace(/[\n\r]/g, "");
        }
        this.manager.tabs.updateOutputTabHeader(inputNum, tabStr);
        if (this.manager.worker.recipeConfig !== undefined) {
            this.manager.tabs.updateOutputTabProgress(inputNum, this.outputs[inputNum].progress, this.manager.worker.recipeConfig.length);
        }

        const tabItem = this.manager.tabs.getOutputTabItem(inputNum);
        if (tabItem) {
            if (this.outputs[inputNum].status === "error") {
                tabItem.style.color = "#FF0000";
            } else {
                tabItem.style.color = "";
            }
        }
    }

    /**
     * Displays information about the output.
     *
     * @param {number} length - The length of the current output string
     * @param {number} lines - The number of the lines in the current output string
     * @param {number} duration - The length of time (ms) it took to generate the output
     */
    setOutputInfo(length, lines, duration) {
        if (!length) return;
        let width = length.toString().length;
        width = width < 4 ? 4 : width;

        const lengthStr = length.toString().padStart(width, " ").replace(/ /g, "&nbsp;");
        const timeStr = (duration.toString() + "ms").padStart(width, " ").replace(/ /g, "&nbsp;");

        let msg = "time: " + timeStr + "<br>length: " + lengthStr;

        if (typeof lines === "number") {
            const linesStr = lines.toString().padStart(width, " ").replace(/ /g, "&nbsp;");
            msg += "<br>lines: " + linesStr;
        }

        document.getElementById("output-info").innerHTML = msg;
        document.getElementById("input-selection-info").innerHTML = "";
        document.getElementById("output-selection-info").innerHTML = "";
    }

    /**
     * Triggers the BackgroundWorker to attempt Magic on the current output.
     */
    async backgroundMagic() {
        this.hideMagicButton();
        const dish = this.getOutputDish(this.manager.tabs.getActiveOutputTab());
        if (!this.app.options.autoMagic || dish === null) return;
        const buffer = await this.getDishBuffer(dish);
        const sample = buffer.slice(0, 1000) || "";

        if (sample.length || sample.byteLength) {
            this.manager.background.magic(sample);
        }
    }

    /**
     * Handles the results of a background Magic call.
     *
     * @param {Object[]} options
     */
    backgroundMagicResult(options) {
        if (!options.length) return;

        const currentRecipeConfig = this.app.getRecipeConfig();
        let msg = "",
            newRecipeConfig;

        if (options[0].recipe.length) {
            const opSequence = options[0].recipe.map(o => o.op).join(", ");
            newRecipeConfig = currentRecipeConfig.concat(options[0].recipe);
            msg = `<i>${opSequence}</i> will produce <span class="data-text">"${Utils.escapeHtml(Utils.truncate(options[0].data), 30)}"</span>`;
        } else if (options[0].fileType && options[0].fileType.name) {
            const ft = options[0].fileType;
            newRecipeConfig = currentRecipeConfig.concat([{op: "Detect File Type", args: []}]);
            msg = `<i>${ft.name}</i> file detected`;
        } else {
            return;
        }

        this.showMagicButton(msg, newRecipeConfig);
    }

    /**
     * Handler for Magic click events.
     *
     * Loads the Magic recipe.
     *
     * @fires Manager#statechange
     */
    magicClick() {
        const magicButton = document.getElementById("magic");
        this.app.setRecipeConfig(JSON.parse(magicButton.getAttribute("data-recipe")));
        window.dispatchEvent(this.manager.statechange);
        this.hideMagicButton();
    }

    /**
     * Displays the Magic button with a title and adds a link to a recipe.
     *
     * @param {string} msg
     * @param {Object[]} recipeConfig
     */
    showMagicButton(msg, recipeConfig) {
        const magicButton = document.getElementById("magic");
        magicButton.setAttribute("data-original-title", msg);
        magicButton.setAttribute("data-recipe", JSON.stringify(recipeConfig), null, "");
        magicButton.classList.remove("hidden");
        magicButton.classList.add("pulse");
    }


    /**
     * Hides the Magic button and resets its values.
     */
    hideMagicButton() {
        const magicButton = document.getElementById("magic");
        magicButton.classList.add("hidden");
        magicButton.classList.remove("pulse");
        magicButton.setAttribute("data-recipe", "");
        magicButton.setAttribute("data-original-title", "Magic!");
    }


    /**
     * Handler for file slice display events.
     */
    async displayFileSlice() {
        document.querySelector("#output-loader .loading-msg").textContent = "Loading file slice...";
        this.toggleLoader(true);
        const outputText = document.getElementById("output-text"),
            outputHtml = document.getElementById("output-html"),
            outputFile = document.getElementById("output-file"),
            outputHighlighter = document.getElementById("output-highlighter"),
            inputHighlighter = document.getElementById("input-highlighter"),
            showFileOverlay = document.getElementById("show-file-overlay"),
            sliceFromEl = document.getElementById("output-file-slice-from"),
            sliceToEl = document.getElementById("output-file-slice-to"),
            sliceFrom = parseInt(sliceFromEl.value, 10) * 1024,
            sliceTo = parseInt(sliceToEl.value, 10) * 1024,
            output = this.outputs[this.manager.tabs.getActiveOutputTab()].data;

        let str;
        if (output.type === "ArrayBuffer") {
            str = Utils.arrayBufferToStr(output.result.slice(sliceFrom, sliceTo));
        } else {
            str = Utils.arrayBufferToStr(await this.getDishBuffer(output.dish).slice(sliceFrom, sliceTo));
        }

        outputText.classList.remove("blur");
        showFileOverlay.style.display = "block";
        outputText.value = Utils.printable(str, true);

        outputText.style.display = "block";
        outputHtml.style.display = "none";
        outputFile.style.display = "none";
        outputHighlighter.display = "block";
        inputHighlighter.display = "block";

        this.toggleLoader(false);
    }

    /**
     * Handler for showing an entire file at user's discretion (even if it's way too big)
     */
    async showAllFile() {
        document.querySelector("#output-loader .loading-msg").textContent = "Loading entire file at user instruction. This may cause a crash...";
        this.toggleLoader(true);
        const outputText = document.getElementById("output-text"),
            outputHtml = document.getElementById("output-html"),
            outputFile = document.getElementById("output-file"),
            outputHighlighter = document.getElementById("output-highlighter"),
            inputHighlighter = document.getElementById("input-highlighter"),
            showFileOverlay = document.getElementById("show-file-overlay"),
            output = this.outputs[this.manager.tabs.getActiveOutputTab()].data;

        let str;
        if (output.type === "ArrayBuffer") {
            str = Utils.arrayBufferToStr(output.result);
        } else {
            str = Utils.arrayBufferToStr(await this.getDishBuffer(output.dish));
        }

        outputText.classList.remove("blur");
        showFileOverlay.style.display = "none";
        outputText.value = Utils.printable(str, true);

        outputText.style.display = "block";
        outputHtml.style.display = "none";
        outputFile.style.display = "none";
        outputHighlighter.display = "block";
        inputHighlighter.display = "block";

        this.toggleLoader(false);
    }

    /**
     * Handler for show file overlay events
     *
     * @param {Event} e
     */
    showFileOverlayClick(e) {
        const showFileOverlay = e.target;

        document.getElementById("output-text").classList.add("blur");
        showFileOverlay.style.display = "none";
        this.set(this.manager.tabs.getActiveOutputTab());
    }

    /**
     * Handler for extract file events.
     *
     * @param {Event} e
     */
    async extractFileClick(e) {
        e.preventDefault();
        e.stopPropagation();

        const el = e.target.nodeName === "I" ? e.target.parentNode : e.target;
        const blobURL = el.getAttribute("blob-url");
        const fileName = el.getAttribute("file-name");

        const blob = await fetch(blobURL).then(r => r.blob());
        this.manager.input.loadUIFiles([new File([blob], fileName, {type: blob.type})]);
    }


    /**
     * Handler for copy click events.
     * Copies the output to the clipboard
     */
    async copyClick() {
        const dish = this.getOutputDish(this.manager.tabs.getActiveOutputTab());
        if (dish === null) {
            this.app.alert("Could not find data to copy. Has this output been baked yet?", 3000);
            return;
        }

        const output = await dish.get(Dish.STRING);

        // Create invisible textarea to populate with the raw dish string (not the printable version that
        // contains dots instead of the actual bytes)
        const textarea = document.createElement("textarea");
        textarea.style.position = "fixed";
        textarea.style.top = 0;
        textarea.style.left = 0;
        textarea.style.width = 0;
        textarea.style.height = 0;
        textarea.style.border = "none";

        textarea.value = output;
        document.body.appendChild(textarea);

        let success = false;
        try {
            textarea.select();
            success = output && document.execCommand("copy");
        } catch (err) {
            success = false;
        }

        if (success) {
            this.app.alert("Copied raw output successfully.", 2000);
        } else {
            this.app.alert("Sorry, the output could not be copied.", 3000);
        }

        // Clean up
        document.body.removeChild(textarea);
    }

    /**
     * Returns true if the output contains carriage returns
     *
     * @returns {boolean}
     */
    async containsCR() {
        const dish = this.getOutputDish(this.manager.tabs.getActiveOutputTab());
        if (dish === null) return;

        if (dish.type === Dish.STRING) {
            const data = await dish.get(Dish.STRING);
            return data.indexOf("\r") >= 0;
        }
    }

    /**
     * Handler for switch click events.
     * Moves the current output into the input textarea.
     */
    async switchClick() {
        const activeTab = this.manager.tabs.getActiveOutputTab();
        const transferable = [];

        const switchButton = document.getElementById("switch");
        switchButton.classList.add("spin");
        switchButton.disabled = true;
        switchButton.firstElementChild.innerHTML = "autorenew";
        $(switchButton).tooltip("hide");

        let active = await this.getDishBuffer(this.getOutputDish(activeTab));

        if (!this.outputExists(activeTab)) {
            this.resetSwitchButton();
            return;
        }

        if (this.outputs[activeTab].data.type === "string" &&
            active.byteLength <= this.app.options.ioDisplayThreshold * 1024) {
            const dishString = await this.getDishStr(this.getOutputDish(activeTab));
            if (!await this.manager.input.preserveCarriageReturns(dishString)) {
                active = dishString;
            }
        } else {
            transferable.push(active);
        }

        this.manager.input.inputWorker.postMessage({
            action: "inputSwitch",
            data: {
                inputNum: activeTab,
                outputData: active
            }
        }, transferable);
    }

    /**
     * Handler for when the inputWorker has switched the inputs.
     * Stores the old input
     *
     * @param {object} switchData
     * @param {number} switchData.inputNum
     * @param {string | object} switchData.data
     * @param {ArrayBuffer} switchData.data.fileBuffer
     * @param {number} switchData.data.size
     * @param {string} switchData.data.type
     * @param {string} switchData.data.name
     */
    inputSwitch(switchData) {
        this.switchOrigData = switchData;
        document.getElementById("undo-switch").disabled = false;

        this.resetSwitchButton();

    }

    /**
     * Handler for undo switch click events.
     * Removes the output from the input and replaces the input that was removed.
     */
    undoSwitchClick() {
        this.manager.input.updateInputObj(this.switchOrigData.inputNum, this.switchOrigData.data);

        this.manager.input.fileLoaded(this.switchOrigData.inputNum);

        this.resetSwitch();
    }

    /**
     * Removes the switch data and resets the switch buttons
     */
    resetSwitch() {
        if (this.switchOrigData !== undefined) {
            delete this.switchOrigData;
        }

        const undoSwitch = document.getElementById("undo-switch");
        undoSwitch.disabled = true;
        $(undoSwitch).tooltip("hide");

        this.resetSwitchButton();
    }

    /**
     * Resets the switch button to its usual state
     */
    resetSwitchButton() {
        const switchButton = document.getElementById("switch");
        switchButton.classList.remove("spin");
        switchButton.disabled = false;
        switchButton.firstElementChild.innerHTML = "open_in_browser";
    }

    /**
     * Handler for maximise output click events.
     * Resizes the output frame to be as large as possible, or restores it to its original size.
     */
    maximiseOutputClick(e) {
        const el = e.target.id === "maximise-output" ? e.target : e.target.parentNode;

        if (el.getAttribute("data-original-title").indexOf("Maximise") === 0) {
            document.body.classList.add("output-maximised");
            this.app.initialiseSplitter(true);
            this.app.columnSplitter.collapse(0);
            this.app.columnSplitter.collapse(1);
            this.app.ioSplitter.collapse(0);

            $(el).attr("data-original-title", "Restore output pane");
            el.querySelector("i").innerHTML = "fullscreen_exit";
        } else {
            document.body.classList.remove("output-maximised");
            $(el).attr("data-original-title", "Maximise output pane");
            el.querySelector("i").innerHTML = "fullscreen";
            this.app.initialiseSplitter(false);
            this.app.resetLayout();
        }
    }

    /**
     * Handler for find tab button clicked
     */
    findTab() {
        this.filterTabSearch();
        $("#output-tab-modal").modal();
    }

    /**
     * Searches the outputs using the filter settings and displays the results
     */
    async filterTabSearch() {
        const showPending = document.getElementById("output-show-pending").checked,
            showBaking = document.getElementById("output-show-baking").checked,
            showBaked = document.getElementById("output-show-baked").checked,
            showStale = document.getElementById("output-show-stale").checked,
            showErrored = document.getElementById("output-show-errored").checked,
            contentFilter = document.getElementById("output-content-filter").value,
            resultsList = document.getElementById("output-search-results"),
            numResults = parseInt(document.getElementById("output-num-results").value, 10),
            inputNums = Object.keys(this.outputs),
            results = [];

        let contentFilterExp;
        try {
            contentFilterExp = new RegExp(contentFilter, "i");
        } catch (error) {
            this.app.handleError(error);
            return;
        }

        // Search through the outputs for matching output results
        for (let i = 0; i < inputNums.length; i++) {
            const iNum = inputNums[i],
                output = this.outputs[iNum];

            if (output.status === "pending" && showPending ||
                output.status === "baking" && showBaking ||
                output.status === "error" && showErrored ||
                output.status === "stale" && showStale ||
                output.status === "inactive" && showStale) {
                const outDisplay = {
                    "pending": "Not baked yet",
                    "baking": "Baking",
                    "error": output.error || "Errored",
                    "stale": "Stale (output is out of date)",
                    "inactive": "Not baked yet"
                };

                // If the output has a dish object, check it against the filter
                if (Object.prototype.hasOwnProperty.call(output, "data") &&
                    output.data &&
                    Object.prototype.hasOwnProperty.call(output.data, "dish")) {
                    const data = await output.data.dish.get(Dish.STRING);
                    if (contentFilterExp.test(data)) {
                        results.push({
                            inputNum: iNum,
                            textDisplay: data.slice(0, 100)
                        });
                    }
                } else {
                    results.push({
                        inputNum: iNum,
                        textDisplay: outDisplay[output.status]
                    });
                }
            } else if (output.status === "baked" && showBaked && output.progress === false) {
                let data = await output.data.dish.get(Dish.STRING);
                data = data.replace(/[\r\n]/g, "");
                if (contentFilterExp.test(data)) {
                    results.push({
                        inputNum: iNum,
                        textDisplay: data.slice(0, 100)
                    });
                }
            } else if (output.progress !== false && showErrored) {
                let data = await output.data.dish.get(Dish.STRING);
                data = data.replace(/[\r\n]/g, "");
                if (contentFilterExp.test(data)) {
                    results.push({
                        inputNum: iNum,
                        textDisplay: data.slice(0, 100)
                    });
                }
            }

            if (results.length >= numResults) {
                break;
            }
        }

        for (let i = resultsList.children.length - 1; i >= 0; i--) {
            resultsList.children.item(i).remove();
        }

        for (let i = 0; i < results.length; i++) {
            const newListItem = document.createElement("li");
            newListItem.classList.add("output-filter-result");
            newListItem.setAttribute("inputNum", results[i].inputNum);
            newListItem.innerText = `${results[i].inputNum}: ${results[i].textDisplay}`;

            resultsList.appendChild(newListItem);
        }
    }

    /**
     * Handler for clicking on a filter result.
     * Changes to the clicked output
     *
     * @param {event} e
     */
    filterItemClick(e) {
        if (!e.target) return;
        const inputNum = parseInt(e.target.getAttribute("inputNum"), 10);
        if (inputNum <= 0) return;

        $("#output-tab-modal").modal("hide");
        this.changeTab(inputNum, this.app.options.syncTabs);
    }
}

export default OutputWaiter;
