/**
 * Controller managing the validation logic of a wed editor.
 * @author Louis-Dominique Dubeau
 * @license MPL 2.0
 * @copyright Mangalam Research Center for Buddhist Languages
 */
define(["require", "exports", "salve", "salve-dom", "./dloc", "./domtypeguards", "./domutil", "./guiroot", "./task-runner", "./tasks/process-validation-errors", "./tasks/refresh-validation-errors", "./util", "./wed-util"], function (require, exports, salve_1, salve_dom_1, dloc_1, domtypeguards_1, domutil_1, guiroot_1, task_runner_1, process_validation_errors_1, refresh_validation_errors_1, util_1, wed_util_1) {
    "use strict";
    Object.defineProperty(exports, "__esModule", { value: true });
    const stateToStr = {};
    stateToStr[salve_dom_1.WorkingState.INCOMPLETE] = "stopped";
    stateToStr[salve_dom_1.WorkingState.WORKING] = "working";
    stateToStr[salve_dom_1.WorkingState.INVALID] = "invalid";
    stateToStr[salve_dom_1.WorkingState.VALID] = "valid";
    const stateToProgressType = {};
    stateToProgressType[salve_dom_1.WorkingState.INCOMPLETE] = "info";
    stateToProgressType[salve_dom_1.WorkingState.WORKING] = "info";
    stateToProgressType[salve_dom_1.WorkingState.INVALID] = "danger";
    stateToProgressType[salve_dom_1.WorkingState.VALID] = "success";
    // This is a utility function for the method of the same name. If the mode is
    // set to not display attributes or if a custom decorator is set to not display
    // a specific attribute, then finding the GUI location of the attribute won't be
    // possible. In such case, we want to fail nicely rather than crash to the
    // ground.
    //
    // (NOTE: What we're talking about is not the label visibility level being such
    // that attributes are not *seen* but have DOM elements for them in the GUI
    // tree. We're talking about a situation in which the mode's decorator does not
    // create DOM elements for the attributes.)
    //
    function findInsertionPoint(editor, node, index) {
        const caretManager = editor.caretManager;
        try {
            return caretManager.fromDataLocation(node, index);
        }
        catch (ex) {
            if (ex instanceof guiroot_1.AttributeNotFound) {
                // This happens only if node points to an attribute.
                return caretManager.fromDataLocation(node.ownerElement, 0);
            }
            throw ex;
        }
    }
    /**
     * Add a list of elements to a ``DocumentFragment``.
     *
     * @param doc The document from which to create the fragment.
     *
     * @param items The elements to add to the new fragment.
     *
     * @returns A new fragment that contains the elements passed.
     */
    function elementsToFrag(doc, items) {
        const frag = doc.createDocumentFragment();
        for (const item of items) {
            frag.appendChild(item);
        }
        return frag;
    }
    /**
     * Convert the names in an error message from their expanded form to their
     * prefix, local name form.
     *
     * @param error The error.
     *
     * @param resolve The resolver to use to convert the names.
     *
     * @returns The converted names.
     */
    function convertNames(error, resolver) {
        // Turn the names into qualified names.
        const convertedNames = [];
        const patterns = error.getNames();
        for (const pattern of patterns) {
            const names = pattern.toArray();
            let convertedName = "";
            if (names !== null) {
                // Simple pattern, just translate all names one by one.
                const conv = [];
                for (const name of names) {
                    conv.push(resolver.unresolveName(name.ns, name.name));
                }
                convertedName = conv.join(" or ");
            }
            else {
                // We convert the complex pattern into something reasonable.
                convertedName = util_1.convertPatternObj(pattern.toObject(), resolver);
            }
            convertedNames.push(convertedName);
        }
        return convertedNames;
    }
    /**
     * Controls the validator and the tasks that pertain to error processing and
     * refreshing. Takes care of reporting errors to the user.
     */
    class ValidationController {
        /**
         * @param editor The editor for which this controller is created.
         *
         * @param validator The validator which is under control.
         *
         * @param resolver A name resolver to resolve names in errors.
         *
         * @param scroller The scroller for the edited contents.
         *
         * @param guiRoot The DOM element representing the root of the edited
         * document.
         *
         * @param progressBar: The DOM element which contains the validation progress
         * bar.
         *
         * @param validationMessage: The DOM element which serves to report the
         * validation status.
         *
         * @param errorLayer: The layer that holds error markers.
         *
         * @param errorList: The DOM element which serves to contain the error list.
         *
         * @param errorItemHandler: An event handler for the markers.
         */
        constructor(editor, validator, resolver, scroller, guiRoot, progressBar, validationMessage, errorLayer, errorList, errorItemHandler) {
            this.editor = editor;
            this.validator = validator;
            this.resolver = resolver;
            this.scroller = scroller;
            this.guiRoot = guiRoot;
            this.progressBar = progressBar;
            this.validationMessage = validationMessage;
            this.errorLayer = errorLayer;
            this.errorList = errorList;
            this.errorItemHandler = errorItemHandler;
            this.lastDoneShown = 0;
            /**
             * This holds the timeout set to process validation errors in batch.  The
             * delay in ms before we consider a batch ready to process.
             */
            this.processErrorsDelay = 500;
            this._errors = [];
            this.document = guiRoot.ownerDocument;
            this.$errorList = $(errorList);
            this.refreshErrorsRunner =
                new task_runner_1.TaskRunner(new refresh_validation_errors_1.RefreshValidationErrors(this));
            this.processErrorsRunner =
                new task_runner_1.TaskRunner(new process_validation_errors_1.ProcessValidationErrors(this));
            this.validator.events.addEventListener("state-update", this.onValidatorStateChange.bind(this));
            this.validator.events.addEventListener("error", this.onValidatorError.bind(this));
            this.validator.events.addEventListener("reset-errors", this.onResetErrors.bind(this));
        }
        /**
         * @returns a shallow copy of the error list.
         */
        copyErrorList() {
            return this._errors.slice();
        }
        /**
         * Stops all tasks and the validator.
         */
        stop() {
            this.refreshErrorsRunner.stop();
            this.processErrorsRunner.stop();
            this.validator.stop();
        }
        /**
         * Resumes all tasks and the validator.
         */
        resume() {
            this.refreshErrorsRunner.resume();
            this.processErrorsRunner.resume();
            this.validator.start(); // Yes, start is the right method.
        }
        /**
         * Handles changes in the validator state. Updates the progress bar and the
         * validation status.
         */
        onValidatorStateChange(workingState) {
            const { state, partDone } = workingState;
            if (state === salve_dom_1.WorkingState.WORKING) {
                // Do not show changes less than 5%
                if (partDone - this.lastDoneShown < 0.05) {
                    return;
                }
            }
            else if (state === salve_dom_1.WorkingState.VALID || state === salve_dom_1.WorkingState.INVALID) {
                // We're done so we might as well process the errors right now.
                this.processErrors();
            }
            this.lastDoneShown = partDone;
            const percent = partDone * 100;
            const progress = this.progressBar;
            progress.style.width = `${percent}%`;
            progress.classList.remove("progress-bar-info", "progress-bar-success", "progress-bar-danger");
            progress.classList.add(`progress-bar-${stateToProgressType[state]}`);
            this.validationMessage.textContent = stateToStr[state];
        }
        /**
         * Handles a validation error reported by the validator. It records the error
         * and schedule future processing of the errors.
         */
        onValidatorError(ev) {
            this._errors.push({
                ev,
                marker: undefined,
                item: undefined,
            });
            // We "batch" validation errors to process multiple of them in one shot
            // rather than call _processErrors each time.
            if (this.processErrorsTimeout === undefined) {
                this.processErrorsTimeout = setTimeout(this.processErrors.bind(this), this.processErrorsDelay);
            }
        }
        /**
         * Handles resets of the validation state.
         */
        onResetErrors(ev) {
            if (ev.at !== 0) {
                throw new Error("internal error: wed does not yet support " +
                    "resetting errors at an arbitrary location");
            }
            this.lastDoneShown = 0;
            this.clearErrors();
        }
        /**
         * Resets the state of the error processing task and resumes it
         * as soon as possible.
         */
        processErrors() {
            // Clear the timeout... because this function may be called from somewhere
            // else than the timeout.
            if (this.processErrorsTimeout !== undefined) {
                clearTimeout(this.processErrorsTimeout);
                this.processErrorsTimeout = undefined;
            }
            this.processErrorsRunner.reset();
            this.editor.resumeTaskWhenPossible(this.processErrorsRunner);
        }
        /**
         * Find where the error represented by the event passed should be marked.
         *
         * @param ev The error reported by the validator.
         *
         * @returns A location, if possible.
         */
        findInsertionPoint(ev) {
            const { error, node: dataNode, index } = ev;
            if (dataNode == null) {
                throw new Error("error without a node");
            }
            if (index === undefined) {
                throw new Error("error without an index");
            }
            const isAttributeNameError = error instanceof salve_1.AttributeNameError;
            let insertAt;
            if (isAttributeNameError) {
                const guiNode = wed_util_1.getGUINodeIfExists(this.editor, dataNode);
                if (guiNode === undefined) {
                    return undefined;
                }
                // Attribute name errors can have two causes: the attribute is not
                // allowed, or an attribute is missing. In the former case, the error
                // points to the attribute node. In the latter case, it points to the
                // element that's missing the attribute.
                let insertionNode;
                if (domtypeguards_1.isAttr(dataNode)) {
                    // Spurious attribute.
                    insertionNode = guiNode;
                }
                else {
                    // Missing attribute.
                    if (!domtypeguards_1.isElement(guiNode)) {
                        throw new Error("attribute name errors should be associated with " +
                            "elements");
                    }
                    insertionNode =
                        guiNode.querySelector("._gui.__start_label ._greater_than");
                }
                insertAt = dloc_1.DLoc.mustMakeDLoc(this.guiRoot, insertionNode, 0);
            }
            else {
                insertAt = findInsertionPoint(this.editor, dataNode, index);
                if (insertAt === undefined) {
                    return undefined;
                }
                insertAt = this.editor.caretManager.normalizeToEditableRange(insertAt);
            }
            return insertAt;
        }
        /**
         * Process a single error. This will compute the location of the error marker
         * and will create a marker to add to the error layer, and a list item to add
         * to the list of errors.
         *
         * @return ``false`` if there was no insertion point for the error, and thus
         * no marker or item were created. ``true`` otherwise.
         */
        // tslint:disable-next-line:max-func-body-length
        processError(err) {
            this.editor.expandErrorPanelWhenNoNavigation();
            const { ev } = err;
            const { error, node: dataNode } = ev;
            if (dataNode == null) {
                throw new Error("error without a node");
            }
            const insertAt = this.findInsertionPoint(ev);
            if (insertAt === undefined) {
                return false;
            }
            let closestElement = insertAt.node;
            if (closestElement.nodeType === Node.TEXT_NODE) {
                closestElement = closestElement.parentNode;
            }
            if (!domtypeguards_1.isElement(closestElement)) {
                throw new Error("we should be landing on an element");
            }
            let item;
            let marker = err.marker;
            // We may be getting here with an error that already has a marker. It has
            // already been "processed" and only needs its location updated. Otherwise,
            // this is a new error: create a list item and marker for it.
            if (marker === undefined) {
                // Turn the names into qualified names.
                const convertedNames = convertNames(error, this.resolver);
                const doc = insertAt.node.ownerDocument;
                item = doc.createElement("li");
                const linkId = item.id = util_1.newGenericID();
                if (domtypeguards_1.isAttr(dataNode) &&
                    domutil_1.isNotDisplayed(closestElement, insertAt.root)) {
                    item.textContent = error.toStringWithNames(convertedNames);
                    item.title = "This error belongs to an attribute " +
                        "which is not currently displayed.";
                }
                else {
                    marker = doc.createElement("span");
                    marker.className = "_phantom wed-validation-error";
                    const $marker = $(marker);
                    $marker.mousedown(() => {
                        this.$errorList.parents(".panel-collapse").collapse("show");
                        const $link = $(this.errorList.querySelector(`#${linkId}`));
                        const $scrollable = this.$errorList.parent(".panel-body");
                        $scrollable.animate({
                            scrollTop: $link.offset().top - $scrollable.offset().top +
                                $scrollable[0].scrollTop,
                        });
                        this.errorLayer.select(marker);
                        $link.siblings().removeClass("selected");
                        $link.addClass("selected");
                        // We move the caret ourselves and prevent further processing of this
                        // event. Older versions of wed let the event trickle up and be
                        // handled by the general caret movement code but that would sometimes
                        // result in a caret being put in a bad position.
                        this.editor.caretManager.setCaret(insertAt);
                        return false;
                    });
                    const markerId = marker.id = util_1.newGenericID();
                    const link = doc.createElement("a");
                    link.href = `#${markerId}`;
                    link.textContent = error.toStringWithNames(convertedNames);
                    item.appendChild(link);
                    $(item.firstElementChild).click(err, this.errorItemHandler);
                }
            }
            // Update the marker's location.
            if (marker !== undefined) {
                const { top, left } = wed_util_1.boundaryXY(insertAt);
                const { scrollTop, scrollLeft } = this.scroller;
                const scrollerPos = this.scroller.getBoundingClientRect();
                const fontSize = parseFloat(this.editor.window.getComputedStyle(closestElement)
                    .fontSize);
                const height = fontSize * 0.2;
                marker.style.height = `${height}px`;
                // We move down from the top of the box produced by boundaryXY because
                // when targeting parent, it may return a box which is as high as the
                // parent's contents.
                marker.style.top =
                    `${top + fontSize - height - scrollerPos.top + scrollTop}px`;
                marker.style.left = `${left - scrollerPos.left + scrollLeft}px`;
            }
            if (err.item === undefined) {
                err.item = item;
            }
            err.marker = marker;
            return true;
        }
        /**
         * Clear all validation errors. This makes the editor forget and updates the
         * GUI to remove all displayed errors.
         */
        clearErrors() {
            this._errors = [];
            this.refreshErrorsRunner.stop();
            this.processErrorsRunner.stop();
            this.errorLayer.clear();
            const list = this.errorList;
            while (list.lastChild != null) {
                list.removeChild(list.lastChild);
            }
            this.refreshErrorsRunner.reset();
            this.processErrorsRunner.reset();
        }
        /**
         * Terminate the controller. This stops all runners and clears any unexpired
         * timeout.
         */
        terminate() {
            if (this.processErrorsTimeout !== undefined) {
                clearTimeout(this.processErrorsTimeout);
            }
            this.stop();
        }
        /**
         * This method updates the location markers of the errors.
         */
        refreshErrors() {
            this.refreshErrorsRunner.reset();
            this.editor.resumeTaskWhenPossible(this.refreshErrorsRunner);
        }
        /**
         * This method recreates the error messages and the error markers associated
         * with the errors that the editor already knows.
         */
        recreateErrors() {
            this.errorLayer.clear();
            const list = this.errorList;
            while (list.lastChild !== null) {
                list.removeChild(list.lastChild);
            }
            for (const error of this._errors) {
                error.marker = undefined;
                error.item = undefined;
            }
            this.processErrors();
        }
        /**
         * Add items to the list of errors.
         *
         * @param items The items to add to the list of errors.
         */
        appendItems(items) {
            this.errorList.appendChild(elementsToFrag(this.document, items));
        }
        /**
         * Add markers to the layer that is used to contain error markers.
         *
         * @param markers The markers to add.
         */
        appendMarkers(markers) {
            this.errorLayer.append(elementsToFrag(this.document, markers));
        }
    }
    exports.ValidationController = ValidationController;
});
//  LocalWords:  MPL scroller processErrors li markerId loc scrollerPos px
//  LocalWords:  scrollTop scrollLeft
//# sourceMappingURL=validation-controller.js.map