/*
Copyright The Infusion copyright holders
See the AUTHORS.md file at the top-level directory of this distribution and at
https://github.com/fluid-project/infusion/raw/main/AUTHORS.md.

Licensed under the Educational Community License (ECL), Version 2.0 or the New
BSD license. You may not use this file except in compliance with one these
Licenses.

You may obtain a copy of the ECL 2.0 License and BSD License at
https://github.com/fluid-project/infusion/raw/main/Infusion-LICENSE.txt
*/

"use strict";

/**********************************************
 * fluid.orator
 *
 * A component for self voicing a web page
 **********************************************/

fluid.defaults("fluid.orator", {
    gradeNames: ["fluid.viewComponent"],
    selectors: {
        // TODO: AMB 14/9/22 This selector appears unused. The controller instead needs a "parentContainer" literal element supplied -
        // The enactor then reaches in and overrides this
        controller: ".flc-orator-controller",
        content: ".flc-orator-content"
    },
    model: {
        enabled: true,
        play: false
    },
    components: {
        tts: {
            type: "fluid.textToSpeech"
        },
        controller: {
            type: "fluid.orator.controller",
            options: {
                parentContainer: "{orator}.container",
                model: {
                    playing: "{orator}.model.play",
                    enabled: "{orator}.model.enabled"
                }
            }
        },
        selectionReader: {
            type: "fluid.orator.selectionReader",
            container: "{that}.container",
            options: {
                model: {
                    enabled: "{orator}.model.enabled"
                }
            }
        },
        domReader: {
            type: "fluid.orator.domReader",
            container: "{that}.dom.content",
            options: {
                model: {
                    tts: {
                        enabled: "{orator}.model.enabled"
                    }
                },
                listeners: {
                    "onStop.domReaderStop": {
                        changePath: "{orator}.model.play",
                        value: false,
                        source: "domReader.onStop",
                        priority: "after:removeHighlight"
                    }
                },
                modelListeners: {
                    "{orator}.model.play": {
                        funcName: "fluid.orator.handlePlayToggle",
                        args: ["{that}", "{change}.value"],
                        namespace: "domReader.handlePlayToggle"
                    }
                }
            }
        }
    },
    modelListeners: {
        "enabled": {
            listener: "fluid.orator.cancelWhenDisabled",
            args: ["{tts}.cancel", "{change}.value"],
            namespace: "orator.clearSpeech"
        }
    },
    distributeOptions: [{
        source: "{that}.options.tts",
        target: "{that tts}.options",
        removeSource: true,
        namespace: "ttsOpts"
    }, {
        source: "{that}.options.controller",
        target: "{that controller}.options",
        removeSource: true,
        namespace: "controllerOpts"
    }, {
        source: "{that}.options.domReader",
        target: "{that domReader}.options",
        removeSource: true,
        namespace: "domReaderOpts"
    }, {
        source: "{that}.options.selectionReader",
        target: "{that selectionReader}.options",
        removeSource: true,
        namespace: "selectionReaderOpts"
    }]
});

// TODO: When https://issues.fluidproject.org/browse/FLUID-6393 has been addressed, it will be possible to remove
//       this function and directly configure the modelListener to only trigger when a false value is passed.
fluid.orator.cancelWhenDisabled = function (cancelFn, state) {
    if (!state) {
        cancelFn();
    }
};

fluid.orator.handlePlayToggle = function (that, state) {
    that.events[state ? "play" : "pause"].fire();
};

/**********************************************
 * fluid.orator.controller
 *
 * Provides a UI Widget to control the Orator
 **********************************************/

fluid.defaults("fluid.orator.controller", {
    gradeNames: ["fluid.containerRenderingView"],
    selectors: {
        playToggle: ".flc-orator-controller-playToggle"
    },
    styles: {
        play: "fl-orator-controller-play"
    },
    strings: {
        play: "play",
        pause: "pause"
    },
    model: {
        playing: false,
        enabled: true
    },
    injectionType: "prepend",
    markup: {
        container: "<div class=\"flc-orator-controller fl-orator-controller\">" +
            "<div class=\"fl-icon-orator\" aria-hidden=\"true\"></div>" +
            "<button type=\"button\" class=\"flc-orator-controller-playToggle\">" +
                "<span class=\"fl-orator-controller-playToggle fl-icon-orator-playToggle\" aria-hidden=\"true\"></span>" +
            "</button></div>"
    },
    invokers: {
        play: {
            changePath: "playing",
            value: true,
            source: "play"
        },
        pause: {
            changePath: "playing",
            value: false,
            source: "pause"
        },
        toggle: {
            funcName: "fluid.orator.controller.toggleState",
            args: ["{that}", "{arguments}.0", "{arguments}.1"]
        }
    },
    listeners: {
        "onCreate.bindClick": {
            listener: "fluid.orator.controller.bindClick",
            args: ["{that}"]
        }
    },
    modelListeners: {
        "playing": {
            listener: "fluid.orator.controller.setToggleView",
            args: ["{that}", "{change}.value"],
            namespace: "toggleController"
        },
        "enabled": {
            "this": "{that}.container",
            method: "toggle",
            args: ["{change}.value"],
            namespace: "toggleView"
        }
    }
});

