/*
 * Copyright (c) Sulake Dynamoid Oy
 * All Rights Reserved
 *
 * $Id: suggestinput.js,v 1.44 2010/07/06 12:51:22 jukkapekkak Exp $
 */

/**
 * Context for suggest search, ie. users, communities.
 *
 * @author Bro
 * @package SuggestSearch
 */
var SuggestContext = Class.create({
	/**
	 * Previous searches for this context cached.
	 *
	 * @var Hash<String, Object>
	 */
	cache: null,

	/**
	 * Css class used with results from this context.
	 */
	className: null,

	/**
	 * Custom data, fetched automatically from customSource.
	 *
	 * @var Array
	 */
	customData: null,

	/**
	 * Template for creating urls without a result from the input box.
	 * The template will be given only one parameter named 'input'.
	 *
	 * @var Template
	 * @example /user/#{input}
	 */
	defaultUrlTemplate: null,

	/**
	 * Context id
	 *
	 * @var String
	 * @example fi-users
	 */
	id: null,

	/**
	 * Searched fields
	 *
	 * @var Array
	 */
	searchFields: null,

	/**
	 * Template for creating urls for the results from this context.
	 * The template will be given an object containing an object named result.
	 *
	 * @var Template
	 * @example /user/#{result.nick}
	 */
	urlTemplate: null,

	/**
	 * Template for creating the html content for the results from this context.
	 * The template will be given an object containing objects named result and hilight,
	 * which is the result highlighted with the search tokens.
	 *
	 * @var Template
	 * @example <span>#{hilight.nick}</span>
	 */
	viewTemplate: null,

	/**
	 * Create a new context. External code should use the static registerContext().
	 *
	 * @param contextId  id of the context
	 * @param options    context options, possible options include className, urlTemplate and viewTemplate
	 * @see SuggestContext.registerContext() for registering unique contexts
	 */
	initialize: function(contextId, options) {
		this.id = contextId;
		this.cache = new Hash();

		this.className = options.className;
		if (options.defaultUrlTemplate) {
			this.defaultUrlTemplate = new IG.Template(options.defaultUrlTemplate);
		}
		if (options.urlTemplate) {
			this.urlTemplate = new Template(options.urlTemplate);
		}
		if (options.viewTemplate) {
			this.viewTemplate = new IG.Template(options.viewTemplate);
		}

		this.globalContext = options.globalContext;
		this.searchFields = options.searchFields;
	},

	/**
	 * Check cache for a search result using given search needle.
	 *
	 * @param String needle  search needle
	 * @return Object  previous search result or null if not found
	 */
	getCache: function(needle) {
		return this.cache.get(needle.toLowerCase());
	},

	isLetterOrDigit: function(c) {
		//var reLetterOrDigit = /^([a-zA-Z]|\d)$/
		var reLetterOrDigit = /^(\w|[äöåÄÖÅ]|\d)$/
		return reLetterOrDigit.test(c)
	},

	/**
	 * Sanitizes ie. removes non-letter and non-digit characters.
	 */
	sanitize: function(s) {
		return s;
		/*
		if (s == "") return "";

		var b = "";
		s.toArray().each(function(c) {
			if (this.isLetterOrDigit(c)) {
				b += c;
			}
		}, this);

		return b;
		*/
	},

	/**
	 * Search custom data for given search needle.
	 *
	 * @param String needle  search needle
	 * return Array  matching results
	 */
	searchCustomData: function(needleString) {
		if (!this.customData) {
			// No custom data yet, make sure it's being fetched
			SuggestContext.getCustomDatas();
			return null;
		}

		// Split the string into multiple needles and execute search for each of them
		var needles = this.sanitize(needleString.toUpperCase()).split(" ");

		// Do the actual search
		var matches = this.customData.select(
			function(data) {
				return this.searchFields.any(
					function(field) {
						var dataField = data[field];
						if (!dataField) {
							return false;
						}

						var sanitizedField =  this.sanitize(dataField).toUpperCase();
						if (!sanitizedField) {
							return false;
						}
						return needles.any(function(needle) {
							// Split the search term. This is needed because name contains both first- and lastnames and we also need to be able to find by lastname
							return sanitizedField.split(" ").any(
								function(field) {
									if (field.indexOf(needle) == 0) {
										return true;
									}
									return false;
								}.bind(this)
							);

						}.bind(this));

					}.bind(this)
				);
			}.bind(this)
		);

		return matches;
	},

	/**
	 * Set custom data.
	 *
	 * @param Array customData
	 */
	setCustomData: function(customData) {
		this.customData = customData;
	},

	/**
	 * Set a search result for a needle in the cache.
	 *
	 * @param String needle  search needle
	 * @param Object data    search result to be cached
	 */
	setCache: function(needle, data) {
		this.cache.set(needle.toLowerCase(), data);
	}
});

