/*jslint browser: true, forin: true */
/*global jQuery, window, google, alert*/

/**
 * Make a form with input boxes get geographical suggestions from web services
 * 
 * Allows multiple forms on the page, with multiple input boxes, with multiple 
 * services.
 * 
 * Current services:
 *		Google Maps Geocoder (good for addresses)
 *		Google Local Search (good for businesses / POIs)
 * 
 * How it works:
 *		Intercepts form submit
 *		Looks up each input field with each services
 *		Shows suggestions to user
 *		Replaces input box with latLng of clicked result, whilst keeping the result title visible
 *		Submits the form if all input boxes are valid results or already latLngs
 */

(function($) {

var powerGeoSearch = {

		/**
		 * Map of service names to generator functions
		 *
		 * These are used to normalise the different GIS APIs
		 * 
		 * To add new services:
		 *		Call $.powerGeoSearch.addService('serviceName', generatorFunc);
		 *		Called in context of each config object
		 *		Must take 1 param service_name
		 *		When run:
		 *			Must change this.services[service_name] from true to an object
		 *			And set this.services[service_name].lookUp(search_string, callback_context, callback)
		 *			Callback takes an object with {title1: latLngString1, ...}
		 */
		services: {},
		
		addService: function(name, func) {
			// this.services?
			powerGeoSearch.services[name] = func;
		}
	},

	empty_func = function() {},

	latlng_re = /^([+\-]?\d*\.\d+)(?![\-+0-9\.]), ?([+\-]?\d*\.\d+)(?![\-+0-9\.])$/,

	scripts = {
		// Google's JS API loader
		jsapi: {
			url: "http://www.google.com/jsapi?sensor=false",
			loaded: function() {
				return !!window.google && !!window.google.load;
			},
			load: function(cb) {
				$.getScript(this.url, cb);
			}
		}
	},

	/**
	 * Make sure all the results are within bounds
	 */
	filterResults = function(results, bounds) {
		var i, v;
		for (i in results) {
			v = results[i].split(',');
			v[0] = parseFloat(v[0], 10);
			v[1] = parseFloat(v[1], 10);
			if (!v[0] || v[0] < bounds[0][0] || v[0] > bounds[1][0] ||	// lat
				!v[1] || v[1] < bounds[0][1] || v[1] > bounds[1][1]) {	// lng
					delete results[i];
			}
		}
		return results;
	},

	/**
	 * Handler for clicking on a single search result entry
	 */
	resultClick = function(title, latLng, field) {
		// chrome does not handle click evernts on object
		$('#' + field).val(title).removeClass('searching').data('latlng', latLng);
	},

	/**
	 * Generate HTML DOM elements to go in the result list
	 */
	resultLink = function (title, latLng, field) {
		return $('<option>' + title + '</option>').click(function() {
			resultClick(title, latLng, field);
		});
	},
	
	/**
	 * Check each geosearch service is loaded and load it if not
	 */
	loadServices = function() {
		var i;
		for (i in this.services) {
			if (this.services[i] === true) {
				if (powerGeoSearch.services[i]) {
					powerGeoSearch.services[i].call(this, i);
				} else {
					this.logError("Service " + i + " does not exist.");
				}
			}
		}
	},

	/**
	 * Load everything and run callbacks
	 */
	activate = function() {
		var me = this;
		if (this.activated) {
			return;
		}
		this.activated = true;

		if (!scripts.jsapi.loaded()) {
			scripts.jsapi.load(function() {
				loadServices.call(me);
			});
		} else {
			loadServices.call(this);
		}

		if (this.activate) {
			this.activate();
		}
	},

	/**
	 * Add the results suggestions to the field id's output
	 * 
	 * @param object hash results
	 * @param string id of form field
	 */
	output_results = function(results, id) {
		var config = this,
				select = config.getOutput.call($('#' + id)).find('select'),
				i,
				shownOutput = false;

		if (config.mustBeInBounds) {
			results = filterResults(results, config.bounds);
		}
		
		for (i in results) {
			if (!shownOutput) {
				config.showOutput($('#' + id));
				shownOutput = true;
			}
			// latLng may have already come back from another service but it could be named differently
			select.append(resultLink(i, results[i], id));
		}
	},

	/**
	 * See if a form field is ok and run all services on it if not
	 * 
	 * @param object jQuery field
	 */
	check_field = function(field) {
		var config = this,
			val = $(field).val(),
			id = $(field).attr('id'),
			incomplete_services = 0,
			service_name;

		if (val === 'Enter a postcode or place') {
			$(field).val('').focus();
			return;
		}

		if (latlng_re.test(val)) {
			// field is a lat, lng
			config.bad_fields--;
			return;
		}
		
		if ($(field).data('latlng')) {
			// field has latlng data saved
			config.bad_fields--;
			return;
		}
		

		if (config.preTestValues && config.preTestValues(val)) {
			// field is OK'd by preTestValues
			config.bad_fields--;
			return;
		}
		
		// the field needs a geosearch
		
		$(field).addClass('searching').data('latlng', '');
		
		config.clearOutput($(field));
		//config.output.append(field_output);
		//config.output.append('Looking up <b>' + val + '</b>...<br />');


		function handle_results (results) {
			// this maybe has the wrong value of ID
			incomplete_services--;
			output_results.call(config, results, id);

			if (incomplete_services === 0) {
				$(field).removeClass('searching').data('latlng', '');
				config.resultsComplete.call($(field), config);
			}
		}

		for (service_name in config.services) {
			if (config.services[service_name] === true) {
				config.logError('service ' + service_name + ' not loaded yet');
				continue;
			}

			incomplete_services++;

			//config.output.append('Searching using ' + service_name + '...<br />');
			
			config.services[service_name].lookUp(val, field, handle_results);
		}		
	},


	form_submit = function() {
		var //submit_time = new Date(), 
				config = this;

		$('.ui-autocomplete').hide();

		activate.call(config);

		// make so submitting the form does geocoding
		// TODO: don't re-geocode
		config.bad_fields = config.fields.length;

		config.fields.each(function() {
			check_field.call(config, this);
		});
		
		if (config.bad_fields !== 0) {
			return false;
		}
		
		// here we go!
		
		config.fields.each(function() {
			var latlng = $(this).data('latlng'),
					val = $(this).val(),
					name = $(this).attr('name');
					
			if (latlng && latlng.length) {
				$(this).val(latlng);
				$(this).after('<input type="hidden" name="' + name + '_original" value="' + val + '" />');
			}
		});
		
		if (config.submit) {
			config.submit.call(config.form);
		}
		
		return true;
	};


$.powerGeoSearch = powerGeoSearch;



$.fn.powerGeoSearch = function(settings) {

	return this.each(function() {
		var config = {
				activate:			null,				// callback on form activation
				activated:			false,				// whether it's been activated (private)
				appendQuery:		[],					// if the search wasn't affective then try adding this array
				bounds:				[[0, 0], [0, 0]],	// area to search
				loadEvents:			'focus click',		// load stuff when these events happen
				resultsComplete:	empty_func,			// when all searches have returned. called in context of input element; passed config as 1st param 
				logError:			empty_func,			// log an error message
				autocomplete:		null,				// use the jQuery UI autocomplete too
				preTestValues:		null,				// allow certain field values to pass, not requiring geocoding
				mustBeInBounds:		true,
				services: {								// service functions to use. either a func or true to load in
					googleGeocode:		true,
					googleLocal:		true
				},

				form:				$(this),			// jQuery <form>
				
				fields:				[],					// array of input text fields elements to use
				bad_fields:			null,				// all fields are considered bad until they're good
				getFields: function () {
					return $('input.autocomplete', this);
				},
				
				getOutput: function (form) {
					// called in context of input field
					if ($(this).next().is('.suggestion-container')) {
						return $(this).next();
					}
					return $('<div style="display:none" class="suggestion-container"><select></select></div>').insertAfter(this);
					//return $('<div style="display:none;"></div>').appendTo(form);
				},
				
				showOutput:	function(field) {
					var output = config.getOutput.call(field);
					
					// webkit does not handle clicks on option elements
					// make so changing the select triggers the click
					$('select', output).change(function() {
						$('option:selected', this).click();
					});
					output.show();
				},
				hideOutput: function(field) {
					var output = config.getOutput.call(field);
					output.hide();
					//this.html('').hide();
				},
				clearOutput: function(field) {
					var output = config.getOutput.call(field);
					output.remove();
					$(field).next('.message-error').remove();
					//output.find('option').remove();
				}
			};

		if (settings) {
			$.extend(config, settings);
		}

		// find the input fields
		if (!config.fields.length) {
			config.fields = config.getFields.call(config.form);
			
			if (!config.fields.length) {
				config.logError('No input fields to work with');
				return;
			}
		}
		
		// to all fields are assumed guilty until proven innocent
		config.bad_fields = config.fields.length;
		
		/*if (!config.ouput) {
			config.output = config.getOutput(config.form);
			
			if (!config.output) {
				config.logError('No output element to work with');
				return;
			}
		}*/

		config.form.delegate('*', config.loadEvents, function() {
			// activate the geocoder ASAP
			activate.call(config);
		}).submit(function() {
			// this needs access to local vars
			activate.call(config);
			return form_submit.call(config);
		});


		if (config.autocomplete) {
			if (!$().autocomplete) {
				config.logError('jQuery Autocomplete plugin not loaded');
				return;
			}
			config.fields.autocomplete(config.autocomplete);
		}

		// reset
		config.fields.bind('change keydown', function() {
			//var id = $(this).attr('id');
			// change bad_fields count
			
			$(this).removeClass('geocoded').data('latlng', '');
			
			config.clearOutput(this);
			//$('.suggestion-container').remove();
			
			//config.output.find('.' + id).remove();
		});



	});

};



}(jQuery));

