/**
 * Binds the click event for the "playToggle" element to trigger the `that.toggle` method.
 * This is not bound declaratively to ensure that the correct arguments are passed along to the `that.toggle`
 * method.
 *
 * @param {fluid.orator.controller} that - an instance of the component
 */
fluid.orator.controller.bindClick = function (that) {
    that.locate("playToggle").on("click", function () {
        that.toggle("playing");
    });
};

/**
 * Used to toggle the state of a model value at a specified path. The new state will be the inverse of the current
 * boolean value at the specified model path, or can be set explicitly by passing in a `state` value. It's likely
 * that this method will be used in conjunction with a click handler. In that case, it's most likely that the state
 * will be toggling the existing model value.
 *
 * @param {fluid.orator.controller} that - an instance of the component
 * @param {String|Array} path - the path, into the model, for the value to toggle
 * @param {Boolean} state - (optional) explicit state to set the model value to
 */
fluid.orator.controller.toggleState = function (that, path, state) {
    var newState = fluid.isValue(state) ? state : !fluid.get(that.model, path);
    // the !! ensures that the newState is a boolean value.
    that.applier.change(path, !!newState, "ADD", "toggleState");
};

/**
 * Sets the view state of the toggle controller.
 * True - play style added
 *      - aria-label set to the `pause` string
 * False - play style removed
 *       - aria-label set to the `play` string
 *
 * @param {fluid.orator.controller} that - an instance of the component
 * @param {Boolean} state - the state to set the controller to
 */
fluid.orator.controller.setToggleView = function (that, state) {
    var playToggle = that.locate("playToggle");
    playToggle.toggleClass(that.options.styles.play, state);
    playToggle.attr({
        "aria-label": that.options.strings[state ? "pause" : "play"]
    });
};


/*******************************************************************************
 * fluid.orator.domReader
 *
 * Reads in text from a DOM element and voices it
 *******************************************************************************/

fluid.defaults("fluid.orator.domReader", {
    gradeNames: ["fluid.viewComponent"],
    selectors: {
        highlight: ".flc-orator-highlight"
    },
    markup: {
        highlight: "<mark class=\"flc-orator-highlight fl-orator-highlight\"></mark>"
    },
    events: {
        play: null,
        pause: null,
        readFromDOM: null,
        onQueueSpeech: null,
        utteranceOnEnd: null,
        utteranceOnBoundary: null,
        utteranceOnError: null,
        utteranceOnMark: null,
        utteranceOnPause: null,
        utteranceOnResume: null,
        utteranceOnStart: null,
        onStop: null,
        onError: null
    },
    utteranceEventMap: {
        onboundary: "utteranceOnBoundary",
        onend: "utteranceOnEnd",
        onerror: "utteranceOnError",
        onmark:"utteranceOnMark",
        onpause: "utteranceOnPause",
        onresume: "utteranceOnResume",
        onstart: "utteranceOnStart"
    },
    model: {
        tts: {
            paused: false,
            speaking: false,
            enabled: true
        },
        parseQueueIndex: 0,
        parseIndex: null,
        ttsBoundary: null,
        parseQueueCount: 0,
        parseItemCount: 0
    },
    modelRelay: [{
        target: "parseIndex",
        backward: "never",
        forward: {
            excludeSource: ["utteranceOnPause", "init"]
        },
        namespace: "getClosestIndex",
        singleTransform: {
            type: "fluid.transforms.free",
            func: "fluid.orator.domReader.getClosestIndex",
            args: ["{that}", "{that}.model.ttsBoundary", "{that}.model.parseQueueIndex"]
        }
    }],
    members: {
        parseQueue: []
    },
    components: {
        parser: {
            type: "fluid.textNodeParser",
            options: {
                listeners: {
                    "onParsedTextNode.addToParseQueue": "{domReader}.addToParseQueue"
                }
            }
        }
    },
    invokers: {
        parsedToString: "fluid.orator.domReader.parsedToString",
        removeHighlight: {
            funcName: "fluid.orator.domReader.unWrap",
            args: ["{that}.dom.highlight"]
        },
        addToParseQueue: {
            funcName: "fluid.orator.domReader.addToParseQueue",
            args: ["{that}", "{arguments}.0", "{that}.retrieveActiveQueue"]
        },
        retrieveActiveQueue: "fluid.orator.domReader.retrieveActiveQueue",
        resetParseQueue: {
            funcName: "fluid.orator.domReader.resetParseQueue",
            args: ["{that}"]
        },
        highlight: {
            funcName: "fluid.orator.domReader.highlight",
            args: ["{that}"]
        },
        queueSpeech: {
            funcName: "fluid.orator.domReader.queueSpeech",
            args: ["{that}", "{arguments}.0", "{arguments}.1"]
        },
        isWord: "fluid.textNodeParser.hasGlyph"
    },
    modelListeners: {
        "parseIndex": {
            listener: "{that}.highlight",
            namespace: "highlight",
            excludeSource: ["init", "utteranceOnEnd", "resetParseQueue"]
        }
    },
    listeners: {
        "play.impl": {
            funcName: "fluid.orator.domReader.play",
            args: ["{that}", "{fluid.textToSpeech}.resume"]
        },
        "pause.impl": {
            funcName: "fluid.orator.domReader.pause",
            args: ["{that}", "{fluid.textToSpeech}.pause"]
        },
        "readFromDOM.impl": {
            funcName: "fluid.orator.domReader.readFromDOM",
            args: ["{that}", "{that}.container"]
        },
        "onQueueSpeech.removeExtraWhiteSpace": "fluid.orator.domReader.removeExtraWhiteSpace",
        "onQueueSpeech.queueSpeech": {
            func: "{fluid.textToSpeech}.queueSpeech",
            args: ["{arguments}.0", "{arguments}.1.interrupt", "{arguments}.1"],
            priority: "after:removeExtraWhiteSpace"
        },
        "onError.stop": "{that}.events.onStop",
        "onStop.resetParseQueue": {
            listener: "{that}.resetParseQueue"
        },
        "onStop.removeHighlight": {
            listener: "{that}.removeHighlight",
            priority: "after:resetParseQueue"
        },
        "onStop.updateTTSModel": {
            changePath: "tts",
            value: {
                speaking: false,
                paused: false
            },
            source: "onStop"
        },
        "utteranceOnEnd.resetParseIndex": {
            changePath: "",
            value: {
                parseIndex: null
            },
            source: "utteranceOnEnd"
        },
        "utteranceOnStart.updateTTSModel": {
            changePath: "tts",
            value: {
                speaking: true,
                paused: false
            },
            source: "utteranceOnStart"
        },
        "utteranceOnPause.updateTTSModel": {
            changePath: "tts",
            value: {
                speaking: true,
                paused: true
            },
            source: "utteranceOnPause"
        },
        // needed to prevent the parseQueueIndex from incrementing when the resume is called, instead of continuing on.
        "utteranceOnPause.resetBoundary": {
            changePath: "ttsBoundary",
            value: null,
            source: "utteranceOnPause"
        },
        "utteranceOnResume.updateTTSModel": {
            changePath: "tts",
            value: {
                speaking: true,
                paused: false
            },
            source: "utteranceOnResume"
        },
        "utteranceOnBoundary.setCurrentBoundary": {
            listener: "fluid.orator.domReader.setCurrentBoundary",
            args: ["{that}", "{arguments}.0.charIndex", "{arguments}.0.name"]
        }
    }
});

