/* The Agentmap class, which turns a Leaflet map into a simulation platform. */ 

let lineSlice = require('@turf/line-slice').default,
length = require('@turf/length').default;

/**
 * The main class for building, storing, simulating, and manipulating agent-based models on Leaflet maps. 
 *
 * @class Agentmap
 * @param {object} map - A Leaflet Map instance. 
 * @param {number} [animation_interval=1] - The number of steps agents must move before being redrawn.  Given 1, they will be redrawn after every step.  Given 0, the animation will not update at all.  1 by default.  Must be a nonnegative integer. 
 * @property {object} map - A Leaflet Map instance. 
 * @property {FeatureGroup} agents - A featureGroup containing all agents. 
 * @property {FeatureGroup} units - A featureGroup containing all units. 
 * @property {FeatureGroup} streets - A featureGroup containing all streets. 
 * @property {object} state - Properties detailing the state of the simulation process. 
 * @property {boolean} state.running - Whether the simulation is running or not. 
 * @property {boolean} state.paused - Whether the simulation is paused. 
 * @property {? number} state.animation_frame_id - The id of the agentmap's update function in the queue of functions to call for the coming animation frame. 
 * @property {? number} state.ticks - The number of ticks elapsed since the start of the simulation. 
 * @property {number} animation_interval - The number of steps agents must move before being redrawn.  Given 1, they will be redrawn after every step.  Given 0, the animation will not update at all.  1 by default.  Will be a nonnegative integer. 
 * @property {? function} controller - User-defined function to be called on each update. 
 */
Agentmap = function(map, animation_interval = 1) {
	Agentmap.checkAnimIntervalOption(animation_interval);

	this.map = map,
	this.units = null,
	this.streets = null,
	this.agents = null, 
	this.pathfinder = null,
	this.state = {
		running: false,
		paused: false,
		animation_frame_id: null,
		ticks: null,
	},
	this.controller = function() {},
	this.animation_interval = animation_interval
};

/**
 * Change the animation interval of the simulation & redraw the agents. 
 *
 * @param {number} animation_interval - The desired animation interval to give the simulation.  Must be a nonnegative integer. 
 */
Agentmap.prototype.setAnimationInterval = function(animation_interval) {
	Agentmap.checkAnimIntervalOption(animation_interval);

	this.animation_interval = animation_interval;

	this.agents.eachLayer(agent => agent.setLatLng(agent._latlng));
}

/**
 * Check whether the animation interval option provided is valid. 
 * @private
 *
 * @param {number} animation_interval - An input specifying an animation interval distance. 
 */
Agentmap.checkAnimIntervalOption = function(animation_interval) {
	if (! Number.isInteger(animation_interval) && animation_interval >= 0) {
		throw new Error("The animation_interval must be a non-negative integer!"); 
	}
}

/**
 * Get an animation frame, have the agents update & get ready to be drawn, and keep doing that until paused or reset. 
 */
Agentmap.prototype.run = function() {
	if (this.state.running === false) {
		this.state.running = true;

		let animation_update = (function (rAF_time) {
			
			if (this.state.paused === true) {
				this.state.paused = false;
			}
			this.state.animation_frame_id = L.Util.requestAnimFrame(animation_update);
			this.update();
		}).bind(this);

		this.state.animation_frame_id = L.Util.requestAnimFrame(animation_update);
	}
}

/**
 * Update the simulation at the given time. 
 * @private
 */
Agentmap.prototype.update = function() {
	if (this.state.ticks === null) {
		this.state.ticks = 0;
	}

	//Execute user-provided per-tick instructions for the agentmap. 
	this.controller();

	//Execute user-provided per-tick instructions for each agent. 
	this.agents.eachLayer(function(agent) {
		agent.controller();
	});
	
	this.state.ticks += 1;
};

/**
* Stop the animation, reset the animation state properties, and delete the features. 
*/
Agentmap.prototype.clear = function() {
	L.Util.cancelAnimFrame(this.state.animation_frame_id);
	this.state.running = false,
	this.state.paused = false,
	this.state.animation_frame_id = null,
	this.state.ticks = null,
	
	this.agents.clearLayers();
	this.streets.clearLayers();
	this.units.clearLayers();
};

/** 
 * Stop the animation, stop updating the agents. 
 */
Agentmap.prototype.pause = function() {
	L.Util.cancelAnimFrame(this.state.animation_frame_id);
	this.state.running = false,
	this.state.paused = true;
};

/**
 * Get a point through which an agent can exit/enter a unit. 
 *
 * @param {number} unit_id - The unique ID of the unit whose door you want. 
 * @returns {LatLng} - The coordinates of the center point of the segment of the unit parallel to the street. 
 */
Agentmap.prototype.getUnitDoor = function(unit_id) {
	let unit = this.units.getLayer(unit_id);
	
	if (typeof(unit) === "undefined") {
		throw new Error("No unit with the specified ID exists."); 
	}
	
	let unit_spec = unit.getLatLngs()[0],
	corner_a = unit_spec[0],
	corner_b = unit_spec[1],
	door = 	L.latLngBounds(corner_a, corner_b).getCenter();
	
	return door;
};

/**
 * Get the point on the adjacent street in front of the unit's door. 
 *
 * @param {number} unit_id - The unique ID of the unit whose door's corresponding point on the street you want. 
 * @returns {LatLng} - The coordinates point of the adjacent street directly in front of unit's door. 
 */
Agentmap.prototype.getStreetNearDoor = function(unit_id) {
	let unit = this.units.getLayer(unit_id);
	
	if (typeof(unit) === "undefined") {
		throw new Error("No unit with the specified ID exists."); 
	}
	
	let unit_anchors = L.A.reversedCoordinates(unit.street_anchors),
	street_point = L.latLngBounds(...unit_anchors).getCenter();
	
	return street_point;
};