/*
 * Static members for SuggestContext
 */
Object.extend(SuggestContext, {
	/**
	 * Registered contexts.
	 *
	 * @var Hash<String, SuggestContext> context id as the key
	 */
	contexts: new Hash(),

	/**
	 * Indicates if custom datas are already loading.
	 *
	 * @var boolean
	 */
	customDatasLoading: false,

	/**
	 * Indicates which custom datas are to be loaded.
	 *
	 * @var Array
	 */
	customDatasMissing: new Hash(),

	/**
	 * Get a registered context.
	 *
	 * @param String contextId  id of the requested context
	 * @return SuggestContext registered context or null if not found
	 */
	getContext: function(contextId) {
		return this.contexts.get(contextId);
	},

	/**
	 * Get custom datas for contexts.
	 */
	getCustomDatas: function() {
		if (this.customDatasLoading || this.customDatasMissing.size() == 0 || !SuggestSearch.customDataSource) {
			// Nothing to load
			return;
		}

		this.customDatasLoading = true;

		new Ajax.Request(
			SuggestSearch.customDataSource,
			{
				onFailure: this.onCustomDatasFailure.bind(this),
				onSuccess: this.onCustomDatasSuccess.bind(this),
				parameters: {
					action: 'get_suggest_search_custom_data',
					context: this.customDatasMissing.values().pluck('globalContext').join(';')
				}
			}
		);
	},

	/**
	 * Event listener for onFailure events from getCustomDatas().
	 */
	onCustomDatasFailure: function() {
		// Just set loading to false to try again
		this.customDatasLoading = false;
	},

	/**
	 * Event listener for onSuccess events from getCustomDatas().
	 *
	 * @param Ajax.Response response
	 */
	onCustomDatasSuccess: function(response) {
		if (typeof response != 'object' || typeof response.responseJSON != 'object') {
			this.customDatasLoading = false;
			return;
		}

		$H(response.responseJSON).each(
			function(pair) {
				var context = this.customDatasMissing.get(pair.key);

				if (context) {
					context.setCustomData(pair.value);
					this.customDatasMissing.unset(pair.key);
				}
			}.bind(this)
		);

		this.customDatasLoading = false;
	},

	/**
	 * Register a context. If a context with the same id has been registered before, it will be overridden.
	 *
	 * @param String contextId  id of the registered context
	 * @param Object options    context options, possible options include className, urlTemplate and viewTemplate
	 */
	registerContext: function(contextId, options) {
		var context = new SuggestContext(contextId, options);
		this.contexts.set(contextId, context);
		this.customDatasMissing.set(context.globalContext, context);
	}
});

/**
 * Text input wrapper for suggest search.
 *
 * @author Bro
 * @package SuggestSearch
 * @todo Implement actions - navigate is the default action right now.
 */