/**
 * Updates the `ttsBoundary` and `parseQueueIndex` model paths based on the provided boundary. Attempts to determine
 * if the supplied boundary is derived from the current queue or if the parseQueueIndex needs to be incremented.
 *
 * @param  {fluid.orator.domReader} that - an instance of the component
 * @param  {Integer} boundary - the incoming boundary, typically from a {SpeechSynthesisUtterance} boundary event.
 *                              This indicates the starting index of the word being Synthesized.
 * @param  {String} boundaryType - Boundary events can fire at the beginning of a "word" or "sentence". This is used
 *                                 to indicate which one it is related to. From the {SpeechSynthesisUtterance}
 *                                 boundary event this is found in the `name` property. Currently only `"word"`
 *                                 boundary events are supported. All others will be ignored.
 */
fluid.orator.domReader.setCurrentBoundary = function (that, boundary, boundaryType) {
    // It is possible that the pause event triggers before all of the boundary events have been received.
    // The following check prevents boundary events from updating the model if TTS is paused.
    // Also we currently only support "word" boundary events. Some synthesizers also fire boundary events
    // In those cases, we will get two boundary events for the beginning which will confuse the parseQueueIndex
    // incrementing algorithm. At the moment we ignore any non-word boundary events. In the future we may also
    // accept sentence boundary events for potentially highlighting sentences as well.
    if (that.model.tts.paused || boundaryType !== "word") {
        return;
    }

    var currentBoundary = fluid.isValue(that.model.ttsBoundary) ? that.model.ttsBoundary : -1;
    var parseQueueIndex;

    if (currentBoundary < boundary) {
        parseQueueIndex = that.model.parseQueueIndex;
    } else {
        parseQueueIndex = that.model.parseQueueIndex + 1;
    }

    that.applier.change("", {
        "ttsBoundary": boundary,
        "parseQueueIndex": parseQueueIndex
    }, "ADD", "setCurrentBoundary");
};

fluid.orator.domReader.play = function (that, resumeFn) {
    if (that.model.tts.enabled) {
        if (that.model.tts.paused) {
            resumeFn();
        } else if (!that.model.tts.speaking) {
            that.events.readFromDOM.fire();
        }
    }
};

fluid.orator.domReader.pause = function (that, pauseFn) {
    if (that.model.tts.speaking && !that.model.tts.paused) {
        pauseFn();
    }
};

fluid.orator.domReader.mapUtteranceEvents = function (that, utterance, utteranceEventMap) {
    fluid.each(utteranceEventMap, function (compEventName, utteranceEvent) {
        var compEvent = that.events[compEventName];
        utterance[utteranceEvent] = compEvent.fire;
    });
};

fluid.orator.domReader.removeExtraWhiteSpace = function (text) {
    var promise = fluid.promise();
    // force a string value
    var str = text.toString();
    // trim whitespace
    str = str.trim();

    if (str) {
        promise.resolve(str);
    } else {
        promise.reject("The text is empty");
    }

    return promise;
};

