(function($) {
    var Highlighter = function (options) { 
        $.extend(this, options);
        this.terms = this.cleanTerms(this.terms.length ? this.terms : this.getSearchTerms());
    };
    Highlighter.prototype = {
        highlight: function(startnode) {
            // Starting at startnode, highlight the terms in the tree
            if (!this.terms.length || !startnode.length) return;

            var self = this;
            $.each(this.terms, function(i, term) {
                startnode.find('*').andSelf().contents().each(function() {
                    if (this.nodeType == 3) 
                        self.highlightTermInNode(this, term);
                });
            });
        },

        highlightTermInNode: function(node, word) {
            // wrap every occurance of word within node in a span with
            // options className.
            // word is a String, node a DOM TextNode
            var c = node.nodeValue, self = this;
            if ($(node).parent().hasClass(self.highlightClass)) return;

            // Internet Explorer cannot create simple <span> tags without content
            // otherwise it'd be $('<span>').addClass(...).text(content)
            var highlight = function(content) {
                return $('<span class="' + self.highlightClass + '">' + 
                    content + '</span>');
            };

            var ci = self.caseInsensitive;
            var index;
            while (c && (index = (ci ? c.toLowerCase() : c).indexOf(word)) > -1) {
                // replace the node with [before]<span>word</span>[after]
                $(node)
                    .before(document.createTextNode(c.substr(0, index)))
                    .before(highlight(c.substr(index, word.length)))
                    .before(document.createTextNode(c.substr(index+word.length)));
                var next = node.previousSibling; // text after the span
                $(node).remove(); 
                // wash, rinse and repeat
                node = next; c = node.nodeValue;
            }
        },
        
        queryStringValue: function(uri, regexp) {
            // Return the decoded value of the key=value pair in the query string
            // uri is the full URI including qs, regexp is a /key=(.*)/ pattern
            if (uri.indexOf('?') < 0) return '';
            uri = uri.substr(uri.indexOf('?') + 1);
            while (uri.indexOf('=') >= 0) {
                uri = uri.replace(/^\&*/, '');
                var pair = uri.split('&', 1)[0];
                uri = uri.substr(pair.length);
                var match = pair.match(regexp);
                if (match) 
                    return decodeURIComponent(
                        match[match.length-1].replace(/\+/g, ' '));
            }
            return '';
        },
        
        termsFromReferrer: function() {
            // Find search terms from the referrer, if a recognized search engine
            var ref = $.fn.highlightSearchTerms._test_referrer !== null ? 
                $.fn.highlightSearchTerms._test_referrer : 
                document.referrer;
            if (!ref) return '';

            for (var i = 0, se; se = this.referrers[i++];) {
                if (ref.match(se.address)) return this.queryStringValue(ref, se.key);
            }
            return '';
        },
        
        cleanTerms: function(terms) {
            var self = this;
            return $.unique($.map(terms, function(term) {
                term = $.trim(self.caseInsensitive ? term.toLowerCase() : term);
                return (!term || self.filterTerms.test(term)) ? null : term;
            }));
        },
        
        getSearchTerms: function() {
            var terms = [];
            var uri = $.fn.highlightSearchTerms._test_location !== null ? 
                $.fn.highlightSearchTerms._test_location : 
                location.href;
            if (this.useReferrer) 
                $.merge(terms, this.termsFromReferrer().split(/\s+/));
            if (this.useLocation) 
                $.merge(terms, this.queryStringValue(uri, this.searchKey).split(/\s+/));
            return terms;
        }
    };

    var makeSearchKey = function(key) {
        return (typeof key === 'string') ? new RegExp('^' + key + '=(.*)$', 'i') : key;
    };
    var makeAddress = function(addr) {
        return (typeof addr === 'string') ? new RegExp('^https?://(www\\.)?' + addr, 'i') : addr;
    };

    $.fn.highlightSearchTerms = function(options) {
        // Wrap terms in a span with class highlightedSearchTerm. 
        // See defaults for options
        options = $.extend({}, defaults, options);
        options = $.extend(options, {
            searchKey: makeSearchKey(options.searchKey),
            referrers: $.map(options.referrers, function(se) {
                return { 
                    address: makeAddress(se.address), 
                    key: makeSearchKey(se.key)
                };
            })
        });
        if (options.includeOwnDomain) {
            var hostname = $.fn.highlightSearchTerms._test_location !== null ?
                $.fn.highlightSearchTerms._test_location : location.hostname;
            options.referrers.push({
                address: makeAddress(hostname.replace(/\./g, '\\.')),
                key: options.searchKey
            });
        }
        new Highlighter(options).highlight(this);
        
        return this;
    };
    
    // defaults referrers is public for easy copying (for extending the 
    // list) or even inplace alteration if you are so inclined.
    $.fn.highlightSearchTerms.referrers = [ // List based on http://fucoder.com/code/se-hilite/
        { address: 'google\\.',         key: 'q' },         // Google
        { address: 'search\\.yahoo\\.', key: 'p' },         // Yahoo
        { address: 'search\\.msn\\.',   key: 'q' },         // MSN
        { address: 'search\\.live\\.',  key: 'query' },     // MSN
        { address: 'search\\.aol\\.',   key: 'userQuery' }, // AOL
        { address: 'ask\\.com',         key: 'q' },         // AOL
        { address: 'altavista\\.',      key: 'q' },         // AltaVista
        { address: 'feedster\\.',       key: 'q' }          // Feedster
    ];
    
    var defaults = {
        // array of terms to highlight; if empty we'll look up terms from the 
        // location and referrer
        terms: [],

        // Use the current location query string? If so, use searchKey to find
        // what query parameter to use; it's either a string or a regexp, the 
        // former will be turned into a regexp matching /^[searchKey]=(.*)$/i.
        // Note that the last group in a match *must* contain the terms.
        useLocation: true,
        searchKey: '(searchterm|SearchableText)',

        // Use the referrer to detect search engine queries? If so, use
        // referrers to detect these and their search keys. Is an 
        // array of {address, key} entries; key is treated as searchKey
        // above, with address turned into '^https?://(www\.)?[address]'
        // regular expressions, if not already a regexp.
        useReferrer: true,
        referrers: $.fn.highlightSearchTerms.referrers,
        
        // Should the current domain name and searchKey be included in
        // the referrers?
        includeOwnDomain: true,
        
        // Are terms matched case insensitive?
        caseInsensitive: true,
        // what terms are never to be highlighted (regexp)?
        filterTerms: /(not|and|or)/i,
        // What class is used to mark highlighted search terms?
        highlightClass: 'highlightedSearchTerm'
    };
    
    // Internal use only, test framework hooks.
    $.fn.highlightSearchTerms._test_location = null;
    $.fn.highlightSearchTerms._test_referrer = null;
    $.fn.highlightSearchTerms._highlighter = Highlighter;
})(jQuery);