var SuggestInput = Class.create({
	/**
	 * Action to be taken when a result is selected.
	 *
	 * @var String
	 */
	action: null,

	/**
	 * Contexts searched with this input.
	 *
	 * @var Hash<String, SuggestContext> context id as the key
	 */
	contexts: null,

	/**
	 * The actual text input html element.
	 *
	 * @var Element
	 */
	element: null,

	/**
	 * Previous value of the text input.
	 *
	 * @var String
	 */
	lastValue: null,

	/**
	 * Resource handle id for a timer object used to do a delayed search after user input.
	 *
	 * @var int
	 */
	timer: null,

	/**
	 * Create a new suggest input wrapper. External code should use static registerInput().
	 *
	 * @param id       id of the text input element
	 * @param options  input options, possible options include action
	 * @see SuggestInput.registerInput()
	 * @throws Exception if a text input is not found with the given id
	 */
	initialize: function(id, options) {
		this.element = $(id);

		if (!this.element || this.element.getAttribute('type') != 'text') {
			throw('SuggestInput element not found with id: ' + id);
		}

		this.action = options.action;

		this.contexts = new Hash();
		$A(options.contexts).each(
			function(contextId) {
				this.contexts.set(contextId, SuggestContext.getContext(contextId));
			}.bind(this)
		);

		this.element.observe('blur', this.onBlur.bindAsEventListener(this));
		this.element.observe('focus', this.onFocus.bindAsEventListener(this));
		this.element.observe('keydown', this.onKeyDown.bindAsEventListener(this));
		this.element.observe('keyup', this.onKeyUp.bindAsEventListener(this));
	},

	/**
	 * Event handler for blur events of the text input element.
	 *
	 * @param Event event  event object
	 */
	onBlur: function(event) {
		// Hide the results if not mousing over them
		if (!SuggestSearch.mouseOver) {
			SuggestSearch.resultsBox.hide();
		}
	},

	/**
	 * Event handler for focus events of the text input element.
	 *
	 * @param Event event  event object
	 */
	onFocus: function(event) {
		// Fetch context custom sources
		SuggestContext.getCustomDatas();

		// Show results
		SuggestSearch.showResultsBox(this);
	},

	/**
	 * Event handler for keyDown events of the text input element.
	 *
	 * @param KeyEvent event  event object
	 */
	onKeyDown: function(event) {
		var keyCode = event.charCode || event.keyCode;

		switch (keyCode) {
			case Event.KEY_RETURN:
				// Navigate to selected on return key
				if (SuggestResultMatrix.selected) {
					document.location = SuggestResultMatrix.selected.getUrl();
				} else {
					// If no selected, use default context
					var value = $F(this.element);
					document.location = this.contexts.values().first().defaultUrlTemplate.evaluate(SuggestResult.getEscapedData({ input: value }));
				}

				Event.stop(event);

				break;

			// Navigate using SuggestResultMatrix on arrow keys
			case Event.KEY_DOWN:
				SuggestResultMatrix.down();
				Event.stop(event);
				break;

			case Event.KEY_UP:
				SuggestResultMatrix.up();
				Event.stop(event);
				break;

			case Event.KEY_LEFT:
				SuggestResultMatrix.left();
				break;

			case Event.KEY_RIGHT:
				SuggestResultMatrix.right();
				break;
		}
	},

	/**
	 * Event handler for keyUp events of the text input element.
	 *
	 * @param KeyEvent event  event object
	 */
	onKeyUp: function(event) {
		var keyCode = event.charCode || event.keyCode;

		switch (keyCode) {
			case Event.KEY_ESC:
				// Hide the results on esc key
				SuggestSearch.resultsBox.hide();
				Event.stop(event);
				return;
				break;
		}

		// Make sure the box is showing
		SuggestSearch.showResultsBox(this);

		var value = $F(this.element);

		// Compare previous value to current
		if (this.lastValue && this.lastValue == value) {
			// Nothing changed
			return;
		} else {
			this.lastValue = value;
		}

		// Clear previous timer
		if (this.timer) {
			clearTimeout(this.timer);
			this.timer = null;
		}

		if (!value) {
			// Empty input
			return;
		}

		// Wait for extra input and do delayed search using a timer
		this.timer = setTimeout(this.search.bind(this), SuggestInput.timerDelay);
	},

	/**
	 * Event handler for ajax query onException events.
	 *
	 * @param Ajax.Response response
	 * @param Exception ex
	 */
	onQueryException: function(response, ex) {
		SuggestSearch.debug(ex);
		SuggestSearch.showNotice('error');
	},

	/**
	 * Event handler for ajax query onFailure events.
	 *
	 * @param Ajax.Response response
	 */
	onQueryFailure: function(response) {
		SuggestSearch.showNotice('error');
	},

	/**
	 * Event handler for the search ajax query onSuccess events.
	 *
	 * @param Ajax.Response response
	 */
	onQuerySuccess: function(response) {
		var json = $H(response.responseJSON);
		var needle = null;

		// Cache results
		json.each(
			function(contextPair) {
				var context = SuggestContext.getContext(contextPair.key);
				var contextJson = contextPair.value;

				SuggestSearch.debug('SuggestInput.onQuerySuccess: ' + contextPair.key + ' - ' + contextJson.needle);

				if (contextJson.status == 'OK') {
					needle = contextJson.needle;

					var customResult = context.searchCustomData(needle);

					if (customResult) {
						// Found custom results
						if (contextJson.results) {
							// Get custom results' ids
							var customResultIds = customResult.pluck('id');

							// Remove duplicates from query result
							var queryResults = contextJson.results.select(
								function(result) {
									for (var i = 0 ; i < customResultIds.length ; ++i) {
										if (result.id == customResultIds[i]) {
											return false;
										}
									}

									return true;
								}
							);

							// Combine
							contextJson.results = customResult.concat(queryResults);
						} else {
							// Only custom results
							contextJson.results = customResult;
						}
					}

					context.setCache(needle, contextJson);
				}
			}
		);

		// Print if still valid
		var lovercaseNeedle = needle ? needle.strip().toLowerCase() : null;
		var lovercaseInput = $F(this.element).strip().toLowerCase();
		if (lovercaseNeedle == lovercaseInput) {
			this.printResults($H(response.responseJSON));
		} else {
			SuggestSearch.debug('SuggestInput.onQuerySuccess: Got results that are no longed valid, not printing! Needle: ' + lovercaseNeedle + ' vs. input text: ' + lovercaseInput);
		}
	},

	/**
	 * Event handler for the result elements click events.
	 *
	 * @param Event event           event object
	 * @param SuggestResult result  result object for the clicked element
	 */
	onResultClick: function(event, result) {
		document.location = result.getUrl();
	},

	/**
	 * Event handler for the result elements mouseOver events.
	 *
	 * @param Event event           event object
	 * @param SuggestResult result  result object for the element
	 */
	onResultMouseOver: function(event, result) {
		SuggestResultMatrix.select(result);
	},

	/**
	 * Print the results for a context to result container using an ul html element.
	 *
	 * @param SuggestContext context  context for the results
	 * @param Object contextJson      json result object
	 * @param Array columnClasses     extra css classes for the context column (ul element)
	 * @param int column              index of the printed context column in the resultset
	 */
	printContextResult: function(context, contextJson, columnClasses, column) {
		// Get tokens
		var tokens = $A(contextJson.tokens);

		// Create column
		var td = new Element('td');
		SuggestSearch.resultsContainer.insert(td);

		var ul = new Element('ul', {
			className: context.className
		});
		td.insert(ul);

		for (var i = 0 ; i < columnClasses.length ; ++i) {
			ul.addClassName(columnClasses[i]);
		}

		// Loop through results filling the column
		var results = SuggestResult.getResults(context, contextJson);
		var lastIndex = Math.min(results.length - 1, SuggestSearch.resultRowLimit - 1);

		for (var i = 0 ; i <= lastIndex ; ++i) {
			var result = results[i];
			var element = result.createElement(tokens);

			// Add to matrix
			SuggestResultMatrix.add(result, column);

			if (i == 0) {
				element.addClassName('first');
			}

			if (i == lastIndex) {
				element.addClassName('last');
			}

			element.observe('click', this.onResultClick.bindAsEventListener(this, result));
			element.observe('mouseover', this.onResultMouseOver.bindAsEventListener(this, result));

			ul.insert(element);
		}
	},

	/**
	 * Print the search results.
	 *
	 * @param Hash contextResults  the json result as it's received from the search server
	 * @todo Handle error situations (contextJson.status != 'OK')
	 */
	printResults: function(contextResults) {
		// Clean up previous
		SuggestSearch.clearResults();
		SuggestSearch.hideNotices();
		SuggestResultMatrix.clear();

		// Remove empty contexts
		contextResults = contextResults.select(
			function(pair) {
				if (pair.value.hits > 0) {
					return true;
				} else {
					return false
				}
			}
		);

		if (contextResults.size() > 0) {
			// Loop through contexts printing them
			for (var i = 0 ; i < contextResults.length ; ++i) {
				var context = SuggestContext.getContext(contextResults[i][0]);
				var contextJson = contextResults[i][1];

				var columnClasses = new Array();

				if (i == 0) {
					columnClasses.push('first');
				}

				if (i == contextResults.length - 1) {
					columnClasses.push('last');
				}

				this.printContextResult(context, contextJson, columnClasses, i);
			}

			SuggestResultMatrix.down();
		} else {
			SuggestSearch.showNotice('noresults');
		}

		SuggestSearch.showResultsBox(this);
	},

	/**
	 * Do search for this input.
	 */
	search: function() {
		// Get the search needle
		var needle = $F(this.element);

		if (!needle) {
			return;
		}

		// Search cache first
		if (!this.searchCache()) {
			// If not in cache, do a query
			this.searchQuery();
		}
	},

	/**
	 * Do cache search for this input and print the results if everything was found.
	 *
	 * @return boolean true if the search succeeded, false if something was missing and nothing was printed
	 */
	searchCache: function() {
		// Get the search needle
		var needle = $F(this.element);

		// Cached results are gathered in this hash
		var cacheJsons = new Hash();

		// Get the data from context caches
		var contextIds = this.contexts.keys();
		var contextDatas = this.contexts.values();

		for (var i = 0 ; i < contextIds.length ; ++i) {
			var context = contextDatas[i];
			var cacheJson = context.getCache(needle);

			if (!cacheJson) {
				// Not found - exit
				return false;
			}

			cacheJsons.set(contextIds[i], cacheJson);
		}

		// All found - print
		this.printResults(cacheJsons);

		return true;
	},

	/**
	 * Do search query for this input.
	 *
	 * @see setLoading() for loading indicator updating
	 * @see onQueryException() for the exception handler
	 * @see onQueryFailure() for the failure handler
	 * @see onQuerySuccess() for the response handler
	 */
	searchQuery: function() {
		SuggestSearch.debug('SuggestInput.searchQuery: ' + $F(this.element));

		new Ajax.Request(
			SuggestSearch.url,
			{
				method: 'get',
				parameters: {
					// Contexts are separated with a semicolon and one extra semicolon is needed at the end
					context: this.contexts.keys().join(';') + ';',
					needle: $F(this.element)
				},
				onComplete: this.setLoading.bind(this, false),
				onException: this.onQueryException.bind(this),
				onFailure: this.onQueryFailure.bind(this),
				onCreate: this.setLoading.bind(this, true),
				onSuccess: this.onQuerySuccess.bind(this)
			}
		);
	},

	/**
	 * Update ajax loader indicator.
	 *
	 * @param boolean loading  true if the indicator should indicate that the ajax request is in progress
	 * @todo Figure out what to do and implement
	 */
	setLoading: function(loading) {
		var className = 'suggest-input-loading';

		if (loading) {
			SuggestSearch.showNotice('loading', this);
			this.element.addClassName(className);
		} else {
			this.element.removeClassName(className);
		}
	}
});

