/**
 * Highlighter module for Rangy, a cross-browser JavaScript range and selection library
 * https://github.com/timdown/rangy
 *
 * Depends on Rangy core, ClassApplier and optionally TextRange modules.
 *
 * Copyright 2015, Tim Down
 * Licensed under the MIT license.
 * Version: 1.3.0
 * Build date: 10 May 2015
 */
/*

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

(function(factory, root) {
    if (typeof define == "function" && define.amd) {
        // AMD. Register as an anonymous module with a dependency on Rangy.
        define(["./rangy-core"], factory);
    } else if (typeof module != "undefined" && typeof exports == "object") {
        // Node/CommonJS style
        module.exports = factory( require("rangy") );
    } else {
        // No AMD or CommonJS support so we use the rangy property of root (probably the global variable)
        factory(root.rangy);
    }
})(function(rangy) {
    rangy.createModule("Highlighter", ["ClassApplier"], function(api, module) {
        var dom = api.dom;
        var contains = dom.arrayContains;
        var getBody = dom.getBody;
        var createOptions = api.util.createOptions;
        var forEach = api.util.forEach;
        var nextHighlightId = 1;

        // Puts highlights in order, last in document first.
        function compareHighlights(h1, h2) {
            return h1.characterRange.start - h2.characterRange.start;
        }

        function getContainerElement(doc, id) {
            return id ? doc.getElementById(id) : getBody(doc);
        }

        /*----------------------------------------------------------------------------------------------------------------*/

        var highlighterTypes = {};

        function HighlighterType(type, converterCreator) {
            this.type = type;
            this.converterCreator = converterCreator;
        }

        HighlighterType.prototype.create = function() {
            var converter = this.converterCreator();
            converter.type = this.type;
            return converter;
        };

        function registerHighlighterType(type, converterCreator) {
            highlighterTypes[type] = new HighlighterType(type, converterCreator);
        }

        function getConverter(type) {
            var highlighterType = highlighterTypes[type];
            if (highlighterType instanceof HighlighterType) {
                return highlighterType.create();
            } else {
                throw new Error("Highlighter type '" + type + "' is not valid");
            }
        }

        api.registerHighlighterType = registerHighlighterType;

        /*----------------------------------------------------------------------------------------------------------------*/

        function CharacterRange(start, end) {
            this.start = start;
            this.end = end;
        }

        CharacterRange.prototype = {
            intersects: function(charRange) {
                return this.start < charRange.end && this.end > charRange.start;
            },

            isContiguousWith: function(charRange) {
                return this.start == charRange.end || this.end == charRange.start;
            },

            union: function(charRange) {
                return new CharacterRange(Math.min(this.start, charRange.start), Math.max(this.end, charRange.end));
            },

            intersection: function(charRange) {
                return new CharacterRange(Math.max(this.start, charRange.start), Math.min(this.end, charRange.end));
            },

            getComplements: function(charRange) {
                var ranges = [];
                if (this.start >= charRange.start) {
                    if (this.end <= charRange.end) {
                        return [];
                    }
                    ranges.push(new CharacterRange(charRange.end, this.end));
                } else {
                    ranges.push(new CharacterRange(this.start, Math.min(this.end, charRange.start)));
                    if (this.end > charRange.end) {
                        ranges.push(new CharacterRange(charRange.end, this.end));
                    }
                }
                return ranges;
            },

            toString: function() {
                return "[CharacterRange(" + this.start + ", " + this.end + ")]";
            }
        };

        CharacterRange.fromCharacterRange = function(charRange) {
            return new CharacterRange(charRange.start, charRange.end);
        };

        /*----------------------------------------------------------------------------------------------------------------*/

        var textContentConverter = {
            rangeToCharacterRange: function(range, containerNode) {
                var bookmark = range.getBookmark(containerNode);
                return new CharacterRange(bookmark.start, bookmark.end);
            },

            characterRangeToRange: function(doc, characterRange, containerNode) {
                var range = api.createRange(doc);
                range.moveToBookmark({
                    start: characterRange.start,
                    end: characterRange.end,
                    containerNode: containerNode
                });

                return range;
            },

            serializeSelection: function(selection, containerNode) {
                var ranges = selection.getAllRanges(), rangeCount = ranges.length;
                var rangeInfos = [];

                var backward = rangeCount == 1 && selection.isBackward();

                for (var i = 0, len = ranges.length; i < len; ++i) {
                    rangeInfos[i] = {
                        characterRange: this.rangeToCharacterRange(ranges[i], containerNode),
                        backward: backward
                    };
                }

                return rangeInfos;
            },

            restoreSelection: function(selection, savedSelection, containerNode) {
                selection.removeAllRanges();
                var doc = selection.win.document;
                for (var i = 0, len = savedSelection.length, range, rangeInfo, characterRange; i < len; ++i) {
                    rangeInfo = savedSelection[i];
                    characterRange = rangeInfo.characterRange;
                    range = this.characterRangeToRange(doc, rangeInfo.characterRange, containerNode);
                    selection.addRange(range, rangeInfo.backward);
                }
            }
        };

        registerHighlighterType("textContent", function() {
            return textContentConverter;
        });

        /*----------------------------------------------------------------------------------------------------------------*/

        // Lazily load the TextRange-based converter so that the dependency is only checked when required.
        registerHighlighterType("TextRange", (function() {
            var converter;

            return function() {
                if (!converter) {
                    // Test that textRangeModule exists and is supported
                    var textRangeModule = api.modules.TextRange;
                    if (!textRangeModule) {
                        throw new Error("TextRange module is missing.");
                    } else if (!textRangeModule.supported) {
                        throw new Error("TextRange module is present but not supported.");
                    }

                    converter = {
                        rangeToCharacterRange: function(range, containerNode) {
                            return CharacterRange.fromCharacterRange( range.toCharacterRange(containerNode) );
                        },

                        characterRangeToRange: function(doc, characterRange, containerNode) {
                            var range = api.createRange(doc);
                            range.selectCharacters(containerNode, characterRange.start, characterRange.end);
                            return range;
                        },

                        serializeSelection: function(selection, containerNode) {
                            return selection.saveCharacterRanges(containerNode);
                        },

                        restoreSelection: function(selection, savedSelection, containerNode) {
                            selection.restoreCharacterRanges(containerNode, savedSelection);
                        }
                    };
                }

                return converter;
            };
        })());

        /*----------------------------------------------------------------------------------------------------------------*/

        function Highlight(doc, characterRange, classApplier, converter, id, containerElementId) {
            if (id) {
                this.id = id;
                nextHighlightId = Math.max(nextHighlightId, id + 1);
            } else {
                this.id = nextHighlightId++;
            }
            this.characterRange = characterRange;
            this.doc = doc;
            this.classApplier = classApplier;
            this.converter = converter;
            this.containerElementId = containerElementId || null;
            this.applied = false;
        }

        Highlight.prototype = {
            getContainerElement: function() {
                return getContainerElement(this.doc, this.containerElementId);
            },

            getRange: function() {
                return this.converter.characterRangeToRange(this.doc, this.characterRange, this.getContainerElement());
            },

            fromRange: function(range) {
                this.characterRange = this.converter.rangeToCharacterRange(range, this.getContainerElement());
            },

            getText: function() {
                return this.getRange().toString();
            },

            containsElement: function(el) {
                return this.getRange().containsNodeContents(el.firstChild);
            },

            unapply: function() {
                this.classApplier.undoToRange(this.getRange());
                this.applied = false;
            },

            apply: function() {
                this.classApplier.applyToRange(this.getRange());
                this.applied = true;
            },

            getHighlightElements: function() {
                return this.classApplier.getElementsWithClassIntersectingRange(this.getRange());
            },

            toString: function() {
                return "[Highlight(ID: " + this.id + ", class: " + this.classApplier.className + ", character range: " +
                    this.characterRange.start + " - " + this.characterRange.end + ")]";
            }
        };

        /*----------------------------------------------------------------------------------------------------------------*/

        function Highlighter(doc, type) {
            type = type || "textContent";
            this.doc = doc || document;
            this.classAppliers = {};
            this.highlights = [];
            this.converter = getConverter(type);
        }

        Highlighter.prototype = {
            addClassApplier: function(classApplier) {
                this.classAppliers[classApplier.className] = classApplier;
            },

            getHighlightForElement: function(el) {
                var highlights = this.highlights;
                for (var i = 0, len = highlights.length; i < len; ++i) {
                    if (highlights[i].containsElement(el)) {
                        return highlights[i];
                    }
                }
                return null;
            },

            removeHighlights: function(highlights) {
                for (var i = 0, len = this.highlights.length, highlight; i < len; ++i) {
                    highlight = this.highlights[i];
                    if (contains(highlights, highlight)) {
                        highlight.unapply();
                        this.highlights.splice(i--, 1);
                    }
                }
            },

            removeAllHighlights: function() {
                this.removeHighlights(this.highlights);
            },

            getIntersectingHighlights: function(ranges) {
                // Test each range against each of the highlighted ranges to see whether they overlap
                var intersectingHighlights = [], highlights = this.highlights;
                forEach(ranges, function(range) {
                    //var selCharRange = converter.rangeToCharacterRange(range);
                    forEach(highlights, function(highlight) {
                        if (range.intersectsRange( highlight.getRange() ) && !contains(intersectingHighlights, highlight)) {
                            intersectingHighlights.push(highlight);
                        }
                    });
                });

                return intersectingHighlights;
            },

            highlightCharacterRanges: function(className, charRanges, options) {
                var i, len, j;
                var highlights = this.highlights;
                var converter = this.converter;
                var doc = this.doc;
                var highlightsToRemove = [];
                var classApplier = className ? this.classAppliers[className] : null;

                options = createOptions(options, {
                    containerElementId: null,
                    exclusive: true
                });

                var containerElementId = options.containerElementId;
                var exclusive = options.exclusive;

                var containerElement, containerElementRange, containerElementCharRange;
                if (containerElementId) {
                    containerElement = this.doc.getElementById(containerElementId);
                    if (containerElement) {
                        containerElementRange = api.createRange(this.doc);
                        containerElementRange.selectNodeContents(containerElement);
                        containerElementCharRange = new CharacterRange(0, containerElementRange.toString().length);
                    }
                }

                var charRange, highlightCharRange, removeHighlight, isSameClassApplier, highlightsToKeep, splitHighlight;

                for (i = 0, len = charRanges.length; i < len; ++i) {
                    charRange = charRanges[i];
                    highlightsToKeep = [];

                    // Restrict character range to container element, if it exists
                    if (containerElementCharRange) {
                        charRange = charRange.intersection(containerElementCharRange);
                    }

                    // Ignore empty ranges
                    if (charRange.start == charRange.end) {
                        continue;
                    }

                    // Check for intersection with existing highlights. For each intersection, create a new highlight
                    // which is the union of the highlight range and the selected range
                    for (j = 0; j < highlights.length; ++j) {
                        removeHighlight = false;

                        if (containerElementId == highlights[j].containerElementId) {
                            highlightCharRange = highlights[j].characterRange;
                            isSameClassApplier = (classApplier == highlights[j].classApplier);
                            splitHighlight = !isSameClassApplier && exclusive;

                            // Replace the existing highlight if it needs to be:
                            //  1. merged (isSameClassApplier)
                            //  2. partially or entirely erased (className === null)
                            //  3. partially or entirely replaced (isSameClassApplier == false && exclusive == true)
                            if (    (highlightCharRange.intersects(charRange) || highlightCharRange.isContiguousWith(charRange)) &&
                                    (isSameClassApplier || splitHighlight) ) {

                                // Remove existing highlights, keeping the unselected parts
                                if (splitHighlight) {
                                    forEach(highlightCharRange.getComplements(charRange), function(rangeToAdd) {
                                        highlightsToKeep.push( new Highlight(doc, rangeToAdd, highlights[j].classApplier, converter, null, containerElementId) );
                                    });
                                }

                                removeHighlight = true;
                                if (isSameClassApplier) {
                                    charRange = highlightCharRange.union(charRange);
                                }
                            }
                        }

                        if (removeHighlight) {
                            highlightsToRemove.push(highlights[j]);
                            highlights[j] = new Highlight(doc, highlightCharRange.union(charRange), classApplier, converter, null, containerElementId);
                        } else {
                            highlightsToKeep.push(highlights[j]);
                        }
                    }

                    // Add new range
                    if (classApplier) {
                        highlightsToKeep.push(new Highlight(doc, charRange, classApplier, converter, null, containerElementId));
                    }
                    this.highlights = highlights = highlightsToKeep;
                }

                // Remove the old highlights
                forEach(highlightsToRemove, function(highlightToRemove) {
                    highlightToRemove.unapply();
                });

                // Apply new highlights
                var newHighlights = [];
                forEach(highlights, function(highlight) {
                    if (!highlight.applied) {
                        highlight.apply();
                        newHighlights.push(highlight);
                    }
                });

                return newHighlights;
            },

            highlightRanges: function(className, ranges, options) {
                var selCharRanges = [];
                var converter = this.converter;

                options = createOptions(options, {
                    containerElement: null,
                    exclusive: true
                });

                var containerElement = options.containerElement;
                var containerElementId = containerElement ? containerElement.id : null;
                var containerElementRange;
                if (containerElement) {
                    containerElementRange = api.createRange(containerElement);
                    containerElementRange.selectNodeContents(containerElement);
                }

                forEach(ranges, function(range) {
                    var scopedRange = containerElement ? containerElementRange.intersection(range) : range;
                    selCharRanges.push( converter.rangeToCharacterRange(scopedRange, containerElement || getBody(range.getDocument())) );
                });

                return this.highlightCharacterRanges(className, selCharRanges, {
                    containerElementId: containerElementId,
                    exclusive: options.exclusive
                });
            },

            highlightSelection: function(className, options) {
                var converter = this.converter;
                var classApplier = className ? this.classAppliers[className] : false;

                options = createOptions(options, {
                    containerElementId: null,
                    selection: api.getSelection(this.doc),
                    exclusive: true
                });

                var containerElementId = options.containerElementId;
                var exclusive = options.exclusive;
                var selection = options.selection;
                var doc = selection.win.document;
                var containerElement = getContainerElement(doc, containerElementId);

                if (!classApplier && className !== false) {
                    throw new Error("No class applier found for class '" + className + "'");
                }

                // Store the existing selection as character ranges
                var serializedSelection = converter.serializeSelection(selection, containerElement);

                // Create an array of selected character ranges
                var selCharRanges = [];
                forEach(serializedSelection, function(rangeInfo) {
                    selCharRanges.push( CharacterRange.fromCharacterRange(rangeInfo.characterRange) );
                });

                var newHighlights = this.highlightCharacterRanges(className, selCharRanges, {
                    containerElementId: containerElementId,
                    exclusive: exclusive
                });

                // Restore selection
                converter.restoreSelection(selection, serializedSelection, containerElement);

                return newHighlights;
            },

            unhighlightSelection: function(selection) {
                selection = selection || api.getSelection(this.doc);
                var intersectingHighlights = this.getIntersectingHighlights( selection.getAllRanges() );
                this.removeHighlights(intersectingHighlights);
                selection.removeAllRanges();
                return intersectingHighlights;
            },

            getHighlightsInSelection: function(selection) {
                selection = selection || api.getSelection(this.doc);
                return this.getIntersectingHighlights(selection.getAllRanges());
            },

            selectionOverlapsHighlight: function(selection) {
                return this.getHighlightsInSelection(selection).length > 0;
            },

            serialize: function(options) {
                var highlighter = this;
                var highlights = highlighter.highlights;
                var serializedType, serializedHighlights, convertType, serializationConverter;

                highlights.sort(compareHighlights);
                options = createOptions(options, {
                    serializeHighlightText: false,
                    type: highlighter.converter.type
                });

                serializedType = options.type;
                convertType = (serializedType != highlighter.converter.type);

                if (convertType) {
                    serializationConverter = getConverter(serializedType);
                }

                serializedHighlights = ["type:" + serializedType];

                forEach(highlights, function(highlight) {
                    var characterRange = highlight.characterRange;
                    var containerElement;

                    // Convert to the current Highlighter's type, if different from the serialization type
                    if (convertType) {
                        containerElement = highlight.getContainerElement();
                        characterRange = serializationConverter.rangeToCharacterRange(
                            highlighter.converter.characterRangeToRange(highlighter.doc, characterRange, containerElement),
                            containerElement
                        );
                    }

                    var parts = [
                        characterRange.start,
                        characterRange.end,
                        highlight.id,
                        highlight.classApplier.className,
                        highlight.containerElementId
                    ];

                    if (options.serializeHighlightText) {
                        parts.push(highlight.getText());
                    }
                    serializedHighlights.push( parts.join("$") );
                });

                return serializedHighlights.join("|");
            },

            deserialize: function(serialized) {
                var serializedHighlights = serialized.split("|");
                var highlights = [];

                var firstHighlight = serializedHighlights[0];
                var regexResult;
                var serializationType, serializationConverter, convertType = false;
                if ( firstHighlight && (regexResult = /^type:(\w+)$/.exec(firstHighlight)) ) {
                    serializationType = regexResult[1];
                    if (serializationType != this.converter.type) {
                        serializationConverter = getConverter(serializationType);
                        convertType = true;
                    }
                    serializedHighlights.shift();
                } else {
                    throw new Error("Serialized highlights are invalid.");
                }

                var classApplier, highlight, characterRange, containerElementId, containerElement;

                for (var i = serializedHighlights.length, parts; i-- > 0; ) {
                    parts = serializedHighlights[i].split("$");
                    characterRange = new CharacterRange(+parts[0], +parts[1]);
                    containerElementId = parts[4] || null;

                    // Convert to the current Highlighter's type, if different from the serialization type
                    if (convertType) {
                        containerElement = getContainerElement(this.doc, containerElementId);
                        characterRange = this.converter.rangeToCharacterRange(
                            serializationConverter.characterRangeToRange(this.doc, characterRange, containerElement),
                            containerElement
                        );
                    }

                    classApplier = this.classAppliers[ parts[3] ];

                    if (!classApplier) {
                        throw new Error("No class applier found for class '" + parts[3] + "'");
                    }

                    highlight = new Highlight(this.doc, characterRange, classApplier, this.converter, parseInt(parts[2]), containerElementId);
                    highlight.apply();
                    highlights.push(highlight);
                }
                this.highlights = highlights;
            }
        };

        api.Highlighter = Highlighter;

        api.createHighlighter = function(doc, rangeCharacterOffsetConverterType) {
            return new Highlighter(doc, rangeCharacterOffsetConverterType);
        };
    });
    
    return rangy;
}, this);