/**
 * Operates the core "transforming promise workflow" for queuing an utterance. The initial listener is provided the
 * initial text; which then proceeds through the transform chain to arrive at the final text.
 * To change the speech function (e.g for testing) the `onQueueSpeech.queueSpeech` listener can be overridden.
 *
 * @param {fluid.orator.domReader} that - an instance of the component
 * @param {String} text - The text to be synthesized
 * @param {Object} options - (optional) options to configure the utterance with. This will also be interpolated with
 *                           the event mappings. See: `fluid.textToSpeech.queueSpeech` in TextToSpeech.js for an
 *                           example of utterance options for that speech function.
 *
 * @return {Promise} - A promise for the final resolved text
 */
fluid.orator.domReader.queueSpeech = function (that, text, options) {
    options = options || {};
    // map events
    fluid.orator.domReader.mapUtteranceEvents(that, options, that.options.utteranceEventMap);

    return fluid.promise.fireTransformEvent(that.events.onQueueSpeech, text, options);
};

/**
 * Unwraps the contents of the element by removing the tag surrounding the content and placing the content
 * as a node within the element's parent. The parent is also normalized to combine any adjacent text nodes.
 *
 * @param {String|jQuery|DomElement} elm - element to unwrap
 */
fluid.orator.domReader.unWrap = function (elm) {
    elm = $(elm);

    if (elm.length) {
        var parent = elm.parent();
        // Remove the element, but place its contents within the parent.
        elm.contents().unwrap();
        // Normalize the parent to cleanup text nodes
        parent[0].normalize();
    }
};

/**
 * Positional information about a word parsed from the text in a {DomElement}. This can be used for mappings between
 * a synthesizer's speech boundary and the word's location within the DOM.
 *
 * @typedef {Object} DomWordMap
 * @property {Integer} blockIndex - The index into the entire block of text being parsed from the DOM
 * @property {Integer} startOffset - The start offset of the current `word` relative to the closest
 *                                   enclosing DOM element
 * @property {Integer} endOffset - The end offset of the current `word` relative to the closest
 *                                 enclosing DOM element
 * @property {DomNode} node - The current child node being parsed
 * @property {Integer} childIndex - The index of the child node being parsed relative to its parent
 * @property {DomElement} parentNode - The parent DOM node
 * @property {String} word - The text, `word`, parsed from the node. It may contain only whitespace.
 */

/**
 * Retrieves the active parseQueue array to be used when updating from the latest parsed text node. Will increment
 * the parseQueue with a new empty array if one doesn't already exist or if the language has changed.
 *
 * @param {fluid.orator.domReader} that - an instance of the component
 * @param {String} lang - a valid BCP 47 language code.
 *
 * @return {Array} - the parseQueue array to update with the latest parsed text node.
 */
fluid.orator.domReader.retrieveActiveQueue = function (that, lang) {
    var lastQueue = that.parseQueue[that.parseQueue.length - 1];

    if (!lastQueue ||  (lastQueue.length && lastQueue[0].lang !== lang)) {
        lastQueue = [];
        that.parseQueue.push(lastQueue);
        that.applier.change("parseQueueCount", that.parseQueue.length, "ADD", "retrieveActiveQueue");
    }

    return lastQueue;
};

/**
 * Takes in a text node and separates the contained words into {DomWordMaps} that are added to the `parseQueue`.
 * Typically this handles parsed data passed along by a Parser's (`fluid.textNodeParser`) `onParsedTextNode` event.
 * Empty nodes are skipped and the subsequent text is analyzed to determine if it should be appended to the
 * previous {DomWordMap} in the parseQueue. For example: when the syllabification separator tag is inserted
 * between words.
 *
 * @param {fluid.orator.domReader} that - an instance of the component
 * @param {TextNodeData} textNodeData - the parsed information of text node. Typically from `fluid.textNodeParser`
 * @param {Function} retrieveActiveQueue - a function, usually, `fluid.orator.domReader.retrieveActiveQueue`, retrieves
 *                                         and manages the parseQueue.
 */
fluid.orator.domReader.addToParseQueue = function (that, textNodeData, retrieveActiveQueue) {
    var activeQueue = retrieveActiveQueue(that, textNodeData.lang);
    var lastParsed = fluid.peek(activeQueue) || {};

    var words = textNodeData.node.textContent.split(/(\s+)/); // split on whitespace, and capture whitespace

    var parsed = $.extend({}, textNodeData, {
        blockIndex: (lastParsed.blockIndex || 0) + (fluid.get(lastParsed, ["word", "length"]) || 0),
        startOffset: 0,
        parentNode: textNodeData.node.parentNode
    });

    fluid.each(words, function (word) {
        var lastIsWord = that.isWord(lastParsed.word);
        var currentIsWord = that.isWord(word);

        // If the last parsed item is a word and the current item is a word, combine into the the last parsed block.
        // Otherwise, if the new item is a word or non-empty string create a new parsed block.
        if (lastIsWord && currentIsWord) {
            lastParsed.word += word;
            lastParsed.endOffset += word.length;
            parsed.blockIndex += word.length;
            parsed.startOffset += word.length;
        } else {
            parsed.word = word;
            parsed.endOffset = parsed.startOffset + word.length;
            if (currentIsWord || word && lastIsWord) {
                lastParsed = fluid.copy(parsed);
                activeQueue.push(lastParsed);
                that.applier.change("parseItemCount", that.model.parseItemCount + 1, "ADD", "addToParseQueue");
                parsed.blockIndex += word.length;
            }
            parsed.startOffset = parsed.endOffset;
        }
    });
};