/*
 * Static members for SuggestInput.
 */
Object.extend(SuggestInput, {
	/**
	 * Registered suggest inputs.
	 *
	 * @var Hash<String, SuggestInput> element id as key
	 */
	inputs: new Hash(),

	/**
	 * Delay in milliseconds for the delayed search after user input.
	 *
	 * @var int in milliseconds
	 */
	timerDelay: 300,

	/**
	 * Register an text input.
	 * @param inputId  id of the text input element
	 * @param options  input options, possible options include action
	 * @throws Exception if a text input is not found with the given id
	 */
	registerInput: function(inputId, options) {
		this.inputs.set(inputId, new SuggestInput(inputId, options));
	}
});

/**
 * Result wrapper for suggest search results.
 *
 * @author Bro
 * @package SuggestSearch
 */
var SuggestResult = Class.create({
	/**
	 * Context this result was found in.
	 *
	 * @var SuggestContext
	 */
	context: null,

	/**
	 * The (latest) html li element that was created from this result.
	 *
	 * @var Element
	 */
	element: null,

	/**
	 * Unique id for this result inside a context.
	 *
	 * @var Object usually integer like user id or channel id
	 */
	id: null,

	/**
	 * Json result data.
	 *
	 * @var Object
	 */
	resultData: null,

	/**
	 * Create a new suggest search result. External code should use static getResult()
	 *
	 * @param SuggestContext context  context this result was found in.
	 * @param Object resultData       json result data
	 * @see SuggestResult.getResult()
	 */
	initialize: function(context, resultData) {
		this.context = context;
		this.resultData = resultData;
		this.id = resultData.id;
	},

	/**
	 * Create a new li element for this result and highlight with the given tokens.
	 *
	 * @param Array<String> tokens  tokens to be used to highlight the result data
	 * @return Element a new li element
	 * @see element for the latest created element
	 */
	createElement: function(tokens) {
		// Create element
		this.element = new Element('li', {
			className: 'suggest-result suggest-result-' + this.context.className + (this.resultData.classes ? ' ' + this.resultData.classes : '')
		});

		// Create a wrapper inside the element
		wrapper = new Element('div', {
			className: 'suggest-result-wrapper'
		});
		this.element.update(wrapper);

		// Create the contents using view template from the context
		var templateData = {
			result: this.resultData,
			hilight: SuggestResult.getHilightData(this.resultData, tokens)
		};

		wrapper.update(this.context.viewTemplate.evaluate(templateData));

		return this.element;
	},

	/**
	 * Get the url for this result using the url template from the context.
	 *
	 * @return String
	 */
	getUrl: function() {
		return this.context.urlTemplate.evaluate({ result: SuggestResult.getEscapedData(this.resultData) });
	}
});