(function($) {

// Google Local Search, part of Google AJAX Search API
// http://code.google.com/apis/ajaxsearch/documentation/reference.html
// Not part of the Maps API and works very differently to it
// the global google is assumed to be available at this point
function googleLocal(service) {
	var me = this,
		lookUp = function(val, callback_context, cb) {
			var s = new google.search.LocalSearch(),
				center = new google.maps.LatLng(
					(me.bounds[0][0] + me.bounds[1][0]) / 2,
					(me.bounds[0][1] + me.bounds[1][1]) / 2
				);

			s.setCenterPoint(center);

			// googleGeocode does a better job of address lookups
			s.setAddressLookupMode(google.search.LocalSearch.ADDRESS_LOOKUP_DISABLED);

			// the callback has to be set first
			s.setSearchCompleteCallback(this, function() {

				var clean_results = {}, i, r, latLng;

				if (s.results && s.results.length) {
					for (i in s.results) {
						r = s.results[i];
						if (r.titleNoFormatting && r.lat && r.lng) {
							latLng = r.lat + ',' + r.lng;
							clean_results[r.titleNoFormatting + ', ' + r.streetAddress] = latLng;
						}

					}
				}

				cb.call(callback_context, clean_results);
			}, [s]);

			s.execute(val);
		};

	// required google APIs already loaded
	if (google.search && google.search.LocalSearch) {
		this.services[service] = {};
		this.services[service].lookUp = lookUp;
		return;
	}

	// ask google to laod the APIs
	google.load("search", "1", {
		other_params: 'sensor=false',
		nocss: true, // this could conflict later
		callback: function() {
			me.services[service] = {};
			me.services[service].lookUp = lookUp;
		}
	});
}

$.powerGeoSearch.addService('googleLocal', googleLocal);

}(jQuery));