/**
 * Reset the parseQueue and related model values
 *
 * @param {fluid.orator.domReader} that - an instance of the component
 */
fluid.orator.domReader.resetParseQueue = function (that) {
    that.parseQueue = [];
    that.applier.change("", {
        parseQueueIndex: 0,
        parseIndex: null,
        ttsBoundary: null,
        parseQueueCount: 0,
        parseItemCount: 0
    }, "ADD", "resetParseQueue");
};

/**
 * Combines the parsed text into a String.
 *
 * @param {DomWordMap[]} parsed - An array of {DomWordMap} objects containing the position mappings from a parsed
 *                                {DomElement}.
 *
 * @return {String} - The parsed text combined into a String.
 */
fluid.orator.domReader.parsedToString = function (parsed) {
    var words = fluid.transform(parsed, function (block) {
        return block.word;
    });

    return words.join("");
};

/**
 * Parses the DOM element into data points to use for highlighting the text, and queues the text into the self
 * voicing engine. The parsed data points are added to the component's `parseQueue`. Once all of the text has been
 * synthesized, the `onStop` event is fired.
 *
 * @param {fluid.orator.domReader} that - an instance of the component
 * @param {String|jQuery|DomElement} elm - The DOM node to read
 */
fluid.orator.domReader.readFromDOM = function (that, elm) {
    elm = $(elm);

    // only execute if there are nodes to read from
    if (elm.length) {
        that.resetParseQueue();
        that.parser.parse(elm[0]);

        var queueSpeechPromises = fluid.transform(that.parseQueue, function (parsedBlock, index) {
            var interrupt = !index; // only interrupt on the first string
            var text = that.parsedToString(parsedBlock);
            return that.queueSpeech(text, {lang: parsedBlock[0].lang, interrupt: interrupt});
        });

        fluid.promise.sequence(queueSpeechPromises).then(that.events.onStop.fire, that.events.onError.fire);
    }
};

/**
 * Returns the index of the closest data point from the parseQueue based on the boundary provided.
 *
 * @param {fluid.orator.domReader} that - an instance of the component
 * @param {Integer} boundary - The boundary value used to compare against the blockIndex of the parsed data points.
 *                             If the boundary is undefined or out of bounds, `undefined` will be returned.
 * @param {Integer} parseQueueIndex - The index of into the parseQueue to determine which queue to use for
 *                                    calculating the boundary positions against.
 *
 * @return {Integer|undefined} - Will return the index of the closest data point in the parseQueue. If the boundary
 *                               cannot be located within the parseQueue, `undefined` is returned.
 */
fluid.orator.domReader.getClosestIndex = function (that, boundary, parseQueueIndex) {
    // TODO: Model relay rules will not reread volatile material - we must use "that" here
    var parseQueue = that.parseQueue[parseQueueIndex];

    if (!fluid.get(parseQueue, "length") || !fluid.isValue(boundary)) {
        return undefined;
    };

    var maxIndex = Math.max(parseQueue.length - 1, 0);
    var index = Math.max(Math.min(that.model.parseIndex || 0, maxIndex), 0);
    var maxBoundary = parseQueue[maxIndex].blockIndex + parseQueue[maxIndex].word.length;

    if (boundary > maxBoundary || boundary < 0) {
        return undefined;
    }

    while (index >= 0) {
        var nextIndex = index + 1;
        var prevIndex = index - 1;
        var currentBlockIndex = parseQueue[index].blockIndex;
        var nextBlockIndex = index < maxIndex ? parseQueue[nextIndex].blockIndex : (maxBoundary + 1);

        // Break if the boundary lies within the current block
        if (boundary >= currentBlockIndex && boundary < nextBlockIndex) {
            break;
        }

        if (currentBlockIndex > boundary) {
            index = prevIndex;
        } else {
            index = nextIndex;
        }
    }

    return index;

};

/**
 * Searches down, starting from the provided node, returning the first text node found.
 *
 * @param  {DomNode} node - the DOM Node to start searching from.
 * @return {DomNode|undefined} - Returns the first text node found, or `undefined` if none located.
 */
fluid.orator.domReader.findTextNode = function (node) {
    if (!node) {
        return;
    }

    if (node.nodeType === Node.TEXT_NODE) {
        return node;
    }

    var children = node.childNodes;
    for (var i = 0; i < children.length; i++) {
        var textNode = fluid.orator.domReader.findTextNode(children[i]);
        if (textNode !== undefined) {
            return textNode;
        }
    }
};

fluid.orator.domReader.getTextNodeFromSibling = function (node) {
    while (node.nextSibling) {
        node = node.nextSibling;
        var textNode = fluid.orator.domReader.findTextNode(node);
        if (textNode) {
            return textNode;
        }
    }
};

fluid.orator.domReader.getNextTextNode = function (node) {
    var nextTextNode = fluid.orator.domReader.getTextNodeFromSibling(node);

    if (nextTextNode) {
        return nextTextNode;
    }

    var parent = node.parentNode;

    if (parent) {
        return fluid.orator.domReader.getNextTextNode(parent);
    }
};

