// Copyright (C) 2008 Intershop Communications AG, all rights reserved.

/**
 * A highlighting class that supports cross-element hightlight spans.
 *
 * Integration is usually done with the attachOnLoad function. For pages that include embedded resources which may
 * take a while to load - like galleries or overloaded web-bug servers - a timing mechanism ist started immediately
 * that waits for the node to be highlighted to appear in the document even before the window is completely loaded.
 * Note that this mechanism is only effective when the node to be highlighted is referenced by its node id. 
 *
 * The getFlow() function and its flow assoc define how special nodes are handled. This should be synchronized
 * with the interpretation of HTML tags that is used during search index generation. Currently, there are two
 * interpretations for special nodes implemented:
 * <ol>
 * <li>"block": the node is treated as block-level element that starts a new line.</li>
 * <li>"space": text of the node and all child nodes is ignored, and replaced by a single space.</li>
 * <li>"skip": the node and all child nodes are ignored entirely, and replaced by an empty string.</li>
 * </ol>
 *
 * Property names in the flow assoc must be upper-case; there is no case-conversion during tree traversal for
 * performance reasons. It will be ignored if the configuration specifies an alternative function for getFlow().
 *
 * @param config
 *          An object specifying the following options:<ul>
 *          <li>node: string or DOM node to parse, defaults to document.body</li>
 *          <li>tagName: tag name to wrap highlighted text, defaults to "em"</li>
 *          <li>className: CSS class name to use, defaults to "highlight"</li>
 *          <li>getFlow: function to classify nodes, defaults to getFlow</li>
 *          <li>flow: object with flow properties for tag names</li>
 *          </ul>
 */
function HighlightHTML(config) {
    this.config = config;
    this.flow = this.getProperty(config, "flow", this.flow);
    this.getFlow = this.getProperty(config, "getFlow", this.getFlow);
}

/**
 * Attach a handler to the onload event on <var>win</var> that will highlight the terms in <var>query</var>.
 *
 * @param win
 *         Window where to attach to the onload event.
 * @param query
 *         An array of query terms, or a string used as regular expression to highlight. 
 */
HighlightHTML.prototype.attachOnLoad = function(win, query) {
    var t = this;
    var handler = function() {
        var s = arguments.callee;
        if (s.trigger) {
            win.clearTimeout(s.trigger);
            var node = t.getProperty(t.config, "node", document.body);
            if (typeof node !== "string") {
                // Not referenced by id, must wait for completion.
                return;
            } else {
                node = document.getElementById(node);
            }
            if (node) {
                s.trigger = null;
                t.highlight(node,  query);
                return;
            }
            // Not yet loaded, wait for it.
            if (s.delay > 2000) {
                // Give up
                return;
            } else if (s.delay > 0) {
                s.delay = s.delay * 2;
            } else {
                s.delay = 100;
            }
            s.trigger = win.setTimeout(s, s.delay);
        }
    };
    
    handler.trigger = win.setTimeout(handler, 50);

    if (win.addEventListener) {
        win.addEventListener('load', handler, false);
    } else if (win.attachEvent) {
        win.attachEvent('onload', handler);
    } else {
        var prior = win.onload;
        win.onload = function() {
            handler();
            if (prior) {
                prior();
            }
        };
    }
};

HighlightHTML.prototype.createTreeText = function(node, tree, text, size, next) {
    var length = 0;
    var flow = this.getFlow(node);
    if (flow == "skip") {
        return 0;
    } else if (flow == "space") {
        length = 1;
        next.push(1);
        tree.push(null);
        text.push(" ");
        size.push(length);
    } else if (node.nodeType == 3) {
        next.push(1);
        tree.push(node);
        if (node.data) {
            text.push(node.data);
            length += node.data.length;
        }
        size.push(length);
    } else if (node.nodeType == 1) {
        var index = next.length;
        next.push(1);
        tree.push(node);
        size.push(0);
        if (flow == "block") {
            // Insert synthetic spacer for block element start.
            length += 1;
            next.push(1);
            tree.push(null);
            text.push("\n");
            size.push(1);
        }
        for (var i = node.firstChild; i; i = i.nextSibling) {
            length += arguments.callee.call(this, i, tree, text, size, next);
        }
        next[index] = node.nextSibling ? next.length - index: 0;
        size[index] = length;
    } else {
        // Unknown node type, ignore.
        return 0;
    }
    return length;
};