/**
 * Given a unit and a pair of coordinates between 0 and 1, return a corresponding point inside the unit, offset from its first corner along the street. 
 * 
 * @param {number} unit_id - The unique ID of the unit whose interior point you want. 
 * @param {number} x - A point between 0 and 1 representing a position along the width of a unit. 
 * @param {number} y - A point between 0 and 1 representing a position along the depth of a unit. 
 * @returns {LatLng} - The global coordinates of the specified position within the unit. 
 */
Agentmap.prototype.getUnitPoint = function(unit_id, x, y) {
	if (x < 0 || x > 1 || y < 0 || y > 1) {
		throw new Error("x and y must both be between 0 and 1!"); 
	}
	
	let unit = this.units.getLayer(unit_id),
	unit_corners = unit.getLatLngs()[0],
	front_right = unit_corners[0],
	front_left = unit_corners[1],
	back_right = unit_corners[3],
	front_length = front_left.lng - front_right.lng,
	side_length = back_right.lng - front_right.lng,
	front_slope = (front_right.lat - front_left.lat) / (front_right.lng - front_left.lng),
	side_slope = (front_right.lat - back_right.lat) / (front_right.lng - back_right.lng);
	
	//Get the coordinate of the position along the front (x) axis. 
	let lng_along_front = front_right.lng + front_length * x,
	lat_along_front = front_right.lat + (front_length * x) * front_slope,
	point_along_front = L.latLng(lat_along_front, lng_along_front);
	
	//From the position on the front axis, get the coordinate of a position along a line perpendicular to the front and 
	//parallel to the side (y) axis. 
	let lng_along_side = point_along_front.lng + side_length * y,
	lat_along_side = point_along_front.lat + (side_length * y) * side_slope,
	point_in_depth = L.latLng(lat_along_side, lng_along_side);

	return point_in_depth;
}

/**
 * Given a point on a street, find the nearest intersection on that street (with any other street). 
 * 
 * @param {LatLng} lat_lng - The coordinates of the point on the street to search from. 
 * @param {Place} place - A place object corresponding to the street. 
 * @returns {LatLng} - The coordinates of the nearest intersection. 
 */
Agentmap.prototype.getNearestIntersection = function(lat_lng, place) {
	let street_id,
	street_feature;

	if (place.type === "street") {
		street_id = place.id;
	}
	else {
		throw new Error("place must be a street!"); 
	}

	street_feature = this.streets.getLayer(street_id).toGeoJSON();
		
	let intersections = this.streets.getLayer(street_id).intersections,
	intersection_points = [],
	intersection_distances = [];

	for (let intersection in intersections) { 
		for (let cross_point of intersections[intersection]) {
			let intersection_point = cross_point[0],
			distance = lat_lng.distanceTo(intersection_point);

			/* More precise, but slower, distance detection -- not necessary yet.  
			 	let start_coords = L.A.pointToCoordinateArray(lat_lng);
				intersection_coords = L.A.pointToCoordinateArray(intersection_point),
				segment = lineSlice(start_coords, intersection_coords, street_feature),
				distance = length(segment); 
			*/
			
			intersection_points.push(intersection_point);
			intersection_distances.push(distance);
		}
	}
	
	let smallest_distance = Math.min(...intersection_distances),
	smallest_distance_index = intersection_distances.indexOf(smallest_distance),
	closest_intersection_point = L.latLng(intersection_points[smallest_distance_index]);
	
	return closest_intersection_point;
}

/**
 * Since units may take a noticeably long time to generate while typically staying the same over simulations,
 * downloadUnits makes it easy to get a JS file containing the units object, so it can be included with an
 * AgentMaps app and imported into Agentmap.buildingify so they will not need to be regenerated. 
 */
Agentmap.prototype.downloadUnits = function() {
	let file_content = "var units_data = ",
	units_json = this.units.toGeoJSON(20);
	file_content += JSON.stringify(units_json),
	file = new Blob([file_content]);

	var element = document.createElement("a");
	element.setAttribute("href", URL.createObjectURL(file)),
	element.setAttribute("download", "units_data.js"),
	element.style.display = "none";
	document.body.appendChild(element);
	
	element.click();
	
	document.body.removeChild(element);
}

/**
 * Since street layers may take a noticeably long time to generate while typically staying the same over simulations,
 * downloadStreets makes it easy to get a JS file containing the streets object, so it can be included with an
 * AgentMaps app and imported into Agentmap.buildingify so they will not need to be regenerated. 
 */
Agentmap.prototype.downloadStreets = function() {
	let file_content = "var streets_data = ",
	streets_json = this.streets.toGeoJSON(20);
	file_content += JSON.stringify(streets_json),
	file = new Blob([file_content]);

	var element = document.createElement("a");
	element.setAttribute("href", URL.createObjectURL(file)),
	element.setAttribute("download", "streets_data.js"),
	element.style.display = "none";
	document.body.appendChild(element);
	
	element.click();
	
	document.body.removeChild(element);
}

/**
 * Generates an agentmap for the given map. 
 *
 * @name agentmap
 * @param {object} map - A Leaflet Map instance. 
 * @returns {object} - An Agentmap instance. 
 */
function agentmapFactory(map) {
	return new Agentmap(map);
}

/**
 * Returns the number of layers in a Leaflet layer group. 
 *
 * @memberof L.LayerGroup
 */
function layerCount() {
	return this.getLayers().length;
}

L.LayerGroup.include({count: layerCount});

exports. Agentmap = Agentmap,
exports.agentmap = agentmapFactory;