(function($) {


// Google Maps Geocoder, part of Google Maps API
// http://code.google.com/apis/maps/documentation/javascript/reference.html
// Much nicer API than Google Local Serch, but only does addresses
// the global google is assumed to be available at this point
function googleGeocode(service) {
	var me = this,
		lookUp = function(val, callback_context, cb) {
			
			var bounds = new google.maps.LatLngBounds(
					new google.maps.LatLng(me.bounds[0][0],	me.bounds[0][1]),
					new google.maps.LatLng(me.bounds[1][0], me.bounds[1][1])
				),
				request = {
					address: val,
					bounds: bounds
				},
				statuses = {
					ERROR:				'There was a problem contacting the Google servers.',
					INVALID_REQUEST:	'This GeocoderRequest was invalid.',
					OK:					'The response contains a valid GeocoderResponse.',
					OVER_QUERY_LIMIT:	'The webpage has gone over the requests limit in too short a period of time.',
					REQUEST_DENIED:		'The webpage is not allowed to use the geocoder.',
					UNKNOWN_ERROR:		'A geocoding request could not be processed due to a server error. The request may succeed if you try again.',
					ZERO_RESULTS:		'No result was found for this GeocoderRequest.'
				};

			me.services[service].geocode(request, function(results, status) {
				var clean_results = {}, i, r, latLng;

				if (status !== 'OK' && status !== 'ZERO_RESULTS') {
					me.logError('Bad status recieved from ' + service + ': ' +
						status + ' "' + statuses[status] + '"');
				}

				for (i in results) {
					r = results[i];
					if (r.formatted_address && r.geometry) {
						latLng = r.geometry.location.toUrlValue();
						clean_results[r.formatted_address] = latLng;
					}
				}
				
				cb.call(callback_context, clean_results);
			});
		};

	// required google APIs already loaded
	if (google.maps && google.maps.Geocoder) {
		this.services[service] = new google.maps.Geocoder();
		this.services[service].lookUp = lookUp;
		return;
	}

	// ask google to laod the APIs
	google.load("maps", "3", {
		other_params: 'sensor=false',
		nocss: true, // this could conflict later
		callback: function() {
			me.services[service] = new google.maps.Geocoder();
			me.services[service].lookUp = lookUp;
		}
	});
}

$.powerGeoSearch.addService('googleGeocode', googleGeocode);

}(jQuery));