fluid.orator.domReader.setRangeEnd = function (range, node, end) {
    var ranges = fluid.makeArray(range);
    if (end <= node.length) {
        range.setEnd(node, end);
    } else {
        var nextRange = document.createRange();
        var nextTextNode = fluid.orator.domReader.getNextTextNode(node);
        nextRange.selectNode(nextTextNode);
        nextRange.setStart(nextTextNode, 0);
        ranges = ranges.concat(fluid.orator.domReader.setRangeEnd(nextRange, nextTextNode, end - node.length));
    }
    return ranges;
};

/**
 * Highlights text from the `parseQueue`. Highlights are performed by wrapping the appropriate text in the markup
 * specified at `that.options.markup.highlight`.
 *
 * @param {fluid.orator.domReader} that - an instance of the component
 */
fluid.orator.domReader.highlight = function (that) {
    that.removeHighlight();

    if (that.model.parseQueueCount && fluid.isValue(that.model.parseIndex)) {
        var data = that.parseQueue[that.model.parseQueueIndex][that.model.parseIndex];
        var rangeNode = data.parentNode.childNodes[data.childIndex];

        var startRange = document.createRange();
        startRange.selectNode(rangeNode);
        startRange.setStart(rangeNode, data.startOffset);
        var ranges = fluid.orator.domReader.setRangeEnd (startRange, rangeNode, data.endOffset);
        fluid.each(ranges, function (range) {
            range.surroundContents($(that.options.markup.highlight)[0]);
            range.detach(); // removes the range
        });
    }
};

/*******************************************************************************
 * fluid.orator.selectionReader
 *
 * Reads in text from a selection and voices it
 *******************************************************************************/

fluid.defaults("fluid.orator.selectionReader", {
    gradeNames: ["fluid.viewComponent"],
    selectors: {
        control: ".flc-orator-selectionReader-control",
        controlLabel: ".flc-orator-selectionReader-controlLabel"
    },
    strings: {
        play: "play",
        stop: "stop"
    },
    styles: {
        above: "fl-orator-selectionReader-above",
        below: "fl-orator-selectionReader-below",
        control: "fl-orator-selectionReader-control"
    },
    markup: {
        control: "<button type=\"button\" class=\"flc-orator-selectionReader-control\"><span class=\"fl-icon-orator\"></span><span class=\"flc-orator-selectionReader-controlLabel\"></span></button>"
    },
    model: {
        enabled: true,
        play: false,
        text: ""
    },
    events: {
        onError: null,
        onSelectionChanged: null,
        onStop: null,
        onToggleControl: null
    },
    components: {
        // This parser is only used within fluid.orator.selectionReader.parseElement by direct use of its "parse" function
        // and it does not need configuration
        parser: {
            type: "fluid.textNodeParser"
        }
    },
    listeners: {
        "onCreate.bindEvents": {
            funcName: "fluid.orator.selectionReader.bindSelectionEvents",
            args: ["{that}"]
        },
        "onSelectionChanged.updateSelection": "{that}.setTextFromSelection",
        "onStop.stop": {
            changePath: "play",
            value: false,
            source: "stopMethod"
        },
        "onError.stop": {
            changePath: "play",
            value: false,
            source: "onError"
        },
        "onToggleControl.togglePlay": "{that}.toggle"
    },
    modelListeners: {
        "text": [{
            func: "{that}.stop",
            namespace: "stopPlayingWhenTextChanges"
        }, {
            funcName: "fluid.orator.selectionReader.renderControl",
            args: ["{that}", "{change}.value"],
            namespace: "render"
        }],
        "play": [{
            funcName: "fluid.orator.selectionReader.queueSpeech",
            args: ["{that}", "{change}.value", "{fluid.textToSpeech}.queueSpeechSequence"],
            namespace: "queueSpeech"
        }, {
            func: "{that}.renderControlState",
            excludeSource: ["init"],
            namespace: "renderControlState"
        }],
        "enabled": {
            funcName: "fluid.orator.selectionReader.updateText",
            args: ["{that}", "{change}.value"],
            namespace: "updateText"
        }
    },
    invokers: {
        setTextFromSelection: {
            funcName: "fluid.orator.selectionReader.setTextFromSelection",
            args: ["{that}", "{that}.selectionFilter"]
        },
        play: {
            changePath: "play",
            value: true,
            source: "playMethod"
        },
        stop: {
            funcName: "fluid.orator.selectionReader.stopSpeech",
            args: ["{that}.model.play", "{fluid.textToSpeech}.cancel"]
        },
        toggle: {
            funcName: "fluid.orator.selectionReader.togglePlay",
            args: ["{that}", "{arguments}.0"]
        },
        selectionFilter: "fluid.textNodeParser.hasGlyph",
        calculatePosition: "fluid.orator.selectionReader.calculatePosition",
        createControl: {
            funcName: "fluid.orator.selectionReader.createControl",
            args: ["{that}"]
        },
        renderControlState: {
            funcName: "fluid.orator.selectionReader.renderControlState",
            args: ["{that}", "{that}.control"]
        },
        adjustForVerticalCollision: {
            funcName: "fluid.orator.selectionReader.adjustForVerticalCollision",
            args: ["{that}.control", "{arguments}.0", "{arguments}.1", "{arguments}.2"]
        },
        adjustForHorizontalCollision: {
            funcName: "fluid.orator.selectionReader.adjustForHorizontalCollision",
            args: ["{that}.control", "{arguments}.0"]
        }
    }
});

