/**
 * @class GoogleMap
 * @fileOverview Provides an extended interface to the Google Map API
 * 
 * @requires jquery.js
 * 
 * @author Larry Reinhard
 */

/**
 * @constructor
 */
GoogleMap = function () {

	/* the GoogleMap API */
	this.api = google.maps;
	this.map = null;
	this.markercollection = {};
	this.geocoder = null;
};

// load the Google Maps API
if (typeof(google) == 'undefined') {
	alert('Google maps not available.');
} else {
	if (!google.maps)
		google.load('maps','2');
}


// Anonymous function wrapper provides closure access to private methods
(function(){
	
	// Private Methods

	/**
	 * Builds a marker and adds a listener to open an info window
	 * 
	 * @param {Object} api Reference to the Google Map API
	 * @param {GLatLong} latlong A coordinate for the marker
	 * @param {object} opts:
	 * 		{String} [html] The content for the info window
	 * 		{String} [title] The message that appears on mouse hover
	 * 		{Object} [icon] An object describing a custom icon
	 * 		{function} [click] A pointer to a function that will be called when
	 * 			the marker is clicked
	 * 		{string} id A unique identifier string for the marker; will
	 * 			be passed to all of the event handler functions.
	 * @private
	 */
	var createMarker = function (api, latlong, opts) {

		// define marker icon
		var customIcon = api.DEFAULT_ICON;

		if (opts.icon) {
			customIcon = new api.Icon(api.DEFAULT_ICON);
			customIcon.image = opts.icon.image;
			customIcon.shadow = opts.icon.shadow;
			if (opts.icon.iconsize)
				customIcon.iconSize = new api.Size(
					parseInt(opts.icon.iconsize.split(',')[0]),
					parseInt(opts.icon.iconsize.split(',')[1])
				);
			if (opts.icon.shadowsize)
				customIcon.shadowSize = new api.Size(
					parseInt(opts.icon.shadowsize.split(',')[0]),
					parseInt(opts.icon.shadowsize.split(',')[1])
				);
			if (opts.icon.iconanchor)
				customIcon.iconAnchor = new api.Point(
					parseInt(opts.icon.iconanchor.split(',')[0]),
					parseInt(opts.icon.iconanchor.split(',')[1])
				);
			if (opts.icon.infowindowanchor)
				customIcon.infoWindowAnchor = new api.Point(
					parseInt(opts.icon.infowindowanchor.split(',')[0]),
					parseInt(opts.icon.infowindowanchor.split(',')[1])
				);
		}

		// create the marker
		var marker = new api.Marker(latlong, {
			title    : opts.title || null,
			icon     : customIcon,
			draggable: opts.draggable || false
		});

		// add info window content
		if (opts.html && opts.html.length) {
//			marker.bindInfoWindowHtml(opts.html);
			api.Event.addListener(marker, "click", function() {
				marker.openInfoWindowHtml(opts.html);
				return false;
			});
		}
		
		if (opts.click && $.isFunction(opts.click)) {
			// add custom click handler
			api.Event.addListener(marker, "click", function() {			
				opts.click.call(this,opts.id);
			});
		}
		
		if (opts.mouseover && $.isFunction(opts.mouseover)) {
			// add custom mouseover handler
			api.Event.addListener(marker, "mouseover", function() {			
				opts.mouseover.call(this,opts.id);
			});
		}
		
		if (opts.mouseout && $.isFunction(opts.mouseout)) {
			// add custom mouseout handler
			api.Event.addListener(marker, "mouseout", function() {			
				opts.mouseout.call(this,opts.id);
			});
		}
		
		if (opts.draggable && opts.dragstart && $.isFunction(opts.dragstart)) {
			// add custom dragstart handler
			api.Event.addListener(marker,"dragstart", function() {
				opts.dragstart.call(this,opts.id);
			});
		}

		if (opts.draggable && opts.drag && $.isFunction(opts.drag)) {
			// add custom drag (while dragging) handler
			api.Event.addListener(marker,"drag", function() {
				opts.drag.call(this,opts.id);
			});
		}

		if (opts.draggable && opts.dragend && $.isFunction(opts.dragend)) {
			// add custom dragend handler
			api.Event.addListener(marker,"dragend", function() {
				opts.dragend.call(this,opts.id);
			});
		}

		return marker;
	};

	/**
	 * Adds a map marker to a collection.
	 * 
	 *  @param {object} col The marker collection
	 *  @param {object} marker A map marker object
	 *  @param {string} id A unique ID for the marker
	 *  @param {string} [tag] An optional tag name. If specified, markers
	 *  	can be managed by tag name
	 */
	var addMarkerToCollection = function (col,marker,id,tag) {

		if (!tag) { tag = '_default';}
		
		if (!col[tag])
			col[tag] = {};
		col[tag][id] = marker;
	};
	
	/**
	 * Returns true if the marker exists in the collection
	 * 
	 * @param {string} id The unique ID for the marker
	 */
	var findMarkerInCollection = function (col, id) {
		
		for (var tag in col) {
			if (col[tag][id]) {
				return true;
			}
		}

		return false;
	};
	
	/**
	 * Adds one or more markers to the map and the local markercollection array
	 * 
	 * @param {Object} mm A GoogleMap instance
	 * @param {Array or Object} markers An array of markers or a single marker object
	 * 
	 * @private
	 */
	var addMarkersToMap = function (mm, markers) {
		
		var api = mm.api;
		
		if (markers instanceof Array) {
			// an array of marker objects
			for (var i = 0; i < markers.length; i++) {
				if (! findMarkerInCollection(mm.markercollection, markers[i].id)) {
					var marker = createMarker(api,
						new api.LatLng(markers[i].latitude, markers[i].longitude),
						markers[i]
					);
					mm.map.addOverlay(marker);
					if (markers[i].hidden) { marker.hide(); }
					addMarkerToCollection(mm.markercollection, marker, markers[i].id, markers[i].tag || null);
				}
			}
		} else {
			// a single marker object
			
			if (! findMarkerInCollection(mm.markercollection, markers.id)) {
			
				var marker = createMarker(api,
					new api.LatLng(markers.latitude, markers.longitude),
					markers
				);
				mm.map.addOverlay(marker);
				if (markers.hidden) { marker.hide(); }
				addMarkerToCollection(mm.markercollection, marker, markers.id, markers.tag || null);
			}
		}
	};

	/**
	 * Display the map with some controls and set the initial location
	 * 
	 * @param {Object} api Reference to the Google Map API
	 * @param {String} el
	 *            An element or id of an element that will contain the map.
	 * @param {Object} [opt]
	 *            Options for configuring the map
	 * @private
	 */
	var createMap = function (api, el, opt) {

		// el can be an id or an element
		var container = typeof(el) === 'string'
				? document.getElementById(el)
				: el;
		var map = new api.Map2(container);
		var dfltControls = { panZoom:'Large', mapType: true };

		// add controls for Zoom, Pan, and type (map/satellite/hybrid)
		if (!opt.printmap) {
			opt.controls = $.extend(dfltControls,opt.controls);
			switch (opt.controls.panZoom) {
				case 'Small': map.addControl(new api.SmallMapControl()); break;
				case 'ZoomOnly': map.addControl(new api.SmallZoomControl()); break;
				case 'none': break;
				case 'Large': 
				default:
					map.addControl(new api.LargeMapControl());
			}
			if (opt.controls.mapType)
				map.addControl(new api.MapTypeControl());
		}

		// set map center & zoom level
		if (opt.center) {
			map.setCenter(
				new api.LatLng(opt.center.latitude, opt.center.longitude),
				opt.zoomlevel || GoogleMap.DEFAULT_ZOOM_LEVEL
			);
		}

		if (opt.scrollwheelzoom)
			map.enableScrollWheelZoom();
			
		if (opt.continuouszoom)
			map.enableContinuousZoom();

		// Refresh map if container size changes
		$(container).resize(map.checkResize);
		
		return map;
	};

	// Public methods & class properties
	GoogleMap.prototype = {

		DEFAULT_ZOOM_LEVEL : 10,
	
		accuracyDecoder : {
			0 : 'Unknown location',
			1 : 'Country level',
			2 : 'State level',
			3 : 'County level',
			4 : 'City level',
			5 : 'ZIP code level',
			6 : 'Street level',
			7 : 'Intersection level',
			8 : 'Address level'
		},
	
		geoStatusDecoder : {
			200 : 'Successfully geocoded',
			400 : 'Could not parse directions request',
			500 : 'Failed, but could not determine reason',
			601 : 'No address provided',
			602 : 'No geographic location could be found for the specified address.',
			603 : 'The geocode for the given address cannot be returned due to legal or contractual reasons.',
			604 : 'Route not found',
			610 : 'Invalid key for this domain',
			620 : 'The given key has gone over the requests limit in the 24 hour period.'
		},

		/**
		 * Initialize Map
		 * 
		 * @param {String}
		 *            el An element or id of an element that will contain the
		 *            map.
		 * @param {Object}
		 *            [opt] Options for configuring the map:
		 * @config {Object} center An object with latitude and longitude values
		 *         for positioning the map
		 * @config {Array} markers An array of Marker objects, each with fields
		 *         for latitude, longitude, infowindow html, title, and custom
		 *         icon configuration (see details in addMarkers() below)
		 * @config {boolean} printmap If true, the map control overlays are
		 *         removed
		 * @config {Number} zoomlevel The starting zoom level
		 * @config {boolean} scrollwheelzoom If true, enable zoom with mouse
		 *         scroll wheel (default is false)
		 */
		initMap : function(el, opt) {
			if (this.api){
				window.document.onunload = this.api.Unload;
				if (this.api.BrowserIsCompatible()) {
			
					if (!this.map)
						this.map = createMap(this.api, el, opt);
					else if (opt.center) {
						this.map.setCenter(
							new this.api.LatLng(opt.center.latitude, opt.center.longitude),
							opt.zoomlevel || GoogleMap.DEFAULT_ZOOM_LEVEL
						);
					}
					// Set up markers with info windows
					if (opt.markers)
						addMarkersToMap(this, opt.markers);
			
					// Set map type
					if (opt.mapType)
						this.setMapType(opt.mapType);

				} else {
					// display a warning if the browser was not compatible
					alert("Sorry, the Google Maps API is not compatible with this browser");
				}
			} else {
				alert("Google Maps API unavailable.");
			}
		
		},
	
		/**
		 * Returns the visible rectangular region of the map view
		 * in geographical coordinates
		 * 
		 * @return {Object} nLat,sLat,eLng,wLng
		 */
		getBounds : function() {
			var bnds = this.map.getBounds();
			var sw = bnds.getSouthWest();
			var ne = bnds.getNorthEast();
			return { 
				nLat: ne.lat(),
				sLat: sw.lat(),
				eLng: ne.lng(),
				wLng: sw.lng()
			}
		},

		/**
		 * Add new markers to the map
		 * 
		 * @param {Array}
		 *            markers An array of Marker objects, each with fields for
		 *            latitude, longitude, infowindow html, title, and custom
		 *            icon configuration
		 * @config {Number} latitude
		 * @config {Number} longitude
		 * @config {string} id A unique identifier string for the marker
		 * @config {String} [html]
		 * @config {String} [title]
		 * @config {Object} [icon] Describes a custom image for the marker with
		 *         the following fields: image (url), shadow (url), iconsize
		 *         (comma-separated pixel dimensions - w,h), shadowsize
		 *         (comma-separated pixel dimensions - w,h), iconanchor
		 *         (comma-separated pixel coordinates - x,y), infowindowanchor
		 *         (comma-separated pixel coordinates - x,y)
		 * @config {function} [fn] A pointer to a function will be called when
		 * 			the marker is clicked
		 */
		addMarkers : function(markers) {
			if (typeof(markers) === 'object')
				addMarkersToMap(this, markers);
		},
	
		/**
		 * Remove all markers from the map
		 */
		clearMarkers : function() {
			if (this.map)
				this.map.clearOverlays();
			this.markercollection = {};
		},
		
		/**
		 * Sets map center point and closest zoom level so that all markers are
		 * shown on map
		 * 
		 * @param {object} [opt] Configuration options for controlling the zoom
		 * @param {string} [opt.tag]
		 * 		A marker collection tag name. If not provided, markers from all
		 *      collections are used.
		 * @param {number} [opt.maxZoom]
		 *      Prevents overzooming to a single marker or tightly clustered markers 
		 * @param {number} [opt.delta]
		 *      Adjusts the calculated zoom; negative values zoom out; 
		 */
		zoomToMarkers : function(opt) {

			var defaults = { tag: null, maxZoom: 100, delta: 0 }; 
			var options = $.extend({}, defaults, opt);
			
			var numMarkers = 0;
				
			var minLat = 181, maxLat = -181, minLng = 181, maxLng = -181, aLatLng;
	
			for (var thisTag in this.markercollection) {
				if (!options.tag || (thisTag == options.tag)) {
					// compute min & max lat/lng values for all markers
					for (var id in this.markercollection[thisTag]) {
						aLatLng = this.markercollection[thisTag][id].getLatLng();
						minLat = Math.min(minLat, aLatLng.lat());
						maxLat = Math.max(maxLat, aLatLng.lat());
						minLng = Math.min(minLng, aLatLng.lng());
						maxLng = Math.max(maxLng, aLatLng.lng());
						numMarkers++;
					}
				}
			}
			
			if (numMarkers == 0) {
				this.map.setZoom(3);
				
			} else if (numMarkers == 1) {
				this.map.setCenter(
					new this.api.LatLng(minLat, minLng),
					Math.min(options.maxZoom, 17)
				);
			} else {
				
				// define rectangle for marker boundaries
				var markerBounds = new this.api.LatLngBounds(
						new this.api.LatLng(minLat, minLng),
						new this.api.LatLng(maxLat, maxLng));
				// set the new center based on the average lat/lng and set zoom
				// to the best level that keeps this rectangle in view
				this.map.setCenter(
					//new this.api.LatLng((minLat + maxLat) / 2, (minLng + maxLng) / 2),
					markerBounds.getCenter(),
					Math.min(options.maxZoom,Math.max(0,this.map.getBoundsZoomLevel(markerBounds) + options.delta))
				);
			}
			// so that the center zoom/pan control returns here
			this.map.savePosition();
		},
		
		/**
		 * Removes markers outside of given boundaries
		 * 
		 * @param {object} bnds The boundaries in geographical coordinates (compatible with
		 * the results returned by this class's getBounds method).
		 * { nLat: ?, sLat: ?, eLng: ?, wLng: ? }
		 * If not provided, the map boundaries are used. All marker collections are
		 * trimmed.
		 */
		trimMarkers : function(bnds) {
			// construct a trim boundary in geoCoords
			if (bnds) {
				var tbnds = this.api.LatLngBounds(
					new this.api.LatLng(bnds.sLat,bnds.wLng),
					new this.api.LatLng(bnds.nLat,bnds.eLng)
				);
			} else {
				var tbnds = this.map.getBounds();
			}
			
			for (var thisTag in this.markercollection) {
				for (var id in this.markercollection[thisTag]) {
					var m = this.markercollection[thisTag][id];
					// see if it falls within the boundaries
					if (! tbnds.containsLatLng(m.getLatLng())) {
						// remove from map
						this.map.removeOverlay(m);
						// remove from collection
						delete this.markercollection[thisTag][id];
					}
				}
			}		
		},
		
		/**
		 * Hides markers
		 * 
		 * @param {string} [tag] A marker collection tag name. If not provided,
		 * 		markers from all collections are hidden.
		 */
		hideMarkers : function(tag) {
	
			for (var thisTag in this.markercollection) {
				if (!tag || (thisTag == tag)) {
					for (var id in this.markercollection[thisTag]) {
						this.markercollection[thisTag][id].hide();
					}
				}
			}		
		},
	
		/**
		 * Shows markers
		 * 
		 * @param {string} [tag] A marker collection tag name. If not provided,
		 * 		markers from all collections are shown.
		 */
		showMarkers : function(tag) {
	
			for (var thisTag in this.markercollection) {
				if (!tag || (thisTag == tag)) {
					for (var id in this.markercollection[thisTag]) {
						this.markercollection[thisTag][id].show();
					}
				}
			}		
		},
	
		
		/**
		 * Toggles markers (show/hide)
		 * 
		 * @param {string} [tag] A marker collection tag name. If not provided,
		 * 		markers from all collections are toggled (shown <--> hidden).
		 */
		toggleMarkers : function(tag) {
	
			for (var thisTag in this.markercollection) {
				if (!tag || (thisTag == tag)) {
					for (var id in this.markercollection[thisTag]) {
						var m = this.markercollection[thisTag][id];
						m.isHidden() ? m.show() : m.hide();
					}
				}
			}		
		},
		
		/**
		 * Counts markers
		 * 
		 * @param {string} [tag] A marker collection tag name. If not provided,
		 * 		markers from all collections are counted. 
		 */
		countMarkers : function (tag) {
			var count = 0;			
			for (var thisTag in this.markercollection) {
				if (!tag || (thisTag == tag)) {
					for (var id in this.markercollection[thisTag]) { count++; }
				}
			}
			return count;
		},

		/**
		 * Convert address into lat/long
		 */
		geocodeAddress : function(address, callback) {
			
			if (!this.geocoder)
				this.geocoder = new this.api.ClientGeocoder();

			var gm = this;
			var handleResponse = function(gm, result) {
				var rtn = {};
				if (result) {
					var sc = result.Status.code;
					rtn.success = sc === gm.api.GEO_SUCCESS;
					rtn.message = gm.decodeGeoStatus(sc);
					rtn.placemarks = result.Placemark;
				} else {
					rtn.success = false;
					rtn.message = 'Invalid response from Google';
				}
				callback.call(gm, rtn);
			};
			
			// geocoder.getLatLng(address, callback);
			this.geocoder.getLocations(address, function(r) { handleResponse(gm,r) });
		},
	
		/**
		 * Translate accuracy code into user-friendly description
		 * 
		 * @param {number}
		 *            accuracy A GGeoAddressAccuracy code
		 */
		decodeGeoAccuracy : function(accuracy) {
	
			return this.accuracyDecoder[accuracy]
					? this.accuracyDecoder[accuracy]
					: 'Unknown accuracy';
	
		},
	
		decodeGeoStatus : function(sc) {
	
			return this.geoStatusDecoder[sc]
					? this.geoStatusDecoder[sc]
					: 'Unknown status';
	
		},
	
		parseStdAddress : function(addr,accuracy) {
			
			var parsed = {
				street : null,
				city : null,
				state : null,
				zip : null,
				country : null
			};

			if (!accuracy) accuracy = 8;
			switch(accuracy||8) {
				case 8: // Address level
				case 7: // Intersection level
				case 6: // Street level
					var addrparts = addr.match(/^(.*),\s(.*),\s(.{2,4})\s(.{5}),\s([^,]+)$/) // typical address
						|| addr.match(/^(.*),\s(.*),\s(.{2,4})\s(.{5})([^,]*)$/) // exception: no country
						|| addr.match(/^(.*)(),\s(.{2})\s(.{5}),\s([^,]+)$/) // exception: no city returned
						|| addr.match(/^(.*),\s(.*),\s(.{2})(),\s([^,]+)$/); // exception: Canadian, no postal code
			
					if (addrparts) {
						parsed.street = addrparts[1];
						parsed.city = addrparts[2];
						parsed.state = addrparts[3].replace(/\./g,'');
						parsed.zip = addrparts[4];
						parsed.country = addrparts[5];
					}
					break;
				case 5: // ZIP code level
					var addrparts = addr.match(/^(.*),\s(.{2,4})\s(.{5}),\s([^,]+)$/) // expect city,state,zip,country
						|| addr.match(/^(.*),\s(.{2,4})\s(.{5})([^,]*)$/) // exception: no country
						|| addr.match(/^(),?\s?(.{2})\s(.{5}),\s([^,]+)$/) // exception: no city returned
						|| addr.match(/^(.*),\s(.{2})(),\s([^,]+)$/); // exception: Canadian, no postal code
					if (addrparts) {
						parsed.city = addrparts[1];
						parsed.state = addrparts[2].replace(/\./g,'');
						parsed.zip = addrparts[3];
						parsed.country = addrparts[4];
					}
					break;
				case 4: // City level
				case 3: // County level
					var addrparts = addr.match(/^(.*),\s(.{2,4}),\s([^,]+)$/) // expect city,state,country
						|| addr.match(/^(.*),\s(.{2,4})([^,]*)$/); // exception: no country
					if (addrparts) {
						parsed.city = addrparts[1];
						parsed.state = addrparts[2].replace(/\./g,'');
						parsed.country = addrparts[3];
					}
					break;
				case 2: // State level
					var addrparts = addr.match(/^(.{2,4}),\s([^,]+)$/) // expect city,state,country
						|| addr.match(/^(.{2,4})([^,]*)$/); // exception: no country
					if (addrparts) {
						parsed.state = addrparts[1].replace(/\./g,'');
						parsed.country = addrparts[2];
					}
					break;
				default:
					break;
			}

			return parsed;
		},
		
		/**
		 * Causes the map to pan in the given direction by moving the map
		 * images in the opposite direction
		 * 
		 * @param {Char} dir The direction code: 'n','e','w', or 's'
		 */
		pan : function (dir) {
			switch (dir) {
				// move images down
				case 'n': this.map.panDirection(0, 1); break;
				// move images left
				case 'e': this.map.panDirection(-1,0); break;
				// move images right
				case 'w': this.map.panDirection( 1,0); break;
				// move images up
				case 's': this.map.panDirection(0,-1); break;
			}
		},
		
		/**
		 * Increase zoom one level
		 */
		zoomIn : function () {
			this.map.zoomIn();
			return this.map.getZoom();
		},
		
		/**
		 * Decrease zoom one level
		 */
		zoomOut : function () {
			this.map.zoomOut();
			return this.map.getZoom();
		},
		
		/**
		 * Returns the current zoom level
		 */
		getZoom : function () {
			return this.map.getZoom();
		},
		
		/**
		 * Sets the zoom level to specified value
		 */
		setZoom : function (level,save) {
			this.map.setZoom(level);
			if (save) // so that the center zoom/pan control returns here
				this.map.savePosition();

			return this.map.getZoom();
		},
		
		/**
		 * Change the map type
		 * 
		 * @param {string} type The new map type: 'street', 'sat', or 'hybrid'
		 */
		setMapType : function (type) {
			switch (type) {
				case 'sat' : this.map.setMapType(this.api.SATELLITE_MAP); break;
				case 'hybrid' : this.map.setMapType(this.api.HYBRID_MAP); break;
				case 'street' :
				default: this.map.setMapType(this.api.NORMAL_MAP);
			}
		},
	
		/**
		 * Return map center and zoom to last saved position
		 */
		resetPosition : function () {
			this.map.returnToSavedPosition();
		},
		
		checkResize : function () {
			this.map.checkResize();
		},
		
		/**
		 * Registers an event handler for when the zoom changes
		 */
		addZoomCallback : function (fn) {
			return this.api.Event.addListener(this.map,'zoomend',fn);
		},
		
		/**
		 * Registers an event handler for when the map moves (pan)
		 */
		addPanCallback : function (fn) {
			return this.api.Event.addListener(this.map,'moveend',fn);
		},
		
		addMapClickCallback : function (fn) {
			return this.api.Event.addListener(this.map,'click',fn);
		},
		
		addDragStartCallback : function (fn) {
			return this.api.Event.addListener(this.map,'dragstart',fn);
		},
		
		/**
		 * Returns the pixel coordinates corresponding to the provided Lat/Long
		 * @return {object} Coordinates x,y
		 */
		xlateLatLngToPix : function (lat,lng) {
			var pt = this.map.fromLatLngToDivPixel(new this.api.LatLng(lat,lng));
			return { x: pt.x, y: pt.y }
		},
	
		/**
		 * Returns the center of the map in geographical coordinates
		 * @return {object} Coordinates lat,lng
		 */
		getCenter : function () {
			var cntr = this.map.getCenter();
			return {
				lat: cntr.lat(),
				lng: cntr.lng()
			}
		},
		
		/**
		 * Writes a message into the Google log window. HTML is escaped.
		 */
		logMessage : function (m) {
			this.api.Log.write(m);
		}
	};

})(); // anonymous singleton for private methods
