text: var _ = require('lodash');
  var FieldType = require('../Type');
  var https = require('https');
  var keystone = require('../../../');
  var querystring = require('querystring');
  var util = require('util');
  var utils = require('keystone-utils');
  
  var RADIUS_KM = 6371;
  var RADIUS_MILES = 3959;
  
  /**
   * Location FieldType Constructor
   */
  function location (list, path, options) {
  
  	this._underscoreMethods = ['format', 'googleLookup', 'kmFrom', 'milesFrom'];
  	this._fixedSize = 'full';
  	this._properties = ['enableMapsAPI'];
  	this.enableMapsAPI = (options.enableImprove === true || (options.enableImprove !== false && keystone.get('google server api key'))) ? true : false;
  
  	// Throw on invalid options in 4.0 (remove for 5.0)
  	if ('geocodeGoogle' in options) {
  		throw new Error('The geocodeGoogle option for Location fields has been renamed to enableImprove');
  	}
  
  	if (!options.defaults) {
  		options.defaults = {};
  	}
  
  	if (options.required) {
  		if (Array.isArray(options.required)) {
  			// required can be specified as an array of paths
  			this.requiredPaths = options.required;
  		} else if (typeof options.required === 'string') {
  			// or it can be specified as a comma-delimited list
  			this.requiredPaths = options.required.replace(/,/g, ' ').split(/\s+/);
  		}
  		// options.required should always be simplified to a boolean
  		options.required = true;
  	}
  
  	// default this.requiredPaths
  	if (!this.requiredPaths) {
  		this.requiredPaths = ['street1', 'suburb'];
  	}
  
  	location.super_.call(this, list, path, options);
  }
  location.properName = 'Location';
  util.inherits(location, FieldType);
  
  /**
   * Registers the field on the List's Mongoose Schema.
   */
  location.prototype.addToSchema = function (schema) {
  
  	var field = this;
  	var options = this.options;
  
  	var paths = this.paths = {
  		number: this.path + '.number',
  		name: this.path + '.name',
  		street1: this.path + '.street1',
  		street2: this.path + '.street2',
  		suburb: this.path + '.suburb',
  		state: this.path + '.state',
  		postcode: this.path + '.postcode',
  		country: this.path + '.country',
  		geo: this.path + '.geo',
  		geo_lat: this.path + '.geo_lat',
  		geo_lng: this.path + '.geo_lng',
  		serialised: this.path + '.serialised',
  		improve: this.path + '_improve',
  		overwrite: this.path + '_improve_overwrite',
  	};
  
  	var getFieldDef = function (type, key) {
  		var def = { type: type };
  		if (options.defaults[key]) {
  			def.default = options.defaults[key];
  		}
  		return def;
  	};
  
  	schema.nested[this.path] = true;
  	schema.add({
  		number: getFieldDef(String, 'number'),
  		name: getFieldDef(String, 'name'),
  		street1: getFieldDef(String, 'street1'),
  		street2: getFieldDef(String, 'street2'),
  		street3: getFieldDef(String, 'street3'),
  		suburb: getFieldDef(String, 'suburb'),
  		state: getFieldDef(String, 'state'),
  		postcode: getFieldDef(String, 'postcode'),
  		country: getFieldDef(String, 'country'),
  		geo: { type: [Number], index: '2dsphere' },
  	}, this.path + '.');
  
  	schema.virtual(paths.serialised).get(function () {
  		return _.compact([
  			this.get(paths.number),
  			this.get(paths.name),
  			this.get(paths.street1),
  			this.get(paths.street2),
  			this.get(paths.suburb),
  			this.get(paths.state),
  			this.get(paths.postcode),
  			this.get(paths.country),
  		]).join(', ');
  	});
  
  	// pre-save hook to fix blank geo fields
  	// see http://stackoverflow.com/questions/16388836/does-applying-a-2dsphere-index-on-a-mongoose-schema-force-the-location-field-to
  	schema.pre('save', function (next) {
  		var obj = field._path.get(this);
  		var geo = (obj.geo || []).map(Number).filter(_.isFinite);
  		obj.geo = (geo.length === 2) ? geo : undefined;
  		next();
  	});
  
  	this.bindUnderscoreMethods();
  };
  
  /**
   * Add filters to a query
   */
  var FILTER_PATH_MAP = {
  	street: 'street1',
  	city: 'suburb',
  	state: 'state',
  	code: 'postcode',
  	country: 'country',
  };
  location.prototype.addFilterToQuery = function (filter) {
  	var query = {};
  	var field = this;
  	['street', 'city', 'state', 'code', 'country'].forEach(function (i) {
  		if (!filter[i]) return;
  		var value = utils.escapeRegExp(filter[i]);
  		value = new RegExp(value, 'i');
  		query[field.paths[FILTER_PATH_MAP[i]]] = filter.inverted ? { $not: value } : value;
  	});
  	return query;
  };
  
  /**
   * Formats a list of the values stored by the field. Only paths that
   * have values will be included.
   *
   * Optionally provide a space-separated list of values to include.
   *
   * Delimiter defaults to `', '`.
   */
  location.prototype.format = function (item, values, delimiter) {
  	if (!values) {
  		return item.get(this.paths.serialised);
  	}
  	var paths = this.paths;
  	values = values.split(' ').map(function (i) {
  		return item.get(paths[i]);
  	});
  	return _.compact(values).join(delimiter || ', ');
  };
  
  /**
   * Detects whether the field has been modified
   */
  location.prototype.isModified = function (item) {
  	return item.isModified(this.paths.number)
  	|| item.isModified(this.paths.name)
  	|| item.isModified(this.paths.street1)
  	|| item.isModified(this.paths.street2)
  	|| item.isModified(this.paths.suburb)
  	|| item.isModified(this.paths.state)
  	|| item.isModified(this.paths.postcode)
  	|| item.isModified(this.paths.country)
  	|| item.isModified(this.paths.geo);
  };
  
  location.prototype.getInputFromData = function (data) {
  	// Allow JSON structured data
  	var input = this.getValueFromData(data);
  
  	// If there is no structured data, look for the flat paths
  	if (!input) {
  		input = {
  			number: data[this.paths.number],
  			name: data[this.paths.name],
  			street1: data[this.paths.street1],
  			street2: data[this.paths.street2],
  			suburb: data[this.paths.suburb],
  			state: data[this.paths.state],
  			postcode: data[this.paths.postcode],
  			country: data[this.paths.country],
  			geo: data[this.paths.geo],
  			geo_lat: data[this.paths.geo],
  			geo_lng: data[this.paths.geo],
  			improve: data[this.paths_improve],
  			overwrite: data[this.paths_improve_overwrite],
  		};
  	}
  
  	return input;
  };
  
  /**
   * Validates that a value for this field has been provided in a data object
   */
  location.prototype.validateInput = function (data, callback) {
  	// var input = this.getInputFromData(data);
  	// TODO: We should strictly check for types in input here
  	utils.defer(callback, true);
  };
  
  /**
   * Validates that input has been provided
   * TODO: Needs test coverage
   */
  location.prototype.validateRequiredInput = function (item, data, callback) {
  	var result = true;
  	var input = this.getInputFromData(data);
  	var currentValue = item.get(this.path);
  	this.requiredPaths.forEach(function (path) {
  		// ignore missing values if they already exist in the item
  		if (input[path] === undefined && currentValue[path]) return;
  		// falsy values mean the input is invalid
  		if (!input[path]) {
  			result = false;
  		}
  	});
  	utils.defer(callback, result);
  };
  
  /**
   * Validates that a value for this field has been provided in a data object
   *
   * options.required specifies an array or space-delimited list of paths that
   * are required (defaults to street1, suburb)
   *
   * Deprecated
   */
  location.prototype.inputIsValid = function (data, required, item) {
  	if (!required) return true;
  	var paths = this.paths;
  	var nested = this._path.get(data);
  	var values = nested || data;
  	var valid = true;
  	this.requiredPaths.forEach(function (path) {
  		if (nested) {
  			if (!(path in values) && item && item.get(paths[path])) {
  				return;
  			}
  			if (!values[path]) {
  				valid = false;
  			}
  		} else {
  			if (!(paths[path] in values) && item && item.get(paths[path])) {
  				return;
  			}
  			if (!values[paths[path]]) {
  				valid = false;
  			}
  		}
  	});
  	return valid;
  };
  
  /**
   * Updates the value for this field in the item from a data object
   */
  location.prototype.updateItem = function (item, data, callback) {
  
  	var paths = this.paths;
  	var fieldKeys = ['number', 'name', 'street1', 'street2', 'suburb', 'state', 'postcode', 'country'];
  	var geoKeys = ['geo', 'geo_lat', 'geo_lng'];
  	var valueKeys = fieldKeys.concat(geoKeys);
  	var valuePaths = valueKeys;
  	var values = this._path.get(data);
  
  	if (!values) {
  		// Handle flattened values
  		valuePaths = valueKeys.map(function (i) {
  			return paths[i];
  		});
  		values = _.pick(data, valuePaths);
  	}
  
  	// convert valuePaths to a map for easier usage
  	valuePaths = _.zipObject(valueKeys, valuePaths);
  
  	var setValue = function (key) {
  		if (valuePaths[key] in values && values[valuePaths[key]] !== item.get(paths[key])) {
  			item.set(paths[key], values[valuePaths[key]] || null);
  		}
  	};
  
  	_.forEach(fieldKeys, setValue);
  
  	if (valuePaths.geo in values) {
  		var oldGeo = item.get(paths.geo) || [];
  		if (oldGeo.length > 1) {
  			oldGeo[0] = item.get(paths.geo)[1];
  			oldGeo[1] = item.get(paths.geo)[0];
  		}
  		var newGeo = values[valuePaths.geo];
  		if (!Array.isArray(newGeo) || newGeo.length !== 2) {
  			newGeo = [];
  		}
  		if (newGeo[0] !== oldGeo[0] || newGeo[1] !== oldGeo[1]) {
  			item.set(paths.geo, newGeo);
  		}
  	} else if (valuePaths.geo_lat in values && valuePaths.geo_lng in values) {
  		var lat = utils.number(values[valuePaths.geo_lat]);
  		var lng = utils.number(values[valuePaths.geo_lng]);
  		item.set(paths.geo, (lat && lng) ? [lng, lat] : undefined);
  	}
  
  	var doGoogleLookup = this.getValueFromData(data, '_improve');
  	if (doGoogleLookup) {
  		var googleUpdateMode = this.getValueFromData(data, '_improve_overwrite') ? 'overwrite' : true;
  		this.googleLookup(item, false, googleUpdateMode, function (err, location, result) {
  			// TODO: we are currently log the error but otherwise discard it; should probably be returned.. needs consideration
  			if (err) console.error(err);
  			callback();
  		});
  		return;
  	}
  
  	process.nextTick(callback);
  };
  
  /**
   * Internal Google geocode request method
   */
  function doGoogleGeocodeRequest (address, region, callback) {
  
  	// https://developers.google.com/maps/documentation/geocoding/
  	// Use of the Google Geocoding API is subject to a query limit of 2,500 geolocation requests per day, except with an enterprise license.
  	// Note: the Geocoding API may only be used in conjunction with a Google map; geocoding results without displaying them on a map is prohibited.
  	// Please make sure your Keystone app complies with the Google Maps API License.
  
  	var options = {
  		sensor: false,
  		language: 'en',
  		address: address,
  	};
  
  	if (arguments.length === 2 && typeof region === 'function') {
  		callback = region;
  		region = null;
  	}
  
  	if (region) {
  		options.region = region;
  	}
  
  	if (keystone.get('google server api key')) {
  		options.key = keystone.get('google server api key');
  	}
  
  	var endpoint = 'https://maps.googleapis.com/maps/api/geocode/json?' + querystring.stringify(options);
  
  	https.get(endpoint, function (res) {
  		var data = [];
  		res.on('data', function (chunk) {
  			data.push(chunk);
  		})
  		.on('end', function () {
  			var dataBuff = data.join('').trim();
  			var result;
  			try {
  				result = JSON.parse(dataBuff);
  			}
  			catch (exp) {
  				result = {
  					status_code: 500,
  					status_text: 'JSON Parse Failed',
  					status: 'UNKNOWN_ERROR',
  				};
  			}
  			callback(null, result);
  		});
  	})
  	.on('error', function (err) {
  		callback(err);
  	});
  }
  
  /**
   * Autodetect the full address and lat, lng from the stored value.
   *
   * Uses Google's Maps API and may only be used in conjunction with a Google map.
   * Geocoding results without displaying them on a map is prohibited.
   * Please make sure your Keystone app complies with the Google Maps API License.
   *
   * Internal status codes mimic the Google API status codes.
   */
  location.prototype.googleLookup = function (item, region, update, callback) {
  
  	if (typeof update === 'function') {
  		callback = update;
  		update = false;
  	}
  
  	var field = this;
  	var stored = item.get(this.path);
  	var address = item.get(this.paths.serialised);
  
  	if (address.length === 0) {
  		return callback({
  			status_code: 500,
  			status_text: 'No address to geocode',
  			status: 'NO_ADDRESS',
  		});
  	}
  
  	doGoogleGeocodeRequest(address, region || keystone.get('default region'), function (err, geocode) {
  
  		if (err || geocode.status !== 'OK') {
  			return callback(err || new Error(geocode.status + ': ' + geocode.error_message));
  		}
  
  		// use the first result
  		// if there were no results in the array, status would be ZERO_RESULTS
  		var result = geocode.results[0];
  
  		// parse the address components into a location object
  
  		var location = {};
  
  		_.forEach(result.address_components, function (val) {
  			if (_.indexOf(val.types, 'street_number') >= 0) {
  				location.street1 = location.street1 || [];
  				location.street1.unshift(val.long_name);
  			}
  			if (_.indexOf(val.types, 'route') >= 0) {
  				location.street1 = location.street1 || [];
  				location.street1.push(val.short_name);
  			}
  			// in some cases, you get suburb, city as locality - so only use the first
  			if (_.indexOf(val.types, 'locality') >= 0 && !location.suburb) {
  				location.suburb = val.long_name;
  			}
  			if (_.indexOf(val.types, 'administrative_area_level_1') >= 0) {
  				location.state = val.short_name;
  			}
  			if (_.indexOf(val.types, 'country') >= 0) {
  				location.country = val.long_name;
  			}
  			if (_.indexOf(val.types, 'postal_code') >= 0) {
  				location.postcode = val.short_name;
  			}
  
  			// These address_components could arguable all map to our 'number' field
  			// .. https://developers.google.com/maps/documentation/geocoding/intro#GeocodingResponses
  
  			// `subpremise` - "Indicates a first-order entity below a named location, usually a singular building within a collection of buildings with a common name"
  			// In practice this is often the unit/apartment number or level and is not always included
  			if (_.indexOf(val.types, 'subpremise') >= 0) {
  				location.number = val.short_name;
  			}
  
  			// These are all optional (rarely used?) and probably shouldn't replace the number if already set (due to subpremise)
  			// `floor` - Indicates the floor of a building address.
  			// `post_box` - Indicates a specific postal box.
  			// `room` - Indicates the room of a building address.
  			if (_.indexOf(val.types, 'floor') >= 0 || _.indexOf(val.types, 'post_box') >= 0 || _.indexOf(val.types, 'room') >= 0) {
  				location.number = location.number || val.short_name;
  			}
  		});
  
  		if (Array.isArray(location.street1)) {
  			location.street1 = location.street1.join(' ');
  		}
  
  		location.geo = [
  			result.geometry.location.lng,
  			result.geometry.location.lat,
  		];
  
  		// console.log('------ Google Geocode Results ------');
  		// console.log(address);
  		// console.log(result);
  		// console.log(location);
  
  		if (update === 'overwrite') {
  			item.set(field.path, location);
  		} else if (update) {
  			_.forEach(location, function (value, key) {
  				if (key === 'geo') {
  					return;
  				}
  				if (!stored[key]) {
  					item.set(field.paths[key], value);
  				}
  			});
  			if (!Array.isArray(stored.geo) || !stored.geo[0] || !stored.geo[1]) {
  				item.set(field.paths.geo, location.geo);
  			}
  		}
  
  		callback(null, location, result);
  
  	});
  };
  
  /**
   * Internal Distance calculation function
   *
   * See http://en.wikipedia.org/wiki/Haversine_formula
   */
  function calculateDistance (point1, point2) {
  	var dLng = (point2[0] - point1[0]) * Math.PI / 180;
  	var dLat = (point2[1] - point1[1]) * Math.PI / 180;
  	var lat1 = (point1[1]) * Math.PI / 180;
  	var lat2 = (point2[1]) * Math.PI / 180;
  	/* eslint-disable space-infix-ops */
  	var a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.sin(dLng/2) * Math.sin(dLng/2) * Math.cos(lat1) * Math.cos(lat2);
  	var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
  	/* eslint-enable space-infix-ops */
  	return c;
  }
  
  /**
   * Returns the distance from a [lng, lat] point in kilometres
   */
  location.prototype.kmFrom = function (item, point) {
  	return calculateDistance(item.get(this.paths.geo), point) * RADIUS_KM;
  };
  
  /**
   * Returns the distance from a [lng, lat] point in miles
   */
  location.prototype.milesFrom = function (item, point) {
  	return calculateDistance(item.get(this.paths.geo), point) * RADIUS_MILES;
  };
  
  /* Export Field Type */
  module.exports = location;
  