HighlightHTML.prototype.flow = {
    ADDRESS: "block",
    BLOCKQUOTE: "block",
    BR: "space",
    CENTER: "block",
    DIR: "block",
    DIR: "block",
    DD: "block",
    DL: "block",
    DT: "block",
    FIELDSET: "block",
    FORM: "block",
    H1: "block",
    H2: "block",
    H3: "block",
    H4: "block",
    H5: "block",
    H6: "block",
    HR: "space",
    ISINDEX: "block",
    LI: "block",
    MENU: "block",
    NOFRAMES: "block",
    NOSCRIPT: "block",
    OBJECT: "skip",
    OL: "block",
    P: "block",
    PRE: "block",
    SCRIPT: "skip",
    STYLE: "skip",
    TABLE: "block",
    TBODY: "block",
    TD: "block",
    TEXTAREA: "skip",
    TFOOT: "block",
    TH: "block",
    THEAD: "block",
    TR: "block"
};

/**
 * Classify nodes by their tag name. The default implementation just looks up the tag name in this.flow.
 *
 * @return "skip" for nodes that should be ignored entirely when parsing the DOM.
 */
HighlightHTML.prototype.getFlow = function(node) {
    if (node) {
        return this.flow[node.tagName];
    }
};

HighlightHTML.prototype.getProperty = function(scope, name, fallback) {
    return scope && scope[name] !== undefined ? scope[name] : fallback;
};

HighlightHTML.prototype.highlight = function(node, query) {
    if (typeof node === "string") {
        node = document.getElementById(node);
    }
    if (!node || !query) {
        return;
    }
    if (typeof query !== "string") {
        var buffer = [];
        for (var i in query) {
            var value = query[i];
            if (value) {
                // Quote meta-characters.
                value = String(value).replace(/[\\\/{}()\[\].*?]/, "\\$&");
                // Make space match any white space.
                value = value.replace(/ +/, "\\s+");
                buffer.push(value);
            }
        }
        query = buffer.join("|");
    }
    var re = new RegExp(query, "gim");
    var tree = [];
    var text = [];
    var size = [];
    var next = [];
    this.createTreeText(node, tree, text, size, next);
    text = text.join("");
    var mark;
    while ((mark = re.exec(text)) != null) {
        this.wrapText(0, mark.index, mark[0].length, tree, size, next);
    }
};

HighlightHTML.prototype.wrapText = function(index, off, len, tree, size, next) {
    while (off + len > 0 && index < next.length) {
        if (size[index] > off) {
            var node = tree[index];
            if (!node) {
                // This node was already split up all the way to the end.
            } else if (node.nodeType == 3) {
                var length = node.data.length
                // Adjust for length of already split nodes.
                var cut = size[index] - length;
                var start = off - cut;
                var end = off + len - cut;
                if (start < 0) {
                    start = 0;
                }
                if (end > length) {
                    end = length;
                }
                if (end > start) {
                    var chunk = node.splitText(start);
                    if (end < length) {
                        tree[index] = chunk.splitText(end - start);
                    } else {
                        tree[index] = null;
                    }
                    var wrapper = chunk.ownerDocument.createElement(this.getProperty(this.config, "tagName", "em"));
                    wrapper.className = this.getProperty(this.config, "className", "highlight");
                    chunk.parentNode.replaceChild(wrapper, chunk);
                    wrapper.appendChild(chunk);
                }
            } else if (node.nodeType == 1 && next[index] != 1) {
                this.wrapText(index + 1, off, len, tree, size, next);
            }
        }
        if (next[index]) {
            off -= size[index];
            index += next[index];
        } else {
            break;
        }
    }
};