/*
 * Static members for SuggestResult.
 */
Object.extend(SuggestResult, {
	/**
	 * All created results grouped by contexts
	 *
	 * @var Hash<String, Hash<Object, SuggestResult>> context id and result id as keys
	 */
	results: new Hash(),

	/**
	 * Url escape the given data and store it to the given data store.
	 *
	 * @param Hash<String, Object> escapedData  the data store where the escaped data should be stored to
	 * @param Object pair                       a data pair from Hash.each()
	 */
	escape: function(escapedData, pair) {
		if (Object.isString(pair.value)) {
			escapedData.set(pair.key, encodeURIComponent(pair.value));
		}
	},

	/**
	 * Get url escaped data for a result dataset.
	 *
	 * @param Hash<String, Object> resultData  result data to be escaped, contents are not changed by the function
	 * @return Hash<String, Object> the result data with all strings url escaped
	 */
	getEscapedData: function(resultData) {
		var escapedData = $H(resultData);
		escapedData.each(this.escape.bind(this, escapedData));
		return escapedData.toObject();
	},

	/**
	 * Get highlighted data for a result dataset.
	 *
	 * @param Hash<String, Object> resultData  result data to be highlighted, contents are not changed by the function
	 * @param Array<String> tokens             the tokens to be used to highlight
	 * @return Hash<String, Object> the result with all strings hilighted
	 */
	getHilightData: function(resultData, tokens) {
		var hilightData = $H(resultData);
		hilightData.each(this.hilight.bind(this, hilightData, tokens));
		return hilightData.toObject();
	},

	/**
	 * Get a result object for the given context using the given result data.
	 * If the same result has been created earlier, it will be returned, otherwise a new result object is created.
	 *
	 * @param SuggestContext context  context for the result
	 * @param Object resultData       json result data
	 * @return SuggestResult
	 * @see getResults() for parsing a whole set of results at once
	 */
	getResult: function(context, resultData) {
		var contextResults = this.results.get(context.id);

		if (!contextResults) {
			// No results for this context - create the results
			contextResults = new Hash();
			this.results.set(context.id, contextResults);
		}

		// Check if the result has already been created
		var result = contextResults.get(resultData.id);

		if (!result) {
			// Create it
			result = new SuggestResult(context, resultData);
			contextResults.set(resultData.id, result);
		}

		return result;
	},

	/**
	 * Parse results json and return all results from it.
	 *
	 * @param SuggestContext context  context for the results
	 * @param Object contextJson      json results data
	 * @return Array<SuggestResult>
	 */
	getResults: function(context, contextJson) {
		var resultDatas = $A(contextJson.results);
		var results = new Array();

		for (var i = 0 ; i < resultDatas.length ; ++i) {
			results[i] = this.getResult(context, resultDatas[i]);
		}

		return results;
	},

	/**
	 * Highlight the given data and store it to the given data store.
	 *
	 * @param Hash<String, Object> hilightData  the data store where the highlighted data should be stored to
	 * @param Array<String> tokens              the tokens to be used for highlighting
	 * @param Object pair                       a data pair from Hash.each()
	 */
	hilight: function(hilightData, tokens, pair) {
		if (Object.isString(pair.value)) {
			hilightData.set(pair.key, pair.value.replace(new RegExp('(' + tokens.invoke('escapeRegex').join('|') + ')', 'gi'), '<em>$1</em>'));
		}
	}
});