// TODO: When https://issues.fluidproject.org/browse/FLUID-6393 has been addressed, it will be possible to remove
//       this function and directly configure the modelListener to only trigger when a false value is passed.
fluid.orator.selectionReader.stopSpeech = function (state, cancelFn) {
    if (state) {
        cancelFn();
    }
};

fluid.orator.selectionReader.queueSpeech = function (that, state, speechFn) {
    if (state && that.model.enabled && that.model.text) {
        var parsed = fluid.orator.selectionReader.parseRange(that.selection.getRangeAt(0), that.parser.parse);
        var speechPromise = speechFn(parsed, true);

        speechPromise.then(that.events.onStop.fire, that.events.onError.fire);
    }
};

fluid.orator.selectionReader.bindSelectionEvents = function (that) {
    $(document).on("selectionchange", function (e) {
        if (that.model.enabled) {
            that.events.onSelectionChanged.fire(e);
        }
    });
};

fluid.orator.selectionReader.updateText = function (that, state) {
    if (state) {
        that.setTextFromSelection();
    } else {
        that.applier.change("text", "", "ADD", "updateText");
    }
};

/**
 * Retrieves the text from the current selection. If a filter is provided and the string does not pass the filter
 * check, an empty string `""` is used as the selection.
 *
 * @param {fluid.orator.selectionReader} that - an instance of the component
 * @param {Function} [filter] - (optional) a function that takes the selection string as an input and returns `true`
 *                              if it should be accepted and `false` if rejected. If the filter rejects, an empty
 *                              string `""` is used as the selection.
 */
fluid.orator.selectionReader.setTextFromSelection = function (that, filter) {
    that.selection = window.getSelection();
    var selectedText = that.selection.toString();
    selectedText = !filter || filter(selectedText) ? selectedText : "";
    that.applier.change("text", selectedText, "ADD", "setTextFromSelection");
};

/**
 * Parses a selection into a {Speech[]}. If the selection includes multiple text nodes, the supplied domParser is
 * used to do an initial parsing into a {TextNodeData[]}.
 *
 * @param  {Range} range - a Range object representing a selection.
 * @param  {Function} domParser - a parser function to parse Dom Elements into a {TextNodeData[]}
 *
 * @return {Speech[]} - an array of {Speech} objects for configuring SpeechSynthesis Utterances with.
 */
fluid.orator.selectionReader.parseRange = function (range, domParser) {
    // Handles the case where all of the selection is in a single text node. Don't need to parse in this case.
    if (range.commonAncestorContainer.nodeType === Node.TEXT_NODE) {
        return [{
            text: range.commonAncestorContainer.textContent.slice(range.startOffset, range.endOffset),
            options: {
                lang: $(range.commonAncestorContainer.parentNode).closest("[lang]").attr("lang")
            }
        }];
    }

    // Handles the case were range.selectNode was called to create the selection
    if (range.commonAncestorContainer === range.startContainer) {
        return fluid.orator.selectionReader.parseElement(range.commonAncestorContainer.childNodes[range.startOffset], domParser);
    }

    return fluid.orator.selectionReader.parseElement(range.commonAncestorContainer, domParser, range);
};

/**
 * The options for parsing an element into {Speech[]}. It has similar properties to a {Range} and is typically used
 * for `fluid.orator.selectionReader.parseElement`.
 * @type {Object} ParseElementOpts
 * @property {Integer} startOffset - the starting offset of the first text node. Text before will be omitted
 * @property {Integer} endOffset - the end offset of the last text node. Text after will be omitted.
 * @property {DomNode} startContainer - the text node to start parsing from.
 * @property {DomNode} endContainer - the text node to stop parsing at.
 */

/**
 * Parses an element into a {Speech[]}. The supplied domParser is used to do an initial parsing of the element into
 * a {TextNodeData[]}.
 *
 * @param  {DomElement} element - The DOM Element to parse
 * @param  {Function} domParser - a parser function to parse Dom Elements into a {TextNodeData[]}
 * @param  {ParseElementOpts} options - (Optional) parsing configuration
 * @return {Speech[]} - an array of {Speech} objects for configuring SpeechSynthesis Utterances with.
 */
fluid.orator.selectionReader.parseElement = function (element, domParser, options) {
    options = options || {};
    var parsed = [];

    var fromParser = domParser(element);
    var parsedNodes = fluid.getMembers(fromParser, "node");
    var startIndex  = options.startContainer ? parsedNodes.indexOf(options.startContainer) : 0;
    var endIndex = options.endContainer ? parsedNodes.indexOf(options.endContainer) : parsedNodes.length - 1;

    if (startIndex >= 0 && endIndex >= 0) {
        for (var i = startIndex; i <= endIndex; i++) {
            var startOffset = i === startIndex ? options.startOffset : 0;
            var endOffset = i === endIndex ? options.endOffset : undefined;
            var node = fromParser[i].node;
            var lang = fromParser[i].lang;
            var lastParsed = parsed[parsed.length - 1];

            if (parsed.length && lastParsed.options.lang === lang) {
                lastParsed.text += node.textContent.slice(startOffset, endOffset);
            } else {
                parsed.push({
                    text: node.textContent.slice(startOffset, endOffset),
                    options: {
                        lang: lang
                    }
                });
            }
        }
    }

    return parsed;
};

