/**
 * Highlighter module for Rangy, a cross-browser JavaScript range and selection library
 * http://code.google.com/p/rangy/
 *
 * Depends on Rangy core, TextRange and CssClassApplier modules.
 *
 * Copyright 2013, Tim Down
 * Licensed under the MIT license.
 * Version: 1.3alpha.804
 * Build date: 8 December 2013
 */
rangy.createModule("Highlighter", ["ClassApplier"], function(api, module) {
    var dom = api.dom;
    var contains = dom.arrayContains;
    var getBody = dom.getBody;

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

    var forEach = [].forEach ?
        function(arr, func) {
            arr.forEach(func);
        } :
        function(arr, func) {
            for (var i = 0, len = arr.length; i < len; ++i) {
                func( arr[i] );
            }
        };

    var nextHighlightId = 1;

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

    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;
        },

        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));
        },

        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 this.containerElementId ? this.doc.getElementById(this.containerElementId) : getBody(this.doc);
        },
        
        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.cssClass + ", 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.cssClass] = 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, converter = this.converter;
            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, containerElementId) {
            var i, len, j;
            var highlights = this.highlights;
            var converter = this.converter;
            var doc = this.doc;
            var highlightsToRemove = [];
            var classApplier = this.classAppliers[className];
            containerElementId = containerElementId || null;

            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);
                    containerElementRange.detach();
                }
            }

            var charRange, highlightCharRange, merged;
            for (i = 0, len = charRanges.length; i < len; ++i) {
                charRange = charRanges[i];
                merged = false;

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

                // 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) {
                    if (containerElementId == highlights[j].containerElementId) {
                        highlightCharRange = highlights[j].characterRange;

                        if (highlightCharRange.intersects(charRange)) {
                            // Replace the existing highlight in the list of current highlights and add it to the list for
                            // removal
                            highlightsToRemove.push(highlights[j]);
                            highlights[j] = new Highlight(doc, highlightCharRange.union(charRange), classApplier, converter, null, containerElementId);
                        }
                    }
                }

                if (!merged) {
                    highlights.push( new Highlight(doc, charRange, classApplier, converter, null, containerElementId) );
                }
            }
            
            // 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, containerElement) {
            var selCharRanges = [];
            var converter = this.converter;
            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(selCharRanges, ranges, containerElementId);
        },

        highlightSelection: function(className, selection, containerElementId) {
            var converter = this.converter;
            selection = selection || api.getSelection();
            var classApplier = this.classAppliers[className];
            var doc = selection.win.document;
            var containerElement = containerElementId ? doc.getElementById(containerElementId) : getBody(doc);

            if (!classApplier) {
                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);

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

            return newHighlights;
        },

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

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

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

        serializeSelection: function(selection, containerElementId){
            var converter = this.converter;
            selection = selection || api.getSelection();
            var doc = selection.win.document;
            var containerElement = containerElementId ? doc.getElementById(containerElementId) : getBody(doc);
            var rangeInfo = converter.serializeSelection(selection, containerElement)[0];
            return CharacterRange.fromCharacterRange(rangeInfo.characterRange);
        },

        serialize: function(options) {
            var highlights = this.highlights;
            highlights.sort(compareHighlights);
            var serializedHighlights = ["type:" + this.converter.type];

            forEach(highlights, function(highlight) {
                var characterRange = highlight.characterRange;
                var parts = [
                    characterRange.start,
                    characterRange.end,
                    highlight.id,
                    highlight.classApplier.cssClass,
                    highlight.containerElementId
                ];
                if (options && 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;
                containerElement = containerElementId ? this.doc.getElementById(containerElementId) : getBody(this.doc);

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

                classApplier = this.classAppliers[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);
    };
});