/**
 * Library class for handling the arrow key navigation for a suggest result matrix.
 *
 * @author Bro
 */
var SuggestResultMatrix = Class.create();

/*
 * Static members for SuggestResultMatrix.
 */
Object.extend(SuggestResultMatrix, {
	/**
	 * The result matrix as 2-dimensional array.
	 *
	 * @var Array<Array<SuggestResult>>
	 */
	matrix: new Array(),

	/**
	 * Currently selected result.
	 *
	 * @var SuggestResult
	 */
	selected: null,

	/**
	 * The x coordinate for the currently selected result.
	 *
	 * @var int
	 */
	x: -1,

	/**
	 * The y coordinate for the currently selected result.
	 *
	 * @var int
	 */
	y: -1,

	/**
	 * Add a result to the given column of the matrix.
	 *
	 * @param SuggestResult result  the result to be added
	 * @param int x                 the index of the column, which will be created automatically when needed
	 */
	add: function(result, x) {
		if (this.matrix.length <= x) {
			for (var i = this.matrix.length ; i <= x ; ++i) {
				this.matrix[i] = new Array();
			}
		}

		this.matrix[x].push(result);
	},

	/**
	 * Clear the matrix and reset it's state.
	 */
	clear: function() {
		this.matrix = new Array();
		this.x = -1;
		this.y = -1;
		this.selected = null;
	},

	/**
	 * Move down in the matrix, if possible.
	 */
	down: function() {
		if (!this.selected) {
			// Select first if not in the matrix yet
			var i = 0;

			while (i < this.matrix.length && this.matrix[i].length == 0) {
				i++
			}

			this.selectXY(i, 0);
		} else if (this.y < this.matrix[this.x].length - 1) {
			this.selectXY(this.x, this.y + 1);
		}
	},

	/**
	 * Move left in the matrix, if possible.
	 */
	left: function() {
		if (this.selected) {
			if (this.x > 0) {
				if (this.y < this.matrix[this.x - 1].length - 1) {
					// Ok to move directly left
					this.selectXY(this.x - 1, this.y);
				} else {
					// Need to jump up at the same
					if (this.matrix[this.x - 1].length > 0) {
						this.selectXY(this.x - 1, this.matrix[this.x - 1].length - 1);
					}
				}
			}
		}
	},

	/**
	 * Move right in the matrix, if possible.
	 */
	right: function() {
		if (this.selected) {
			if (this.x < this.matrix.length - 1) {
				if (this.y < this.matrix[this.x + 1].length - 1) {
					// Ok to move directly right
					this.selectXY(this.x + 1, this.y);
				} else {
					// Need to jump up at the same
					if (this.matrix[this.x + 1].length > 0) {
						this.selectXY(this.x + 1, this.matrix[this.x + 1].length - 1);
					}
				}
			}
		}
	},

	/**
	 * Select the given result in the matrix.
	 *
	 * @param SuggestResult  the result to be selected
	 */
	select: function(result) {
		if (this.selected) {
			if (result.id == this.selected.id) {
				return;
			}
		}

		for (var x = 0 ; x < this.matrix.length ; ++x) {
			for (var y = 0 ; y < this.matrix[x].length ; ++y) {
				if (this.matrix[x][y].id == result.id) {
					this.selectXY(x, y);
					return;
				}
			}
		}
	},

	/**
	 * Select the given coordinates in the matrix.
	 *
	 * @param x  int x coordinate, column index
	 * @param y  int y coordinate, row index
	 */
	selectXY: function(x, y) {
		if (x >= this.matrix.length || y >= this.matrix[x].length) {
			throw('Unable to select [' + x + ', ' + y +'] from matrix');
		}

		if (this.x == x && this.y == y) {
			// Nothing needs to be done
			return;
		}

		if (this.selected) {
			this.selected.element.removeClassName('selected');
		}

		this.matrix[x][y].element.addClassName('selected');
		this.selected = this.matrix[x][y];
		this.x = x;
		this.y = y;
	},

	/**
	 * Move up in the matrix, if possible.
	 */
	up: function() {
		if (this.selected) {
			if (this.y > 0) {
				this.selectXY(this.x, this.y - 1);
			}
		}
	}
});