/**
 * Coordinates for an element, includes both viewPort and Document coordinates.
 *
 * @typedef {Object} ElementPosition
 * @property {Object} viewPort - the coordinates relative to the viewPort
 * @property {Float} viewPort.top - The `top` pixel coordinate relative to the top edge of the viewPort
 * @property {Float} viewPort.left - The `left` pixel coordinate relative to the left edge of the viewPort
 * @property {Object} offset - the coordinates relative to the offset parent (closest positioned ancestor)
 * @property {Float} offset.top - The `top` pixel coordinate relative to the offset parent
 * @property {Float} offset.left - The `left` pixel coordinate relative to the offset parent
 */

/**
 * Returns a position object containing coordinates of the provided range. These can be used to position other
 * elements in relation to it.
 *
 * @param {Range} range - A Range object for which to calculate the position of.
 *
 * @return {ElementPosition} - An object containing the coordinates of the provided `range`.
 */
fluid.orator.selectionReader.calculatePosition = function (range) {
    // use getClientRects()[0] instead of getBoundingClientRect() because in cases where more than one rect
    // is returned we only want the first one, not the aggregation of all of them.
    var rangeRect = range.getClientRects()[0];
    var rangeParent = range.startContainer.parentNode;
    var rangeParentRect = rangeParent.getClientRects()[0];
    var offsetParent = rangeParent.offsetParent;
    var bodyBorderAdjustment = {
        top: 0,
        left: 0
    };

    // If the offset parent is the `body` element and it is positioned, if there is a border set
    // on the `body` it may affect the offset value returned. In some browsers the outer edge of the
    // border is used to calculate the offset, in others it is the inner edge. The algorithm below can calculate
    // a needed adjustment value by comparing the offsetParent's offset and client values. In the case where the
    // Outer edge of the border is used, the offset is 0. In cases where the inner edge is used, the offset is a
    // negative value. The difference in the absolute value of the offset and the client values, is the amount
    // that the positioning needs to be adjusted for.
    if (offsetParent && offsetParent.tagName.toLowerCase() === "body") {
        bodyBorderAdjustment.top = Math.abs(offsetParent.offsetTop) - offsetParent.clientTop;
        bodyBorderAdjustment.left = Math.abs(offsetParent.offsetLeft) - offsetParent.clientLeft;
    }

    return {
        viewPort: {
            top: rangeRect.top,
            bottom: rangeRect.bottom,
            left: rangeRect.left
        },
        offset: {
            top: rangeParent.offsetTop + rangeRect.top - rangeParentRect.top + bodyBorderAdjustment.top,
            bottom: rangeParent.offsetTop + rangeRect.bottom - rangeParentRect.top + bodyBorderAdjustment.top,
            left: rangeParent.offsetLeft + rangeRect.left - rangeParentRect.left + bodyBorderAdjustment.left
        }
    };
};

fluid.orator.selectionReader.renderControlState = function (that, control) {
    var text = that.options.strings[that.model.play ? "stop" : "play"];
    control.find(that.options.selectors.controlLabel).text(text);
};

fluid.orator.selectionReader.adjustForHorizontalCollision = function (control, position, viewPortWidth) {
    viewPortWidth = viewPortWidth || document.body.clientWidth;
    var controlMidPoint = parseFloat(control.css("width")) / 2;
    // check for collision on left side
    if (controlMidPoint > position.viewPort.left) {
        control.css("left", position.offset.left + controlMidPoint - position.viewPort.left);
    // check for collision on right side
    } else if (controlMidPoint + position.viewPort.left > viewPortWidth) {
        control.css("left", position.offset.left - viewPortWidth + position.viewPort.left);
    }
};

fluid.orator.selectionReader.adjustForVerticalCollision = function (control, position, belowStyle, aboveStyle) {
    var controlHeight = parseFloat(control.css("height"));
    if (controlHeight > position.viewPort.top) {
        control.css("top", position.offset.bottom);
        control.removeClass(aboveStyle);
        control.addClass(belowStyle);
    } else {
        control.removeClass(belowStyle);
        control.addClass(aboveStyle);
    }
};

fluid.orator.selectionReader.createControl = function (that) {
    var control = $(that.options.markup.control);
    control.addClass(that.options.styles.control);
    control.on("click", function () {
        // wrapped in an empty function so as not to pass along the jQuery event object
        that.events.onToggleControl.fire();
    });
    return control;
};

fluid.orator.selectionReader.renderControl = function (that, state) {
    if (state) {
        var selectionRange = window.getSelection().getRangeAt(0);
        var controlContainer = selectionRange.startContainer.parentNode.offsetParent || selectionRange.startContainer.parentNode;
        var position = that.calculatePosition(selectionRange);

        that.control = that.control || that.createControl();

        // set the intial position
        that.control.css({
            top:  position.offset.top,
            left: position.offset.left
        });

        that.renderControlState();
        that.control.appendTo(controlContainer);

        // check if there is space to display above, if not move to below selection
        that.adjustForVerticalCollision(
            position,
            that.options.styles.below,
            that.options.styles.above
        );

        // adjust horizontal position for collisions with the viewport edge.
        that.adjustForHorizontalCollision(position);

        // cleanup range
        selectionRange.detach();

    } else {
        if (that.control) {
            that.control.detach();
        }
    }
};

fluid.orator.selectionReader.togglePlay = function (that, state) {
    var newState = state || !that.model.play;
    that[newState ? "play" : "stop"]();
};