/**
 * Library class for generic suggest search functions.
 *
 * @author Bro
 */
var SuggestSearch = Class.create({});

/*
 * Static members for SuggestSearch
 */
Object.extend(SuggestSearch, {
	/**
	 * URL for custom data.
	 *
	 * @var String
	 */
	customDataSource: null,

	/**
	 * Boolean indicating if the mouse is currently over the result area.
	 *
	 * @var boolean
	 */
	mouseOver: false,

	/**
	 * Height of the result rows. This is used to calculate how many rows will fit on the screen.
	 *
	 * @var int
	 */
	resultRowHeight: 30,

	/**
	 * Limit for result rows.
	 *
	 * @var int
	 */
	resultRowLimit: 8,

	/**
	 * Box where the results are displayed
	 *
	 * @var Element
	 */
	resultsBox: null,

	/**
	 * Actual container for the search results inside the results box
	 *
	 * @var Element
	 */
	resultsContainer: null,

	/**
	 * Url for Ajax requests
	 *
	 * @var String
	 */
	url: null,

	/**
	 * Clear the results.
	 */
	clearResults: function() {
		this.resultsContainer.update('');
	},

	debug: function(message) {
		if (typeof console == 'object' && typeof console.log != 'undefined') {
			console.log('SuggestInput:', message);
		}
	},

	/**
	 * Hide all notices.
	 */
	hideNotices: function() {
		this.resultsBox.select('.suggest-search-notice').invoke('hide');
	},

	init: function(event) {
		if (!event.memo || !event.memo.url || !event.memo.resultsBox || !event.memo.resultsContainer) {
			throw('SuggestSearch.init: Invalid event.')
		}

		this.customDataSource = event.memo.customDataSource;
		this.setResultsBox(event.memo.resultsBox, event.memo.resultsContainer);
		this.url = event.memo.url;
	},

	/**
	 * Event handler for the result box mouseOver events.
	 *
	 * @see mouseOver which is updated accordingly
	 */
	onMouseOut: function(event) {
		this.mouseOver = false;
	},

	/**
	 * Event handler for the result box mouseOut events.
	 *
	 * @see mouseOver which is updated accordingly
	 */
	onMouseOver: function(event, result) {
		this.mouseOver = true;
	},

	/**
	 * Set the results box overriding previously setted box.
	 *
	 * @param resultsBoxId        String id of the results box element
	 * @param resultsContainerId  String id of the container inside the results box
	 */
	setResultsBox: function(resultsBoxId, resultsContainerId) {
		this.resultsBox = $(resultsBoxId);

		var table = new Element('table');
		$(resultsContainerId).insert(table);

		var tbody = new Element('tbody');
		table.insert(tbody);

		var tr = new Element('tr');
		tbody.insert(tr);

		this.resultsContainer = tr;

		this.resultsBox.observe('mouseover', this.onMouseOver.bind(this));
		this.resultsBox.observe('mouseout', this.onMouseOut.bind(this));
	},

	/**
	 * Show a single notice and hide others.
	 *
	 * @var String noticeType          notice type id
	 * @var SuggestInput suggestInput
	 */
	showNotice: function(noticeType, suggestInput) {
		SuggestSearch.clearResults();
		SuggestSearch.hideNotices();

		var notice = $('suggest-search-' + noticeType);

		if (notice) {
			notice.show();
		}

		if (suggestInput) {
			SuggestSearch.showResultsBox(suggestInput);
		}
	},

	/**
	 * Position result box to an input and show it
	 *
	 * @param SuggestInput suggestInput
	 */
	showResultsBox: function(suggestInput) {
		var inputElement = suggestInput.element;

 		var width = this.resultsBox.getWidth();

		// Get placement of the input
		var inputOffset = inputElement.cumulativeOffset();

		// Position to the left of the input, if there's room
		var parentWidth = this.resultsBox.getOffsetParent().getWidth();
		var left = Math.min(inputOffset.left, parentWidth - width);

		// Position just under the input
		var top = inputOffset.top + inputElement.getHeight();

		this.resultRowLimit = Math.max(4, Math.floor(IG.viewportSize()[1] / this.resultRowHeight - 2));

		this.resultsBox.setStyle({
			left: left + 'px',
			top: top + 'px'
		});

		this.resultsBox.show();
	}
});

document.observe('suggestsearch:init', SuggestSearch.init.bindAsEventListener(SuggestSearch));
