'use strict';

var request = require('superagent'),
	step = require('step'),
	util = require('util'),
	cypher = require('./utils/cypher'),
	Validator = require('./utils/validator'),
	parser = require('./utils/parser');

module.exports = Neo4j;

function Neo4j(url, token){
	if(typeof url !== 'undefined' && url !== ''){
		this.url = url.slice(-1) !== '/' ? url : url.slice(0, -1); // remove trailing forward slash if present
	} else {
		this.url = 'http://localhost:7474';
	}

	if(token){
		this.header = {'Authorization': 'Basic realm="Neo4j" ' + token};
	} else {
		this.header = {};
	}
}

/*	Insert a Node
	Returns the node that is inserted and his node id (property: _id)
	Examples:
	Insert a Node with no label:
		insertNode({ name: 'Kristof' }, callback);
	Insert a Node with one label:
		insertNode({ name: 'Kristof' }, ['Student'], callback);
		insertNode({ name: 'Kristof' }, 'Student', callback);
		returns { _id: 14, name: 'Kristof' }
	Insert a Node with three labels:
		insertNode({ name:'Darth Vader', level: 99, hobbies: ['lightsaber fighting', 'cycling in space'], shipIds: [123, 321] }, ['User', 'Evil' ,'Man'], callback);
		returns { _id: 17, name:'Darth Vader', level: 99, hobbies: ['lightsaber fighting', 'cycling in space'], shipIds: [123, 321] }	*/

Neo4j.prototype.insertNode = function(node, labels, callback){
	var that = this;
	// Insert node without a label with post request
	if(typeof callback === 'undefined') {
		callback = labels;
		request
			.post(this.url + '/db/data/node')
			.set(this.header)
			.send(node)
			.end(function(result){
				if(result.body && result.body.data) {
					that.addNodeId(result.body, callback);
				} else {
					callback(new Error('Response body is empty'), null);
				}
			});
	} else {
		var val = new Validator();

		if(val.labels(labels).hasErrors) {
			return callback(val.error(), null);
		}

		// Flexibility: make array of single string
		if(typeof labels === 'string') {
			labels = [labels];
		}

		// Insert node and label(s) with cypher query
		if(labels instanceof Array){
			var query = 'CREATE (data'+  cypher.labels(labels) + ' {params}) RETURN data';
			this.cypherQuery(query, { params: node }, function(err, res) {
				if(err) {
					callback(err, null);
				}	else {
					callback(err, res.data[0]);
				}
			});
		} else {
			callback(new Error('The second parameter "labels" should be an array with strings OR "labels" should be a callback function.'), null);
		}
	}
};

/*	Get an array of labels of a Node
	Example:
	Get all labels of node 77:
		readLabels(77, callback);
		returns ['User','Student','Man']
*/

Neo4j.prototype.readLabels = function(node_id, callback){
	request
		.get(this.url + '/db/data/node/' + node_id + '/labels')
		.set(this.header)
		.end(function(result){
			if(result.body) {
				callback(null, result.body);
			} else {
				callback(new Error('Response is empty'), null);
			}
		});
};

/* Delete a Node --------- */
// Nodes with Relationships cannot be deleted -> deliver proper error message

Neo4j.prototype.deleteNode = function(node_id, callback){
	request
		.del(this.url + '/db/data/node/' + node_id)
		.set(this.header)
		.end(function(result){
			switch(result.statusCode){
				case 204:
					callback(null, true); // Node was deleted.
					break;
				case 404:
					callback(null, false); // Node doesn't exist.
					break;
				case 409:
					callback(null, false); // Node has Relationships and cannot be deleted.
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' occurred while deleting a node.'), null);
			}
		});
};

// Delete all nodes with `labels` and `properties`.
// * `labels`          String|Array[String]    e.g.: '', [], 'User', ['User', 'Student']
// * 'properties'      Object                  e.g.: { userid: '124' }
// Returns the number of deleted nodes.

// Examples:
//   deleteNodesWithLabelsAndProperties('User',{ firstname: 'Sam', male: true }, callback);
//   deleteNodesWithLabelsAndProperties(['User','Admin'], { 'name': 'Sam'}, callback);

Neo4j.prototype.deleteNodesWithLabelsAndProperties = function (labels, properties, callback){
  var where = cypher.where('data', properties);
  var query = 'MATCH (data'+  cypher.labels(labels) + ')';

  if (where !== '') {
    query += ' WHERE ' + where;
  }

  query += ' DELETE data RETURN count(data)';

  this.cypherQuery(query, properties, function (err, res) {
		if (err) {
			callback(err);
		} else {
			callback(null, res.data[0]);
		}
  });
};

/*  Read a Node ---------- */

Neo4j.prototype.readNode = function(node_id, callback) {
	var that = this;
	request
		.get(this.url + '/db/data/node/' + node_id)
		.set(this.header)
		.end(function(result) {
			switch(result.statusCode) {
				case 200:
					that.addNodeId(result.body, callback); // Node found.
					break;
				case 404:
					callback(null, false); // Node doesn't exist.
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' occurred while reading a node.'), null);
			}
		});
};

/*	Replace a Node's properties
    This will replace all existing properties on the node with the new set of attributes. */

var replaceNodeById = function(node_id, node_data, callback) {
  var that = this;
  request
    .put(that.url + '/db/data/node/' + node_id + '/properties')
	.set(this.header)
    .send(that.stringifyValueObjects(that.replaceNullWithString(node_data)))
		.end(function(result) {
      switch(result.statusCode) {
        case 204:
          callback(null, true);
          break;
        case 404:
          callback(null, false);
          break;
        default:
          callback(new Error('HTTP Error ' + result.statusCode + ' when updating a Node.'), null);
      }
    });
};
// Create an alias
Neo4j.prototype.replaceNodeById = replaceNodeById;
Neo4j.prototype.updateNode = replaceNodeById;

/*  Update a Node properties
    This will update all existing properties on the node with the new set of attributes. */

Neo4j.prototype.updateNodeById = function(node_id, node_data, callback) {
  var query = 'START data=node({_id}) SET ' + cypher.set('data', node_data) + ' RETURN data';
  node_data._id = node_id;
  this.cypherQuery(query , node_data, function(err, res) {
		if (err) {
			callback(err);
		} else {
			callback(null, res.data[0]);
		}
  });
};

// Update all nodes with `labels` and `oldProperties`, set the `newProperties` and remove `removeProperties`.
// Return nothing if `returnUpdatedNodes` is `false`. Default will return all updated nodes.

// * `labels`              String|Array[String]    e.g.: '' or [] or 'User' or ['User', 'Student']
// * 'oldProperties'       Object                  e.g.: { userid: '124' }
// * `newProperties`       Object                  e.g.: { email: 'fred@example.com' }
// * `removeProperties`    Object                  e.g.: ['old_email', 'old_address'] (Optional)
// * `returnUpdatedNodes`  Boolean                 e.g.: `false` (Optional, default: `true`)

Neo4j.prototype.updateNodesWithLabelsAndProperties = function (labels, oldProperties, newProperties, removeProperties, returnUpdatedNodes, callback) {
  var whereSetProperties = cypher.whereSetProperties('data', oldProperties, newProperties);
  var where = whereSetProperties.where;
  var query = 'MATCH (data'+  cypher.labels(labels) + ')';
  var remove;

  if (typeof removeProperties === 'function') {
    callback = removeProperties;
    returnUpdatedNodes = true;
  } else {
    remove = cypher.remove('data', removeProperties);
    if (typeof returnUpdatedNodes === 'function') {
      callback = returnUpdatedNodes;
      returnUpdatedNodes = true;
    }
  }

  if (where !== '') {
    query += ' WHERE ' + where;
  }

  query += ' SET ' + whereSetProperties.set;

  if (remove && remove !== '') {
    query += ' REMOVE ' + remove;
  }

  if (returnUpdatedNodes) {
    query += ' RETURN data';
  }

  this.cypherQuery(query , whereSetProperties.properties, function(err, res) {
		if (err) {
			callback(err);
		} else {
			callback(null, res.data);
		}
  });
};

/* Insert a Relationship ------ */

Neo4j.prototype.insertRelationship = function(root_node_id, other_node_id, relationship_type, relationship_data, callback) {
	var that = this;
	request
		.post(that.url + '/db/data/node/' + root_node_id + '/relationships')
		.set(this.header)
		.send({
			to: that.url + '/db/data/node/' + other_node_id,
			type: relationship_type,
			data: that.stringifyValueObjects(that.replaceNullWithString(relationship_data))
		})
		.end(function(result) {
			switch(result.statusCode) {
				case 201:
					that.addRelationshipId(result.body, callback);
					break;
				case 400: // Endnode not found exception
					callback(null, false);
					break;
				case 404: // Startnode not found exception
					callback(null, false);
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when inserting a Relationship.'), null);
			}
		});
};

/* Delete a Relationship --------- */

Neo4j.prototype.deleteRelationship = function(relationship_id, callback) {
	var that = this;
	request
		.del(that.url + '/db/data/relationship/' + relationship_id)
		.set(this.header)
		.end(function(result) {
			switch(result.statusCode) {
				case 204:
					callback(null, true);
					break;
				case 404: // Relationship with that id doesn't exist.
					callback(null, false);
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when deleting a Relationship.'), null);
			}
		});
};

/* Read a Relationship ----------- */

Neo4j.prototype.readRelationship = function(relationship_id, callback) {
	var that = this;

	request
		.get(that.url + '/db/data/relationship/' + relationship_id)
		.set(this.header)
		.end(function(result) {
			switch(result.statusCode) {
				case 200:
					that.addRelationshipId(result.body, callback);
					break;
				case 404:
					callback(null, false);
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when reading a Relationship'), null);
			}
		});
};

/* Update a Relationship -------- */

Neo4j.prototype.updateRelationship = function(relationship_id, relationship_data, callback) {
	var that = this;

	request
		.put(that.url + '/db/data/relationship/' + relationship_id + '/properties')
		.set(this.header)
		.send(that.stringifyValueObjects(that.replaceNullWithString(relationship_data)))
		.end(function(result) {
			switch(result.statusCode) {
				case 204:
					callback(null, true);
					break;
				case 404:
					callback(null, false);
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when updating a Relationship.'), null);
			}
		});
};

/* Create an Index ---------- */

Neo4j.prototype.insertIndex = function(index, callback) {
	var that = this;

	request
		.post(that.url + '/db/data/index/' + index.type + '/')
		.set(this.header)
		.send({
			'name': index.index,
			'config': index.config
		})
		.end(function(result) {
			switch(result.statusCode) {
				case 201:
					callback(null, result.body);
					break;
				case 404:
					callback(null, false);
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when inserting an Index.'), null);
			}
		});
};

/*	Create an index on a property of a label
	Example:
	Create an index on the first name of a person.
		insertLabelIndex('Person', 'firstname', callback);
		returns {
				  'label' : 'Person',
				  'property-keys' : [ 'firstname' ]
				}
	Note:
	Compound indexes are not yet supported, only one property per index is allowed.
	So ['firstname', 'lastname'] is not supported yet. */

Neo4j.prototype.insertLabelIndex = function(label, property_key, callback) {
	request
			.post(this.url + '/db/data/schema/index/' + label)
			.set(this.header)
			.send({ 'property_keys' : [property_key] })
			.end(function(result){
				if(result.body) {
					callback(null, result.body);
				} else {
					callback(new Error('Response is empty'), null);
				}
	});
};

Neo4j.prototype.insertNodeIndex = function(index, callback) {
	var _index = index;
	if(typeof index === 'string') {
		_index = {
			type: 'node',
			index: index
		};
	}
	this.insertIndex(_index, callback);
};

Neo4j.prototype.insertRelationshipIndex = function(index, callback) {
	var _index = index;
	if(typeof index === 'string') {
		_index = {
			type: 'relationship',
			index: index
		};
	}
	this.insertIndex(_index, callback);
};

/* Delete an Index ---------- */

Neo4j.prototype.deleteIndex = function(index, callback) {
	request
		.del(this.url + '/db/data/index/' + index.type + '/' + index.index)
		.set(this.header)
		.end(function(result) {
			switch(result.statusCode) {
				case 204:
					callback(null, true); // Index was deleted.
					break;
				case 404:
					callback(null, false); // Index doesn't exist.
					break;
				default:
					callback(new Error('Unknown Error while deleting Index'), null);
			}
	});
};

Neo4j.prototype.deleteNodeIndex = function(index, callback) {
	this.deleteIndex({type: 'node', index: index}, callback);
};

Neo4j.prototype.deleteRelationshipIndex = function(index, callback) {
	this.deleteIndex({type: 'relationship', index: index}, callback);
};

Neo4j.prototype.deleteLabelIndex = function(label, property_key, callback) {
	request
	.del(this.url + '/db/data/schema/index/' + label + '/' + property_key)
	.set(this.header)
	.end(function(result) {
		switch(result.statusCode) {
			case 204:
				callback(null, true); // Index was deleted.
				break;
			case 404:
				callback(null, false); // Index doesn't exist.
				break;
			default:
				callback(new Error('Unknown Error while deleting Index'), null);
		}
	});
};

function listIndexes (url, callback, header) {
	request
	.get(url)
	.set(header)
	.end(function(result) {
		switch(result.statusCode) {
			case 200:
			case 204:
				callback(null, result.body);
				break;
			case 404:
				callback(null, false);
				break;
			default:
				callback(new Error('HTTP Error ' + result.statusCode + ' when listing all indexes.'), null);
		}
	});
}

Neo4j.prototype.listIndexes = function(indexType, callback) {
	var url = this.url + '/db/data/index/' + indexType;
	listIndexes(url, callback, this.header);
};

Neo4j.prototype.listNodeIndexes = function(callback) {
	var url = this.url + '/db/data/index/node';
	listIndexes(url, callback, this.header);
};

Neo4j.prototype.listRelationshipIndexes = function(callback) {
	var url = this.url + '/db/data/index/relationship';
	listIndexes(url, callback, this.header);
};

/*	List indexes for a label
	Example:
	listLabelIndexes('City', callback);
	returns [ { label: 'City', 'property-keys': [ 'postalcode' ] },
  			  { label: 'City', 'property-keys': [ 'name' ] } ]		*/

Neo4j.prototype.listLabelIndexes = function(label, callback) {
	var url = this.url + '/db/data/schema/index/' + label;
	listIndexes(url, callback, this.header);
};

/* Add item to Index ---------- */

Neo4j.prototype.addItemToIndex = function (args, callback) {
	var that = this;

	request
		.post(that.url + '/db/data/index/' + args.indexType + '/' + args.indexName)
		.set(this.header)
		.send({
			'uri': that.url + '/db/data/' + args.indexType + '/' + args.itemId,
			'key': args.indexKey,
			'value': args.indexValue
		})
		.end(function(result) {
			switch(result.statusCode) {
				case 200:
					that.addNodeId(result.body, callback);
					break;
				case 201:
					that.addNodeId(result.body, callback);
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when adding an Item to an Index'), null);
			}
		});
};

Neo4j.prototype.addNodeToIndex = function(nodeId, indexName, indexKey, indexValue, callback) {
	this.addItemToIndex({
		indexType: 'node',
		itemId: nodeId,
		indexName: indexName,
		indexKey: indexKey,
		indexValue: indexValue
	}, callback);
};

Neo4j.prototype.addRelationshipToIndex = function(nodeId, indexName, indexKey, indexValue, callback) {
	this.addItemToIndex({
		indexType: 'relationship',
		itemId: nodeId,
		indexName: indexName,
		indexKey: indexKey,
		indexValue: indexValue
	}, callback);
};

/*	Adding one or multiple labels to a node.
	Given a node id (integer) and one label (string) or multiple labels (array of strings) (non-empty strings)
	returns true if successfully added a label otherwise it will return false.
	Examples:
	addLabelsToNode(77, 'User', callback);
	addLabelsToNode(77, ['User', 'Student'], callback);
		returns true
	addLabelsToNode(77, ['User', ''], callback);
		returns an error! no empty string allowed	*/

Neo4j.prototype.addLabelsToNode = function(nodeId, labels, callback) {
	var url = this.url + '/db/data/node/' + nodeId + '/labels';
	var errorMsg = '"Labels" should be a non-empty string or an array of non-empty strings.';

	if (typeof labels === 'string') {
		if (labels === '') {
			return callback(new Error(errorMsg), null);
		}
		labels = [labels];
	}

	if (labels instanceof Array) {
		request
			.post(url)
		    .set(this.header)
			.send(labels)
			.end(function(result) {
				switch(result.statusCode) {
					case 204:
						callback(null, true); // Labels added
						break;
					case 400:
						callback(new Error(errorMsg), null); // Empty label
						break;
					case 404:
						callback(null, false); // Node doesn't exist
						break;
					default:
						callback(new Error('HTTP Error ' + result.statusCode + ' when adding a label to a node.'), null);
				}
			});
	} else {
		callback(new Error(errorMsg), null);
	}
};

/*	Replacing labels on a node.
	This removes any labels currently on a node, and replaces them with the new labels.
	Given a node id (integer) and one label (string) or multiple labels (array of strings) (non-empty strings)
	returns true if successfully replaced all labels otherwise it will return false or an error.
	Examples:
	replaceLabelsFromNode(77, 'User', callback);
	replaceLabelsFromNode(77, ['User', 'Student'], callback);
		returns true
	replaceLabelsFromNode(77, ['User', ''], callback);
	replaceLabelsFromNode(77, null, callback);
		returns an error! no empty string allowed	*/

Neo4j.prototype.replaceLabelsFromNode = function(nodeId, labels, callback) {
	var errorMsg = '"Labels" should be a non-empty string or an array of non-empty strings.';

	if (typeof labels === 'string') {
		if(labels === '') {
			return callback(new Error(errorMsg), null);
		}
		labels = [labels];
	}

	if (labels instanceof Array) {
		request
			.put(this.url + '/db/data/node/' + nodeId + '/labels')
			.send(labels)
		    .set(this.header)
			.end(function(result) {
				switch(result.statusCode) {
					case 204:
						callback(null, true);
						break;
					case 400:
						callback(new Error(errorMsg), null); // Empty label
						break;
					case 404:
						callback(null, false);
						break;
					default:
						callback(new Error('HTTP Error ' + result.statusCode + ' when replacing labels.'), null);
			}
		});
	} else {
		callback(new Error(errorMsg), null);
	}
};

/*	Removing a label from a node
	Given a node id (positive integer) and one label (non-empty string)
	returns true if successfully removed the label otherwise it will return false (Node doesn't exist) or an error.
	Examples:
	deleteLabelFromNode(77, 'User', callback);
		returns true
	deleteLabelFromNode(77, ['Student'], callback);
	deleteLabelFromNode(77, '', callback);
		returns an error, label should be a non-empty string */

Neo4j.prototype.deleteLabelFromNode = function(nodeId, label, callback) {
	var val = new Validator();
	val.nodeId(nodeId).label(label);

	if (val.hasErrors) {
		return callback(val.error(), null);
	}

	request
		.del(this.url + '/db/data/node/' + nodeId + '/labels/'+ label)
		.set(this.header)
		.end(function(result) {
			switch(result.statusCode) {
				case 204:
					callback(null, true); // Label was deleted.
					break;
				case 404:
					callback(null, false); // Node doesn't exist.
					break;
				default:
					callback(new Error('Unknown Error while deleting Index'), null);
		}
	});
};

/*	Get all nodes with a label
	Given a label (non-empty string)
	returns an array of nodes with that label
	Examples:
	readNodesWithLabel('User', callback);
		returns an array with nodes with the label 'User'
	deleteLabelFromNode('DoesNotExist', callback);
		returns an empty array	 */

Neo4j.prototype.readNodesWithLabel = function(label, callback) {
	var that = this;
	var val = new Validator();

	if (val.label(label).hasErrors) {
		return callback(val.error(), null);
	}

	request
		.get(this.url + '/db/data/label/' + label + '/nodes')
		.set(this.header)
		.end(function(result) {
			var body = result.body;
			switch(result.statusCode) {
				case 200:
					if (body && body.length >= 1) {
						step(
							function addIds() {
								var group = this.group();
								body.forEach(function(node) {
									that.addNodeId(node, group());
								});
							},
							function sumUp(err, nodes) {
								if (err) {
									throw err;
								}
								callback(null, nodes);
							});
					} else {
						callback(null, body);
					}
					break;
				case 404:
					callback(null, false);
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when reading a Relationship'), null);
			}
		});
};

/*	Get all nodes with labels and properties
	Given one label (non-empty string) or multiple labels (array of strings) and one or more properties in json
	returns an array of nodes with these labels and properties
	Examples:
	readNodesWithLabelsAndProperties('User',{ firstname: 'Sam', male: true }, callback);
		returns an array with nodes with the label 'User' and properties firstname='Sam' and male=true
	readNodesWithLabelsAndProperties(['User','Admin'], { 'name': 'DoesNotExist'}, callback);
		returns an empty array	 		*/

Neo4j.prototype.readNodesWithLabelsAndProperties = function(labels, properties, callback) {
	var that = this;
	var val = new Validator();
	val.labels(labels).properties(properties);

	if (val.hasErrors) {
		return callback(val.error(), null);
	}

	// Only one label and one property provided
	if (typeof labels === 'string' && Object.keys(properties).length === 1) {
		var props = cypher.jsonToURL(properties);
		request
			.get(this.url + '/db/data/label/' + labels + '/nodes?' + props)
			.set(this.header)
			.end(function(result) {
				var body = result.body;
				switch(result.statusCode) {
					case 200:
						if (body && body.length >= 1) {
							step(
								function addIds(){
									var group = this.group();
									body.forEach(function(node){
										that.addNodeId(node, group());
									});
								},
								function sumUp(err, nodes){
									if(err) {
										throw err;
									}
									callback(null, nodes);
								});
						} else {
							callback(null, body);
						}
						break;
					case 404:
						callback(null, false);
						break;
					default:
						callback(new Error('HTTP Error ' + result.statusCode + ' when reading Nodes.'), null);
				}
			});
		} else { // Multiple labels or properties provided
			var query = 'MATCH (data'+  cypher.labels(labels) + ') WHERE ' + cypher.where('data', properties) + ' RETURN data';
			this.cypherQuery(query, properties, function(err, res) {
				if (err) {
					callback(err, null);
				} else {
					callback(err, res.data);
				}
			});
		}
};

/*	List all labels.
	Example:
	listAllLabels(callback);
		returns [ 'User', 'Person', 'Male', 'Animal' ] */

Neo4j.prototype.listAllLabels = function(callback) {
	request
	.get(this.url + '/db/data/labels')
	.set(this.header)
	.end(function(result) {
		switch(result.statusCode) {
			case 200:
				callback(null, result.body);
				break;
			case 404:
				callback(null, false);
				break;
			default:
				callback(new Error('HTTP Error ' + result.statusCode + ' when listing all labels.'), null);
		}
	});
};

/* CONSTRAINTS */

/*	Create a uniqueness constraint on a property.
	Example:
		createUniquenessConstraint('User','email', callback);
		returns 	{
					  'label' : 'User',
					  'type' : 'UNIQUENESS',
					  'property-keys' : [ 'email' ]
					}			*/

var createUniquenessConstraint = function(label, property_key, callback) {
	var that = this;
	var val = new Validator();
	val.label(label).property(property_key);

	if (val.hasErrors) {
		return callback(val.error(), null);
	}

	request
		.post(that.url + '/db/data/schema/constraint/' + label + '/uniqueness')
		.set(this.header)
		.send({ 'property_keys' : [property_key] })
		.end(function(result) {
			switch(result.statusCode) {
				case 200:
					callback(null, result.body);
					break;
				case 409:
					callback(null, false); // Constraint already exists
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when creating a uniqueness contraint.'), null);
			}
		});
};
Neo4j.prototype.createUniquenessConstraint = createUniquenessConstraint;
Neo4j.prototype.createUniquenessContstraint = createUniquenessConstraint;

/*	Get a specific uniqueness constraint for a label and a property
	Example:
		readUniquenessConstraint('User','email', callback);
		returns [ {
				  'label' : 'User',
				  'property-keys' : [ 'email' ],
				  'type' : 'UNIQUENESS'
				} ]						 		*/

Neo4j.prototype.readUniquenessConstraint = function(label, property, callback) {
	var val = new Validator();
	val.label(label).property(property);

	if (val.hasErrors) {
		return callback();
	}

	request
		.get(this.url + '/db/data/schema/constraint/' + label + '/uniqueness/' + property)
		.set(this.header)
		.end(function(result) {
			switch(result.statusCode) {
				case 200:
					callback(null, result.body);
					break;
				case 404:
					callback(null, false);
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when reading uniqueness constraints'), null);
			}
	});
};

/*	Get all uniqueness constraints for a label.
	Example:
		listAllUniquenessConstraintsForLabel('User', callback);
		returns [ {
				  'label' : 'User',
				  'property-keys' : [ 'uid' ],
				  'type' : 'UNIQUENESS'
				}, {
				  'label' : 'User',
				  'property-keys' : [ 'email' ],
				  'type' : 'UNIQUENESS'
				} ]						 		*/

Neo4j.prototype.listAllUniquenessConstraintsForLabel = function(label, callback) {
	var val = new Validator();
	val.label(label);
	if (val.hasErrors) {
		return callback();
	}

	request
		.get(this.url + '/db/data/schema/constraint/' + label + '/uniqueness')
		.set(this.header)
		.end(function(result) {
			switch(result.statusCode) {
				case 200:
					callback(null, result.body);
					break;
				case 404:
					callback(null, false);
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when listing all uniqueness constraints.'), null);
			}
	});
};

/*	Get all constraints for a label.
	Example:
		listAllConstraintsForLabel('User', callback);
		returns [ {
				  'label' : 'User',
				  'property-keys' : [ 'uid' ],
				  'type' : 'UNIQUENESS'
				}, {
				  'label' : 'User',
				  'property-keys' : [ 'email' ],
				  'type' : 'UNIQUENESS'
				} ]						*/

Neo4j.prototype.listAllConstraintsForLabel = function(label, callback) {
	var val = new Validator();
	val.label(label);
	if (val.hasErrors) {
		return callback();
	}

	request
	.get(this.url + '/db/data/schema/constraint/' + label)
	.set(this.header)
	.end(function(result) {
		switch(result.statusCode) {
			case 200:
				callback(null, result.body);
				break;
			case 404:
				callback(null, false);
				break;
			default:
				callback(new Error('HTTP Error ' + result.statusCode + ' when listing all constraints.'), null);
		}
	});
};

/*	Get all constraints.
	Example:
		listAllConstraints(callback);
		returns [ {
				  'label' : 'Product',
				  'property-keys' : [ 'pid' ],
				  'type' : 'UNIQUENESS'
				}, {
				  'label' : 'User',
				  'property-keys' : [ 'email' ],
				  'type' : 'UNIQUENESS'
				} ]								*/

Neo4j.prototype.listAllConstraints = function(callback) {
	request
	.get(this.url + '/db/data/schema/constraint')
	.set(this.header)
	.end(function(result) {
		switch(result.statusCode) {
			case 200:
				callback(null, result.body);
				break;
			case 404:
				callback(null, false);
				break;
			default:
				callback(new Error('HTTP Error ' + result.statusCode + ' when listing all constraints.'), null);
		}
	});
};

/*	Drop uniqueness constraint for a label and a property.
	Returns true if constraint was successfully removed.
	Returns false if the constraint was not found.
	Example:
		dropContstraint('User','email', callback);
		returns true	*/

Neo4j.prototype.dropUniquenessContstraint = function(label, property_key, callback) {
	var val = new Validator();
	val.label(label).property(property_key);

	if(val.hasErrors) {
		return callback(val.error(), null);
	}

	request
		.del(this.url + '/db/data/schema/constraint/' + label + '/uniqueness/' + property_key)
		.set(this.header)
		.end(function(result) {
			switch(result.statusCode) {
				case 204:
					callback(null, true); // Constraint was deleted.
					break;
				case 404:
					callback(null, false); // Constraint doesn't exist.
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when removing a uniqueness contraint.'), null);
			}
		});
};

/* TRANSACTIONS */

/*	NOTE:
	Details 'statements' property in beginTransaction, addStatementsToTransaction,
	commitTransaction and beginAndCommitTransaction:

	Return results in graph format by adding	resultDataContents : [ 'row', 'graph' ]	to a statement.
	If you want to understand the graph structure of nodes and relationships returned by your query,
	you can specify the 'graph' results data format.
	For example, this is useful when you want to visualise the graph structure.
	The format collates all the nodes and relationships from all columns of the result,
	and also flattens collections of nodes and relationships, including paths.

	Note the resultDataContents property.

	Example of a 'statements' parameter:
	{
		statements:	[ { statement : 'CREATE ( bike:Bike { weight: 10 } )CREATE ( frontWheel:Wheel { spokes: 3 } )CREATE ( backWheel:Wheel { spokes: 32 } )CREATE p1 = bike -[:HAS { position: 1 } ]-> frontWheel CREATE p2 = bike -[:HAS { position: 2 } ]-> backWheel RETURN bike, p1, p2',
    					resultDataContents : [ 'row', 'graph' ]
					} ]
	}

/*	Begin a transaction
	You begin a new transaction by posting zero or more Cypher statements to the transaction endpoint.
	The server will respond with the result of your statements, as well as the location of your open transaction.
	In the 'transaction' section you will find the expire date of the transaction. It's a RFC1123 formatted timestamp.
	The transactionId will be added to the result.
	Check the above 'NOTE' for more details about the statements parameter.

	Examples:
	beginTransaction(callback);
	returns  {
				commit: 'http://localhost:7474/db/data/transaction/10/commit',
				results: [],
				transaction: { expires: 'Tue, 24 Sep 2013 19:43:31 +0000' },
				errors: [],
				_id: 10
			}

	beginTransaction({
					  statements : [ {
					    statement : 'CREATE (n {props}) RETURN n',
					    parameters : {
					      props : {
					        name : 'Adam',
					        age: 22
					      }
					    }
					  } ]
					}, calback);
	returns {
				commit: 'http://localhost:7474/db/data/transaction/18/commit',
			  	results: [ { columns: [ 'person' ], data: [ { row: [ { age: 22, name: 'Adam' } ] } ] } ],
			  	transaction: { expires: 'Sun, 22 Sep 2013 19:31:17 +0000' },
			  	errors: [],
			  	_id: 18
			}																								*/

Neo4j.prototype.beginTransaction = function(statements, callback) {
	var that = this;
	if (!statements || typeof statements === 'function') {
		callback = statements;
		statements = { statements : [] };
	}
	request
		.post(this.url + '/db/data/transaction')
		.set(this.header)
		.send(statements)
		.end(function(result) {
			switch(result.statusCode) {
				case 201:
					that.addTransactionId(result.body, callback);
					break;
				case 404:
					callback(null, false);
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when beginning transaction.'), null);
			}
		});
};

/*	Execute statements in an open transaction
	Given that you have an open transaction, you can make a number of requests,
	each of which executes additional statements, and keeps the transaction open by resetting the transaction timeout.
	If the transaction in rolled back or it does not exist false will be returned (to callback)
	In the 'transaction' section you will find the expire date of the transaction. It's a RFC1123 formatted timestamp.
	The transactionId will be added to the result.
	Check the above 'NOTE' for more details about the statements parameter.

	Example:
		db.addStatementsToTransaction(7, {
											statements : [ {
												statement : 'CREATE (p:Person {props}) RETURN p',
													parameters : {
														props : {
															name : 'Adam',
															age: 23
														}
													}
												}]
										}, callback);
		returns {
					commit: 'http://localhost:7474/db/data/transaction/22/commit',
					results: [],
					transaction: { expires: 'Wed, 25 Sep 2013 13:45:17 +0000' },
					errors: [],
					transactionId: 22
				}																		*/

Neo4j.prototype.addStatementsToTransaction = function(transactionId, statements, callback) {
	var that = this;
	var val = new Validator();
	val.transaction(transactionId);

	if (val.hasErrors) {
		return callback(val.error(), null);
	}

	request
		.post(this.url + '/db/data/transaction/' + transactionId)
		.set(this.header)
		.send(statements)
		.end(function(result) {
			switch(result.statusCode) {
				case 200:
					that.addTransactionId(result.body, function afterAddingTransactionId (err, res) {
						if (res.errors && res.errors.length > 0) {
							callback(new Error('An error occured when adding statements to the transaction. See "errors" inside the result for more details.'), res);
						} else {
							callback(null, res);
						}
					});
					break;
				case 404:
					callback(null, false); // Transaction doesn't exist.
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when adding statements to transaction.'), null);
			}
		});
};

/*	Reset transaction timeout of an open transaction
	Every orphaned transaction is automatically expired after a period of inactivity.
	This may be prevented by resetting the transaction timeout.
	This request will reset the transaction timeout and return the new time at which
	the transaction will expire as an RFC1123 formatted timestamp value in the “transaction” section of the response.
	If the transaction in rolled back or it does not exist false will be returned (to callback)
	The transactionId will be added to the result.

	Example:
		resetTimeoutTransaction(7, callback);
		returns {
					commit: 'http://localhost:7474/db/data/transaction/7/commit',
					results: [],
					transaction: { expires: 'Tue, 24 Sep 2013 18:13:43 +0000' },
					errors: [],
  					transactionId: 7
  				}																	*/

Neo4j.prototype.resetTimeoutTransaction = function(transactionId, callback) {
	var that = this;
	var val = new Validator();
	val.transaction(transactionId);

	if (val.hasErrors) {
		return callback(val.error(), null);
	}

	request
		.post(this.url + '/db/data/transaction/' + transactionId)
		.set(this.header)
		.send({ statements : [ ]})
		.end(function(result) {
			switch(result.statusCode) {
				case 200:
					that.addTransactionId(result.body, callback);
					break;
				case 404:
					callback(null, false); // Transaction doesn't exist.
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when resetting transaction timeout.'), null);
			}
		});
};

/*	Commit an open transaction
	Given you have an open transaction, you can send a commit request.
	Optionally, you submit additional statements along with the request that will
	be executed before committing the transaction.
	If the transaction in rolled back or it does not exist false will be returned (to callback)
	Check the above 'NOTE' for more details about the statements parameter.

	Example:
	commitTransaction(7, {
								statements : [ {
									statement : 'CREATE (p:Person {props}) RETURN p',
										parameters : {
											props : {
												name : 'Adam',
												age: 24,
												favoriteColors: ['Green', 'Vanilla White']
											}
										}
									}]
							});
	returns	{
				results: [ { columns: [ 'p' ],
				data: [ { row: [ {  name: 'Adam',
									age: 24,
									favoriteColors: [ 'Green', 'Vanilla White' ] } ] } ]
						} ],
				errors: []
			}					*/

Neo4j.prototype.commitTransaction = function(transactionId, statements, callback){
	var that = this;
	var val = new Validator();
	val.transaction(transactionId);

	if (val.hasErrors) {
		return callback(val.error(), null);
	}

	if (!statements || typeof statements === 'function') {
		callback = statements;
		statements = { statements : [] };
	}

	request
		.post(this.url + '/db/data/transaction/' + transactionId + '/commit')
		.set(this.header)
		.send(statements)
		.end(function(result) {
			switch(result.statusCode) {
				case 200:
					callback(null, result.body);
					break;
				case 404:
					callback(null, false); // Transaction doesn't exist.
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when commiting transaction.'), null);
			}
		});
};

/*	Rollback an open transaction
	Given that you have an open transaction, you can send a roll back request.
	The server will roll back the transaction.
	If the transaction was already rolled back or it does not exist false will be returned (to callback)
	If the transaction has been rolled back true will be returned.

	Examples:
		rollbackTransaction(10, callback); // transaction 10 exists
		returns true
		rollbackTransaction(12345, callback); // transaction 12345 doesn't exist
		returns false																*/

Neo4j.prototype.rollbackTransaction = function(transactionId, callback) {
	var that = this;
	var val = new Validator();
	val.transaction(transactionId);

	if (val.hasErrors) {
		return callback(val.error(), null);
	}

	request
		.del(this.url + '/db/data/transaction/' + transactionId)
		.set(this.header)
		.end(function(result) {
			switch(result.statusCode) {
				case 200:
					callback(null, true);
					break;
				case 404:
					callback(null, false); // Transaction doesn't exist.
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when rolling back transaction.'), null);
			}
		});
};

/*	Begin and commit a transaction in one request
	If there is no need to keep a transaction open across multiple HTTP requests, you can begin a transaction,
	execute statements, and commit with just a single HTTP request.
	If the transaction in rolled back or it does not exist false will be returned (to callback)
	Check the above 'NOTE' for more details about the statements parameter.

	Examples:
		beginAndCommitTransaction({
									statements : [ {
										statement : 'CREATE (p:Person {props}) RETURN p',
											parameters : {
												props : {
													name : 'Adam',
													age: 21.17,
													favoriteNumbers: [123, 456789],
													gender: true
												}
											}
										}]
								}, callback);
		returns {
					results: [ { columns: [ 'p' ],
					data: [ { row: [ {	gender: true,
										name: 'Adam',
										favoriteNumbers: [ 123, 456789 ],
										age: 21.17 } ] } ] } ],
					errors: []
				}

		beginAndCommitTransaction({
									statements : [ {
										statement : 'CREATE (p:Person {props}) RETURN p',
										parameters : {
											props : {
												name : 'Adam',
												age: 21.17,
												favoriteNumbers: [123, 456789],
												gender: true
											}
										},
										resultDataContents : [ 'row', 'graph' ]
									}]
								}, callback);

		returns {	results: [ { columns: [ 'p' ],
					data: [ { row: [ {	gender: true,
										name: 'Adam',
										favoriteNumbers: [ 123, 456789 ],
										age: 21.17 } ],
										graph:{ nodes: [ {	id: '382',
															labels: [ 'Person' ],
															properties: {	gender: true,
																			name: 'Adam',
																			favoriteNumbers: [ 123, 456789 ],
																			age: 21.17 } } ],
																			relationships: [] } } ] } ],
					errors: [] }																				*/

Neo4j.prototype.beginAndCommitTransaction = function(statements, callback) {
	request
		.post(this.url + '/db/data/transaction/commit')
		.set(this.header)
		.set('X-Stream', true)
		.send(statements)
		.end(function(result) {
			switch(result.statusCode) {
				case 200:
					callback(null, result.body);
					break;
				case 404:
					callback(null, false); // Transaction doesn't exist.
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when beginning and commiting transaction.'), null);
			}
		});
};

/* ADVANCED FUNCTIONS ---------- */

/* Get all Relationship Types -------- */

Neo4j.prototype.readRelationshipTypes = function(callback) {
	var that = this;

	request
		.get(that.url + '/db/data/relationship/types')
		.set(this.header)
		.end(function(result) {
			switch(result.statusCode) {
				case 200:
					callback(null, result.body);
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when retrieving relationship types.'), null);
			}
		});
};

/* Get Relationships of a Node --------- */

var readRelationshipsOfNode = function(node_id, options, callback) {
	var that = this;

	if (typeof options === 'function') {
		callback = options;
	}

	var url = that.url + '/db/data/node/' + node_id + '/relationships/';

	// Set direction of relationships to retrieve.
	if (options.direction && (options.direction === 'in' || options.direction === 'out')) {
		url += options.direction;
	} else {
		url += 'all';
	}

	// Set types of relationships to retrieve.
	if (options.types && options.types.length >= 1) {
		url += '/' + encodeURIComponent(options.types.join('&'));
	}

	request
		.get(url)
		.set(this.header)
		.end(function(result) {
			switch(result.statusCode) {
				case 200:
					that.addRelationshipIdForArray(result.body, callback);
					break;
				case 404:
					callback(null, false);
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when retrieving relationships for node ' + node_id), null);
			}
		});
};
// Create aliases
Neo4j.prototype.readRelationshipsOfNode = readRelationshipsOfNode;
Neo4j.prototype.readAllRelationshipsOfNode = readRelationshipsOfNode;
Neo4j.prototype.readTypedRelationshipsOfNode = function(node_id, types, callback) {
	this.readRelationshipsOfNode(node_id, {types: types}, callback);
};
Neo4j.prototype.readIncomingRelationshipsOfNode = function(node_id, callback) {
	this.readRelationshipsOfNode(node_id, {direction: 'in'}, callback);
};
Neo4j.prototype.readOutgoingRelationshipsOfNode = function(node_id, callback) {
	this.readRelationshipsOfNode(node_id, {direction: 'out'}, callback);
};

/* Run Cypher Query -------- */

Neo4j.prototype.cypherQuery = function(query, params, include_stats, callback) {
	var that = this;
	var body = { query: query };
	if (params) {
		if (typeof params === 'function') {
			callback = params;
		} else {
			body['params'] = params;
		}
	}
	if (include_stats) {
		if (typeof include_stats === 'function') {
			callback = include_stats;
			include_stats = false;
		}
	}

	request
		.post(that.url + '/db/data/cypher' + (include_stats ? '?includeStats=true' : ''))
		.set(this.header)
		.set('Content-Type', 'application/json')
		.send(body)
    .on('error', callback)
		.end(function(result) {
			switch(result.statusCode) {
				case 200:
					if (result.body && result.body.data.length >= 1) {
						var addIdsToColumnData = function(columnData, callback) {
							step(
								function addId() {
									var group = this.group();
									columnData.forEach(function(node) {
										that.addNodeId(node, group());
									});
								},
								function sumUp(err, nodes) {
									if (err) {
										throw err;
									} else {
										callback(null, nodes);
									}
								});
						};

						step(
							function eachColumn() {
								var group = this.group();
								result.body.data.forEach(function(columnResult) {
									addIdsToColumnData(columnResult, group());
								});
							},
							function sumUp(err, columns) {
								if (err) {
									throw err;
								} else {
									// flatten the array if only one variable is getting returned to make it more convenient.
									if (result.body.columns.length >= 2) {
										result.body.data = columns;
									} else {
										result.body.data = [].concat.apply([], columns);
									}
									callback(null, result.body);
								}
							});
					} else {
						callback(null, result.body);
					}
					break;
				case 404:
					callback(null, null);
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when running the cypher query against neo4j.\n' + result.body.exception + ': ' + result.body.message), null);
			}
		});
};

/* Run Batch Queries -------- */

Neo4j.prototype.batchQuery = function(query, callback) {
	var that = this;

	request
		.post(that.url + '/db/data/batch')
		.set(this.header)
		.set('Content-Type', 'application/json')
		.send(query)
		.end(function(result) {
			switch(result.statusCode) {
				case 200:
					callback(null, result.body);
					break;
				case 404:
					callback(null, null);
					break;
				default:
					callback(new Error('HTTP Error ' + result.statusCode + ' when running the batch query against neo4j'), null);
			}
		});
};

/* HELPER METHODS --------- */

/* Extract node_id and add it as a property. */

Neo4j.prototype.addNodeId = function(node, callback) {
	if (node && node.self) {
		node.data._id = parseInt(node.self.match(/\/([0-9]+)$/)[1]);
		callback(null, node.data);
	} else {
		callback(null, node);
	}
};

/*	Extract the transaction id and adds it as an _id property. */

Neo4j.prototype.addTransactionId = function(node,  callback) {
	node._id = parseInt(node.commit.match(/\/transaction\/([0-9]+)\/commit$/)[1]);
	delete node.commit;
	callback(null, node);
};

/*	Extract start node id (_start), end node id (_end) and node id (_id) add it as a property. */

Neo4j.prototype.addRelationshipId = function(relationship, callback) {
	if (relationship.data) {
		relationship.data._start = parser.getNodeId(relationship.start);
		relationship.data._end = parser.getNodeId(relationship.end);
		relationship.data._id = parser.getRelationshipId(relationship.self);
		relationship.data._type = relationship.type;
		callback(null, relationship.data);
	} else {
		callback(new Error("Relationships data property doesn't exist.", null));
	}
};

/* Add relationship_id for an array of relationships */

Neo4j.prototype.addRelationshipIdForArray = function(relationships, callback) {
	var that = this;
	step(
		function process_relationships() {
			var group = this.group();
			relationships.forEach(function(relationship) {
				that.addRelationshipId(relationship, group());
			});
		},
		function sum_up(err, results) {
			if (err) {
				callback(err, null);
			} else {
				callback(null, results);
			}
		}
	);
};

/* Replace null values with an empty string */

Neo4j.prototype.replaceNullWithString = function(node_data, callback) {

	for (var key in node_data) {
		if (node_data.hasOwnProperty(key) && node_data[key] === null) {
			node_data[key] = '';
		}
	}

	return node_data;
};

/* Turn values that are objects themselves into strings. */

Neo4j.prototype.stringifyValueObjects = function(node_data, callback) {

	for (var key in node_data) {
		if (node_data.hasOwnProperty(key) && typeof node_data[key] === 'object') {
			node_data[key] = JSON.stringify(node_data[key]);
		}
	}

	return node_data;
};


'use strict';

const EventEmitter = require('events').EventEmitter,
    Long = require('long'),
    POGOProtos = require('node-pogo-protos'),
    Signature = require('pogobuf-signature'),
    Promise = require('bluebird'),
    request = require('request'),
    retry = require('bluebird-retry'),
    Utils = require('./pogobuf.utils.js');

const Lehmer = Utils.Random;

const RequestType = POGOProtos.Networking.Requests.RequestType,
    PlatformRequestType = POGOProtos.Networking.Platform.PlatformRequestType,
    PlatformRequestMessages = POGOProtos.Networking.Platform.Requests,
    PlatformResponses = POGOProtos.Networking.Platform.Responses,
    RequestMessages = POGOProtos.Networking.Requests.Messages,
    Responses = POGOProtos.Networking.Responses;

const INITIAL_ENDPOINT = 'https://pgorelease.nianticlabs.com/plfe/rpc';
const INITIAL_PTR8 = '90f6a704505bccac73cec99b07794993e6fd5a12';

// See pogobuf wiki for description of options
const defaultOptions = {
    authToken: '',
    authType: 'ptc',
    downloadSettings: true,
    mapObjectsThrottling: true,
    mapObjectsMinDelay: 5000,
    proxy: null,
    maxTries: 5,
    automaticLongConversion: true,
    includeRequestTypeInResponse: false,
    version: 4500,
    useHashingServer: false,
    hashingServer: 'http://hashing.pogodev.io/',
    hashingKey: null,
    deviceId: null,
};

/**
 * Pokémon Go RPC client.
 * @class Client
 * @param {Object} [options] - Client options (see pogobuf wiki for documentation)
 * @memberof pogobuf
 */
function Client(options) {
    if (!(this instanceof Client)) {
        return new Client(options);
    }
    const self = this;

    /*
     * PUBLIC METHODS
     */

     /**
      * Sets the specified client option to the given value.
      * Note that not all options support changes after client initialization.
      * @param {string} option - Option name
      * @param {any} value - Option value
      */
    this.setOption = function(option, value) {
        self.options[option] = value;
    };

    /**
     * Sets the player's latitude and longitude.
     * Note that this does not actually update the player location on the server, it only sets
     * the location to be used in following API calls. To update the location on the server you
     * need to make an API call.
     * @param {number|object} latitude - The player's latitude, or an object with parameters
     * @param {number} longitude - The player's longitude
     * @param {number} [accuracy=0] - The location accuracy in m
     * @param {number} [altitude=0] - The player's altitude
     */
    this.setPosition = function(latitude, longitude, accuracy, altitude) {
        if (typeof latitude === 'object') {
            const pos = latitude;
            latitude = pos.latitude;
            longitude = pos.longitude;
            accuracy = pos.accuracy;
            altitude = pos.altitude;
        }
        self.playerLatitude = latitude;
        self.playerLongitude = longitude;
        self.playerLocationAccuracy = accuracy || 0;
        self.playerAltitude = altitude || 0;
    };

    /**
     * Performs client initialization and downloads needed settings from the API and hashing server.
     * @param {boolean} [downloadSettings] - Deprecated, use downloadSettings option instead
     * @return {Promise} promise
     */
    this.init = function(downloadSettings) {
        // For backwards compatibility only
        if (typeof downloadSettings !== 'undefined') self.setOption('downloadSettings', downloadSettings);

        self.lastMapObjectsCall = 0;
        self.endpoint = INITIAL_ENDPOINT;

        // convert app version (5704) to client version (0.57.4)
        let signatureVersion = '0.' + ((+self.options.version) / 100).toFixed(0);
        signatureVersion += '.' + (+self.options.version % 100);

        Signature.signature.register(self, self.options.deviceId);

        self.signatureBuilder = new Signature.encryption.Builder({
            protos: POGOProtos,
            version: signatureVersion,
        });
        self.signatureBuilder.encryptAsync = Promise.promisify(self.signatureBuilder.encrypt,
                                                                { context: self.signatureBuilder });

        let promise = Promise.resolve(true);

        if (self.options.useHashingServer) {
            promise = promise.then(self.initializeHashingServer);
        }

        if (self.options.downloadSettings) {
            promise = promise.then(() => self.downloadSettings()).then(self.processSettingsResponse);
        }

        return promise;
    };

    /**
     * Sets batch mode. All further API requests will be held and executed in one RPC call when
     * {@link #batchCall} is called.
     * @return {Client} this
     */
    this.batchStart = function() {
        if (!self.batchRequests) {
            self.batchRequests = [];
        }
        return self;
    };

    /**
     * Clears the list of batched requests and aborts batch mode.
     */
    this.batchClear = function() {
        delete self.batchRequests;
    };

    /**
     * Executes any batched requests.
     * @return {Promise}
     */
    this.batchCall = function() {
        var p = self.callRPC(self.batchRequests || []);
        self.batchClear();
        return p;
    };

    /**
     * Gets rate limit info from the latest signature server request, if applicable.
     * @return {Object}
     */
    this.getSignatureRateInfo = function() {
        return self.signatureBuilder.rateInfos;
    };

    /*
     * API CALLS (in order of RequestType enum)
     */

    this.getPlayer = function(country, language, timezone) {
        return self.callOrChain({
            type: RequestType.GET_PLAYER,
            message: new RequestMessages.GetPlayerMessage({
                player_locale: {
                    country: country,
                    language: language,
                    timezone: timezone
                }
            }),
            responseType: Responses.GetPlayerResponse
        });
    };

    this.getInventory = function(lastTimestamp) {
        return self.callOrChain({
            type: RequestType.GET_INVENTORY,
            message: new RequestMessages.GetInventoryMessage({
                last_timestamp_ms: lastTimestamp
            }),
            responseType: Responses.GetInventoryResponse
        });
    };

    this.downloadSettings = function(hash) {
        return self.callOrChain({
            type: RequestType.DOWNLOAD_SETTINGS,
            message: new RequestMessages.DownloadSettingsMessage({
                hash: hash
            }),
            responseType: Responses.DownloadSettingsResponse
        });
    };

    this.downloadItemTemplates = function(paginate, pageOffset, pageTimestamp) {
        return self.callOrChain({
            type: RequestType.DOWNLOAD_ITEM_TEMPLATES,
            message: new RequestMessages.DownloadItemTemplatesMessage({
                paginate: paginate,
                page_offset: pageOffset,
                page_timestamp: pageTimestamp
            }),
            responseType: Responses.DownloadItemTemplatesResponse
        });
    };

    this.downloadRemoteConfigVersion = function(platform, deviceManufacturer, deviceModel, locale, appVersion) {
        return self.callOrChain({
            type: RequestType.DOWNLOAD_REMOTE_CONFIG_VERSION,
            message: new RequestMessages.DownloadRemoteConfigVersionMessage({
                platform: platform,
                device_manufacturer: deviceManufacturer,
                device_model: deviceModel,
                locale: locale,
                app_version: appVersion
            }),
            responseType: Responses.DownloadRemoteConfigVersionResponse
        });
    };

    this.registerBackgroundDevice = function(deviceType, deviceID) {
        return self.callOrChain({
            type: RequestType.REGISTER_BACKGROUND_DEVICE,
            message: new RequestMessages.RegisterBackgroundDeviceMessage({
                device_type: deviceType,
                device_id: deviceID
            }),
            responseType: Responses.RegisterBackgroundDeviceResponse
        });
    };

    this.fortSearch = function(fortID, fortLatitude, fortLongitude) {
        return self.callOrChain({
            type: RequestType.FORT_SEARCH,
            message: new RequestMessages.FortSearchMessage({
                fort_id: fortID,
                player_latitude: self.playerLatitude,
                player_longitude: self.playerLongitude,
                fort_latitude: fortLatitude,
                fort_longitude: fortLongitude
            }),
            responseType: Responses.FortSearchResponse
        });
    };

    this.encounter = function(encounterID, spawnPointID) {
        return self.callOrChain({
            type: RequestType.ENCOUNTER,
            message: new RequestMessages.EncounterMessage({
                encounter_id: encounterID,
                spawn_point_id: spawnPointID,
                player_latitude: self.playerLatitude,
                player_longitude: self.playerLongitude
            }),
            responseType: Responses.EncounterResponse
        });
    };

    this.catchPokemon = function(encounterID, pokeballItemID, normalizedReticleSize, spawnPointID, hitPokemon,
        spinModifier, normalizedHitPosition) {
        return self.callOrChain({
            type: RequestType.CATCH_POKEMON,
            message: new RequestMessages.CatchPokemonMessage({
                encounter_id: encounterID,
                pokeball: pokeballItemID,
                normalized_reticle_size: normalizedReticleSize,
                spawn_point_id: spawnPointID,
                hit_pokemon: hitPokemon,
                spin_modifier: spinModifier,
                normalized_hit_position: normalizedHitPosition
            }),
            responseType: Responses.CatchPokemonResponse
        });
    };

    this.fortDetails = function(fortID, fortLatitude, fortLongitude) {
        return self.callOrChain({
            type: RequestType.FORT_DETAILS,
            message: new RequestMessages.FortDetailsMessage({
                fort_id: fortID,
                latitude: fortLatitude,
                longitude: fortLongitude
            }),
            responseType: Responses.FortDetailsResponse
        });
    };

    this.getMapObjects = function(cellIDs, sinceTimestamps) {
        return self.callOrChain({
            type: RequestType.GET_MAP_OBJECTS,
            message: new RequestMessages.GetMapObjectsMessage({
                cell_id: cellIDs,
                since_timestamp_ms: sinceTimestamps,
                latitude: self.playerLatitude,
                longitude: self.playerLongitude
            }),
            responseType: Responses.GetMapObjectsResponse
        });
    };

    this.fortDeployPokemon = function(fortID, pokemonID) {
        return self.callOrChain({
            type: RequestType.FORT_DEPLOY_POKEMON,
            message: new RequestMessages.FortDeployPokemonMessage({
                fort_id: fortID,
                pokemon_id: pokemonID,
                player_latitude: self.playerLatitude,
                player_longitude: self.playerLongitude
            }),
            responseType: Responses.FortDeployPokemonResponse
        });
    };

    this.fortRecallPokemon = function(fortID, pokemonID) {
        return self.callOrChain({
            type: RequestType.FORT_RECALL_POKEMON,
            message: new RequestMessages.FortRecallPokemonMessage({
                fort_id: fortID,
                pokemon_id: pokemonID,
                player_latitude: self.playerLatitude,
                player_longitude: self.playerLongitude
            }),
            responseType: Responses.FortRecallPokemonResponse
        });
    };

    this.releasePokemon = function(pokemonIDs) {
        if (!Array.isArray(pokemonIDs)) pokemonIDs = [pokemonIDs];

        return self.callOrChain({
            type: RequestType.RELEASE_POKEMON,
            message: new RequestMessages.ReleasePokemonMessage({
                pokemon_id: pokemonIDs.length === 1 ? pokemonIDs[0] : undefined,
                pokemon_ids: pokemonIDs.length > 1 ? pokemonIDs : undefined
            }),
            responseType: Responses.ReleasePokemonResponse
        });
    };

    this.useItemPotion = function(itemID, pokemonID) {
        return self.callOrChain({
            type: RequestType.USE_ITEM_POTION,
            message: new RequestMessages.UseItemPotionMessage({
                item_id: itemID,
                pokemon_id: pokemonID
            }),
            responseType: Responses.UseItemPotionResponse
        });
    };

    this.useItemCapture = function(itemID, encounterID, spawnPointID) {
        return self.callOrChain({
            type: RequestType.USE_ITEM_CAPTURE,
            message: new RequestMessages.UseItemCaptureMessage({
                item_id: itemID,
                encounter_id: encounterID,
                spawn_point_id: spawnPointID
            }),
            responseType: Responses.UseItemCaptureResponse
        });
    };

    this.useItemRevive = function(itemID, pokemonID) {
        return self.callOrChain({
            type: RequestType.USE_ITEM_REVIVE,
            message: new RequestMessages.UseItemReviveMessage({
                item_id: itemID,
                pokemon_id: pokemonID
            }),
            responseType: Responses.UseItemReviveResponse
        });
    };

    this.getPlayerProfile = function(playerName) {
        return self.callOrChain({
            type: RequestType.GET_PLAYER_PROFILE,
            message: new RequestMessages.GetPlayerProfileMessage({
                player_name: playerName
            }),
            responseType: Responses.GetPlayerProfileResponse
        });
    };

    this.evolvePokemon = function(pokemonID, evolutionRequirementItemID) {
        return self.callOrChain({
            type: RequestType.EVOLVE_POKEMON,
            message: new RequestMessages.EvolvePokemonMessage({
                pokemon_id: pokemonID,
                evolution_item_requirement: evolutionRequirementItemID
            }),
            responseType: Responses.EvolvePokemonResponse
        });
    };

    this.getHatchedEggs = function() {
        return self.callOrChain({
            type: RequestType.GET_HATCHED_EGGS,
            responseType: Responses.GetHatchedEggsResponse
        });
    };

    this.encounterTutorialComplete = function(pokemonID) {
        return self.callOrChain({
            type: RequestType.ENCOUNTER_TUTORIAL_COMPLETE,
            message: new RequestMessages.EncounterTutorialCompleteMessage({
                pokemon_id: pokemonID
            }),
            responseType: Responses.EncounterTutorialCompleteResponse
        });
    };

    this.levelUpRewards = function(level) {
        return self.callOrChain({
            type: RequestType.LEVEL_UP_REWARDS,
            message: new RequestMessages.LevelUpRewardsMessage({
                level: level
            }),
            responseType: Responses.LevelUpRewardsResponse
        });
    };

    this.checkAwardedBadges = function() {
        return self.callOrChain({
            type: RequestType.CHECK_AWARDED_BADGES,
            responseType: Responses.CheckAwardedBadgesResponse
        });
    };

    this.useItemGym = function(itemID, gymID) {
        return self.callOrChain({
            type: RequestType.USE_ITEM_GYM,
            message: new RequestMessages.UseItemGymMessage({
                item_id: itemID,
                gym_id: gymID,
                player_latitude: self.playerLatitude,
                player_longitude: self.playerLongitude
            }),
            responseType: Responses.UseItemGymResponse
        });
    };

    this.getGymDetails = function(gymID, gymLatitude, gymLongitude, clientVersion) {
        return self.callOrChain({
            type: RequestType.GET_GYM_DETAILS,
            message: new RequestMessages.GetGymDetailsMessage({
                gym_id: gymID,
                player_latitude: self.playerLatitude,
                player_longitude: self.playerLongitude,
                gym_latitude: gymLatitude,
                gym_longitude: gymLongitude,
                client_version: clientVersion
            }),
            responseType: Responses.GetGymDetailsResponse
        });
    };

    this.startGymBattle = function(gymID, attackingPokemonIDs, defendingPokemonID) {
        return self.callOrChain({
            type: RequestType.START_GYM_BATTLE,
            message: new RequestMessages.StartGymBattleMessage({
                gym_id: gymID,
                attacking_pokemon_ids: attackingPokemonIDs,
                defending_pokemon_id: defendingPokemonID,
                player_latitude: self.playerLatitude,
                player_longitude: self.playerLongitude
            }),
            responseType: Responses.StartGymBattleResponse
        });
    };

    this.attackGym = function(gymID, battleID, attackActions, lastRetrievedAction) {
        return self.callOrChain({
            type: RequestType.ATTACK_GYM,
            message: new RequestMessages.AttackGymMessage({
                gym_id: gymID,
                battle_id: battleID,
                attack_actions: attackActions,
                last_retrieved_action: lastRetrievedAction,
                player_latitude: self.playerLatitude,
                player_longitude: self.playerLongitude
            }),
            responseType: Responses.AttackGymResponse
        });
    };

    this.recycleInventoryItem = function(itemID, count) {
        return self.callOrChain({
            type: RequestType.RECYCLE_INVENTORY_ITEM,
            message: new RequestMessages.RecycleInventoryItemMessage({
                item_id: itemID,
                count: count
            }),
            responseType: Responses.RecycleInventoryItemResponse
        });
    };

    this.collectDailyBonus = function() {
        return self.callOrChain({
            type: RequestType.COLLECT_DAILY_BONUS,
            responseType: Responses.CollectDailyBonusResponse
        });
    };

    this.useItemXPBoost = function(itemID) {
        return self.callOrChain({
            type: RequestType.USE_ITEM_XP_BOOST,
            message: new RequestMessages.UseItemXpBoostMessage({
                item_id: itemID
            }),
            responseType: Responses.UseItemXpBoostResponse
        });
    };

    this.useItemEggIncubator = function(itemID, pokemonID) {
        return self.callOrChain({
            type: RequestType.USE_ITEM_EGG_INCUBATOR,
            message: new RequestMessages.UseItemEggIncubatorMessage({
                item_id: itemID,
                pokemon_id: pokemonID
            }),
            responseType: Responses.UseItemEggIncubatorResponse
        });
    };

    this.useIncense = function(itemID) {
        return self.callOrChain({
            type: RequestType.USE_INCENSE,
            message: new RequestMessages.UseIncenseMessage({
                incense_type: itemID
            }),
            responseType: Responses.UseIncenseResponse
        });
    };

    this.getIncensePokemon = function() {
        return self.callOrChain({
            type: RequestType.GET_INCENSE_POKEMON,
            message: new RequestMessages.GetIncensePokemonMessage({
                player_latitude: self.playerLatitude,
                player_longitude: self.playerLongitude
            }),
            responseType: Responses.GetIncensePokmeonResponse
        });
    };

    this.incenseEncounter = function(encounterID, encounterLocation) {
        return self.callOrChain({
            type: RequestType.INCENSE_ENCOUNTER,
            message: new RequestMessages.IncenseEncounterMessage({
                encounter_id: encounterID,
                encounter_location: encounterLocation
            }),
            responseType: Responses.IncenseEncounterResponse
        });
    };

    this.addFortModifier = function(modifierItemID, fortID) {
        return self.callOrChain({
            type: RequestType.ADD_FORT_MODIFIER,
            message: new RequestMessages.AddFortModifierMessage({
                modifier_type: modifierItemID,
                fort_id: fortID,
                player_latitude: self.playerLatitude,
                player_longitude: self.playerLongitude
            })
        });
    };

    this.diskEncounter = function(encounterID, fortID) {
        return self.callOrChain({
            type: RequestType.DISK_ENCOUNTER,
            message: new RequestMessages.DiskEncounterMessage({
                encounter_id: encounterID,
                fort_id: fortID,
                player_latitude: self.playerLatitude,
                player_longitude: self.playerLongitude
            }),
            responseType: Responses.DiskEncounterResponse
        });
    };

    this.collectDailyDefenderBonus = function() {
        return self.callOrChain({
            type: RequestType.COLLECT_DAILY_DEFENDER_BONUS,
            responseType: Responses.CollectDailyDefenderBonusResponse
        });
    };

    this.upgradePokemon = function(pokemonID) {
        return self.callOrChain({
            type: RequestType.UPGRADE_POKEMON,
            message: new RequestMessages.UpgradePokemonMessage({
                pokemon_id: pokemonID
            }),
            responseType: Responses.UpgradePokemonResponse
        });
    };

    this.setFavoritePokemon = function(pokemonID, isFavorite) {
        return self.callOrChain({
            type: RequestType.SET_FAVORITE_POKEMON,
            message: new RequestMessages.SetFavoritePokemonMessage({
                pokemon_id: pokemonID,
                is_favorite: isFavorite
            }),
            responseType: Responses.SetFavoritePokemonResponse
        });
    };

    this.nicknamePokemon = function(pokemonID, nickname) {
        return self.callOrChain({
            type: RequestType.NICKNAME_POKEMON,
            message: new RequestMessages.NicknamePokemonMessage({
                pokemon_id: pokemonID,
                nickname: nickname
            }),
            responseType: Responses.NicknamePokemonResponse
        });
    };

    this.equipBadge = function(badgeType) {
        return self.callOrChain({
            type: RequestType.EQUIP_BADGE,
            message: new RequestMessages.EquipBadgeMessage({
                badge_type: badgeType
            }),
            responseType: Responses.EquipBadgeResponse
        });
    };

    this.setContactSettings = function(sendMarketingEmails, sendPushNotifications) {
        return self.callOrChain({
            type: RequestType.SET_CONTACT_SETTINGS,
            message: new RequestMessages.SetContactSettingsMessage({
                contact_settings: {
                    send_marketing_emails: sendMarketingEmails,
                    send_push_notifications: sendPushNotifications
                }
            }),
            responseType: Responses.SetContactSettingsResponse
        });
    };

    this.setBuddyPokemon = function(pokemonID) {
        return self.callOrChain({
            type: RequestType.SET_BUDDY_POKEMON,
            message: new RequestMessages.SetBuddyPokemonMessage({
                pokemon_id: pokemonID
            }),
            responseType: Responses.SetBuddyPokemonResponse
        });
    };

    this.getBuddyWalked = function() {
        return self.callOrChain({
            type: RequestType.GET_BUDDY_WALKED,
            responseType: Responses.GetBuddyWalkedResponse
        });
    };

    this.useItemEncounter = function(itemID, encounterID, spawnPointGUID) {
        return self.callOrChain({
            type: RequestType.USE_ITEM_ENCOUNTER,
            message: new RequestMessages.UseItemEncounterMessage({
                item: itemID,
                encounter_id: encounterID,
                spawn_point_guid: spawnPointGUID
            }),
            responseType: Responses.UseItemEncounterResponse
        });
    };

    this.getAssetDigest = function(platform, deviceManufacturer, deviceModel, locale, appVersion) {
        return self.callOrChain({
            type: RequestType.GET_ASSET_DIGEST,
            message: new RequestMessages.GetAssetDigestMessage({
                platform: platform,
                device_manufacturer: deviceManufacturer,
                device_model: deviceModel,
                locale: locale,
                app_version: appVersion
            }),
            responseType: Responses.GetAssetDigestResponse
        });
    };

    this.getDownloadURLs = function(assetIDs) {
        return self.callOrChain({
            type: RequestType.GET_DOWNLOAD_URLS,
            message: new RequestMessages.GetDownloadUrlsMessage({
                asset_id: assetIDs
            }),
            responseType: Responses.GetDownloadUrlsResponse
        });
    };

    this.claimCodename = function(codename) {
        return self.callOrChain({
            type: RequestType.CLAIM_CODENAME,
            message: new RequestMessages.ClaimCodenameMessage({
                codename: codename
            }),
            responseType: Responses.ClaimCodenameResponse
        });
    };

    this.setAvatar = function(playerAvatar) {
        return self.callOrChain({
            type: RequestType.SET_AVATAR,
            message: new RequestMessages.SetAvatarMessage({
                player_avatar: playerAvatar
            }),
            responseType: Responses.SetAvatarResponse
        });
    };

    this.setPlayerTeam = function(teamColor) {
        return self.callOrChain({
            type: RequestType.SET_PLAYER_TEAM,
            message: new RequestMessages.SetPlayerTeamMessage({
                team: teamColor
            }),
            responseType: Responses.SetPlayerTeamResponse
        });
    };

    this.markTutorialComplete = function(tutorialsCompleted, sendMarketingEmails, sendPushNotifications) {
        return self.callOrChain({
            type: RequestType.MARK_TUTORIAL_COMPLETE,
            message: new RequestMessages.MarkTutorialCompleteMessage({
                tutorials_completed: tutorialsCompleted,
                send_marketing_emails: sendMarketingEmails,
                send_push_notifications: sendPushNotifications
            }),
            responseType: Responses.MarkTutorialCompleteResponse
        });
    };

    this.checkChallenge = function(isDebugRequest) {
        return self.callOrChain({
            type: RequestType.CHECK_CHALLENGE,
            message: new RequestMessages.CheckChallengeMessage({
                debug_request: isDebugRequest
            }),
            responseType: Responses.CheckChallengeResponse
        });
    };

    this.verifyChallenge = function(token) {
        return self.callOrChain({
            type: RequestType.VERIFY_CHALLENGE,
            message: new RequestMessages.VerifyChallengeMessage({
                token: token
            }),
            responseType: Responses.VerifyChallengeResponse
        });
    };

    this.echo = function() {
        return self.callOrChain({
            type: RequestType.ECHO,
            responseType: Responses.EchoResponse
        });
    };

    this.sfidaActionLog = function() {
        return self.callOrChain({
            type: RequestType.SFIDA_ACTION_LOG,
            responseType: Responses.SfidaActionLogResponse
        });
    };

    this.listAvatarCustomizations = function(avatarType, slots, filters, start, limit) {
        return self.callOrChain({
            type: RequestType.LIST_AVATAR_CUSTOMIZATIONS,
            message: new RequestMessages.ListAvatarCustomizationsMessage({
                avatar_type: avatarType,
                slot: slots,
                filters: filters,
                start: start,
                limit: limit
            }),
            responseType: Responses.ListAvatarCustomizationsResponse
        });
    };

    this.setAvatarItemAsViewed = function(avatarTemplateIDs) {
        return self.callOrChain({
            type: RequestType.SET_AVATAR_ITEM_AS_VIEWED,
            message: new RequestMessages.SetAvatarItemAsViewedMessage({
                avatar_template_id: avatarTemplateIDs
            }),
            responseType: Responses.SetAvatarItemAsViewdResponse
        });
    };

    /*
     * INTERNAL STUFF
     */

    this.request = request.defaults({
        headers: {
            'User-Agent': 'Niantic App',
            'Accept': '*/*',
            'Content-Type': 'application/x-www-form-urlencoded'
        },
        encoding: null
    });

    this.options = Object.assign({}, defaultOptions, options || {});
    this.authTicket = null;
    this.rpcId = 2;
    this.lastHashingKeyIndex = 0;
    this.firstGetMapObjects = true;
    this.lehmer = new Lehmer(16807);
    this.ptr8 = INITIAL_PTR8;

    /**
     * Executes a request and returns a Promise or, if we are in batch mode, adds it to the
     * list of batched requests and returns this (for chaining).
     * @private
     * @param {object} requestMessage - RPC request object
     * @return {Promise|Client}
     */
    this.callOrChain = function(requestMessage) {
        if (self.batchRequests) {
            self.batchRequests.push(requestMessage);
            return self;
        } else {
            return self.callRPC([requestMessage]);
        }
    };

    /**
     * Generates next rpc request id
     * @private
     * @return {Long}
     */
    this.getRequestID = function() {
        return new Long(self.rpcId++, this.lehmer.nextInt());
    };

    /**
     * Creates an RPC envelope with the given list of requests.
     * @private
     * @param {Object[]} requests - Array of requests to build
     * @return {POGOProtos.Networking.Envelopes.RequestEnvelope}
     */
    this.buildEnvelope = function(requests) {
        var envelopeData = {
            status_code: 2,
            request_id: self.getRequestID(),
            ms_since_last_locationfix: 100 + Math.floor(Math.random() * 900)
        };

        if (self.playerLatitude) envelopeData.latitude = self.playerLatitude;
        if (self.playerLongitude) envelopeData.longitude = self.playerLongitude;
        if (self.playerLocationAccuracy) {
            envelopeData.accuracy = self.playerLocationAccuracy;
        } else {
            const values = [5, 5, 5, 5, 10, 10, 10, 30, 30, 50, 65];
            values.unshift(Math.floor(Math.random() * (80 - 66)) + 66);
            envelopeData.accuracy = values[Math.floor(values.length * Math.random())];
        }

        if (self.authTicket) {
            envelopeData.auth_ticket = self.authTicket;
        } else if (!self.options.authType || !self.options.authToken) {
            throw Error('No auth info provided');
        } else {
            let unknown2 = 0;
            if (self.options.authType === 'ptc') {
                const values = [2, 8, 21, 21, 21, 28, 37, 56, 59, 59, 59];
                unknown2 = values[Math.floor(values.length * Math.random())];
            }
            envelopeData.auth_info = {
                provider: self.options.authType,
                token: {
                    contents: self.options.authToken,
                    unknown2: unknown2,
                }
            };
        }

        if (requests) {
            self.emit('request', {
                request_id: envelopeData.request_id.toString(),
                requests: requests.map(r => ({
                    name: Utils.getEnumKeyByValue(RequestType, r.type),
                    type: r.type,
                    data: r.message
                }))
            });

            envelopeData.requests = requests.map(r => {
                var requestData = {
                    request_type: r.type
                };

                if (r.message) {
                    requestData.request_message = r.message.encode();
                }

                return requestData;
            });
        }

        self.emit('raw-request', envelopeData);

        return new POGOProtos.Networking.Envelopes.RequestEnvelope(envelopeData);
    };

    /**
     * Constructs and adds a platform request to a request envelope.
     * @private
     * @param {RequestEnvelope} envelope - Request envelope
     * @param {PlatformRequestType} requestType - Type of the platform request to add
     * @param {Object} requestMessage - Pre-built but not encoded PlatformRequest protobuf message
     * @return {RequestEnvelope} The envelope (for convenience only)
     */
    this.addPlatformRequestToEnvelope = function(envelope, requestType, requestMessage) {
        envelope.platform_requests.push(
            new POGOProtos.Networking.Envelopes.RequestEnvelope.PlatformRequest({
                type: requestType,
                request_message: requestMessage.encode()
            })
        );

        return envelope;
    };

    /**
     * Determines whether the as of yet unknown platform request type 8 should be added
     * to the envelope based on the given type of requests.
     * @private
     * @param {Object[]} requests - Array of request data
     * @return {boolean}
     */
    this.needsPtr8 = function(requests) {
        // Single GET_PLAYER request always gets PTR8
        if (requests.length === 1 && requests[0].type === RequestType.GET_PLAYER) {
            return true;
        }

        // Any GET_MAP_OBJECTS requests get PTR8 except the first one in the session
        if (requests.some(r => r.type === RequestType.GET_MAP_OBJECTS)) {
            if (self.firstGetMapObjects) {
                self.firstGetMapObjects = false;
                return false;
            }

            return true;
        }

        return false;
    };

    /**
     * Creates an RPC envelope with the given list of requests and adds the encrypted signature,
     * or adds the signature to an existing envelope.
     * @private
     * @param {Object[]} requests - Array of requests to build
     * @param {RequestEnvelope} [envelope] - Pre-built request envelope to sign
     * @return {Promise} - A Promise that will be resolved with a RequestEnvelope instance
     */
    this.buildSignedEnvelope = function(requests, envelope) {
        if (!envelope) {
            try {
                envelope = self.buildEnvelope(requests);
            } catch (e) {
                throw new retry.StopError(e);
            }
        }

        if (self.needsPtr8(requests)) {
            self.addPlatformRequestToEnvelope(envelope, PlatformRequestType.UNKNOWN_PTR_8,
                new PlatformRequestMessages.UnknownPtr8Request({
                    message: self.ptr8,
                }));
        }

        let authTicket = envelope.auth_ticket;
        if (!authTicket) {
            authTicket = envelope.auth_info;
        }

        if (!authTicket) {
            // Can't sign before we have received an auth ticket
            return Promise.resolve(envelope);
        }

        if (self.options.useHashingServer) {
            let key = self.options.hashingKey;
            if (Array.isArray(key)) {
                key = key[self.lastHashingKeyIndex];
                self.lastHashingKeyIndex = (self.lastHashingKeyIndex + 1) % self.options.hashingKey.length;
            }

            self.signatureBuilder.useHashingServer(self.options.hashingServer + self.hashingVersion, key);
        }

        self.signatureBuilder.setAuthTicket(authTicket);

        if (typeof self.options.signatureInfo === 'function') {
            self.signatureBuilder.setFields(self.options.signatureInfo(envelope));
        } else if (self.options.signatureInfo) {
            self.signatureBuilder.setFields(self.options.signatureInfo);
        }

        self.signatureBuilder.setLocation(envelope.latitude, envelope.longitude, envelope.accuracy);

        return retry(() => self.signatureBuilder.encryptAsync(envelope.requests)
                        .catch(err => {
                            if (err.name === 'HashServerError' && err.message === 'Request limited') {
                                throw err;
                            } else {
                                throw new retry.StopError(err);
                            }
                        }),
            {
                interval: 1000,
                backoff: 2,
                max_tries: 10,
                args: envelope.requests,
            })
            .then(sigEncrypted =>
                self.addPlatformRequestToEnvelope(envelope, PlatformRequestType.SEND_ENCRYPTED_SIGNATURE,
                    new PlatformRequestMessages.SendEncryptedSignatureRequest({
                        encrypted_signature: sigEncrypted
                    })
                )
            );
    };

    /**
     * Executes an RPC call with the given list of requests, retrying if necessary.
     * @private
     * @param {Object[]} requests - Array of requests to send
     * @param {RequestEnvelope} [envelope] - Pre-built request envelope to use
     * @return {Promise} - A Promise that will be resolved with the (list of) response messages,
     *     or true if there aren't any
     */
    this.callRPC = function(requests, envelope) {
        // If the requests include a map objects request, make sure the minimum delay
        // since the last call has passed
        if (requests.some(r => r.type === RequestType.GET_MAP_OBJECTS)) {
            var now = new Date().getTime(),
                delayNeeded = self.lastMapObjectsCall + self.options.mapObjectsMinDelay - now;

            if (delayNeeded > 0 && self.options.mapObjectsThrottling) {
                return Promise.delay(delayNeeded).then(() => self.callRPC(requests, envelope));
            }

            self.lastMapObjectsCall = now;
        }

        if (self.options.maxTries <= 1) return self.tryCallRPC(requests, envelope);

        return retry(() => self.tryCallRPC(requests, envelope), {
            interval: 300,
            backoff: 2,
            max_tries: self.options.maxTries
        });
    };

    /**
     * Handle redirection to new API endpoint and resend last request to new endpoint.
     * @private
     * @param {Object[]} requests - Array of requests
     * @param {RequestEnvelope} signedEnvelope - Request envelope
     * @param {ResponseEnvelope} responseEnvelope - Result from API call
     * @return {Promise}
     */
    this.redirect = function(requests, signedEnvelope, responseEnvelope) {
        return new Promise((resolve, reject) => {
            if (!responseEnvelope.api_url) {
                reject(Error('Fetching RPC endpoint failed, none supplied in response'));
                return;
            }

            self.endpoint = 'https://' + responseEnvelope.api_url + '/rpc';

            self.emit('endpoint-response', {
                status_code: responseEnvelope.status_code,
                request_id: responseEnvelope.request_id.toString(),
                api_url: responseEnvelope.api_url
            });

            signedEnvelope.platform_requests = [];
            resolve(self.callRPC(requests, signedEnvelope));
        });
    };

    /**
     * Executes an RPC call with the given list of requests.
     * @private
     * @param {Object[]} requests - Array of requests to send
     * @param {RequestEnvelope} [envelope] - Pre-built request envelope to use
     * @return {Promise} - A Promise that will be resolved with the (list of) response messages,
     *     or true if there aren't any
     */
    this.tryCallRPC = function(requests, envelope) {
        return self.buildSignedEnvelope(requests, envelope)
            .then(signedEnvelope => new Promise((resolve, reject) => {
                self.request({
                    method: 'POST',
                    url: self.endpoint,
                    proxy: self.options.proxy,
                    body: signedEnvelope.toBuffer()
                }, (err, response, body) => {
                    if (err) {
                        reject(Error(err));
                        return;
                    }

                    if (response.statusCode !== 200) {
                        if (response.statusCode >= 400 && response.statusCode < 500) {
                            /* These are permanent errors so throw StopError */
                            reject(new retry.StopError(
                                `Status code ${response.statusCode} received from HTTPS request`
                            ));
                        } else {
                            /* Anything else might be recoverable so throw regular Error */
                            reject(Error(
                                `Status code ${response.statusCode} received from HTTPS request`
                            ));
                        }
                        return;
                    }

                    var responseEnvelope;
                    try {
                        responseEnvelope =
                            POGOProtos.Networking.Envelopes.ResponseEnvelope.decode(body);
                    } catch (e) {
                        self.emit('parse-envelope-error', body, e);
                        if (e.decoded) {
                            responseEnvelope = e.decoded;
                        } else {
                            reject(new retry.StopError(e));
                            return;
                        }
                    }

                    self.emit('raw-response', responseEnvelope);

                    if (responseEnvelope.error) {
                        reject(new retry.StopError(responseEnvelope.error));
                        return;
                    }

                    if (responseEnvelope.auth_ticket) self.authTicket = responseEnvelope.auth_ticket;

                    if (responseEnvelope.status_code === 53 ||
                        (responseEnvelope.status_code === 2 && self.endpoint === INITIAL_ENDPOINT)) {
                        resolve(self.redirect(requests, signedEnvelope, responseEnvelope));
                        return;
                    }

                    responseEnvelope.platform_returns.forEach(platformReturn => {
                        if (platformReturn.type === PlatformRequestType.UNKNOWN_PTR_8) {
                            const ptr8 = PlatformResponses.UnknownPtr8Response.decode(platformReturn.response);
                            if (ptr8) self.ptr8 = ptr8.message;
                        }
                    });

                    /* Throttling, retry same request later */
                    if (responseEnvelope.status_code === 52) {
                        signedEnvelope.platform_requests = [];
                        Promise.delay(2000).then(() => {
                            resolve(self.callRPC(requests, signedEnvelope));
                        });
                        return;
                    }

                    /* These codes indicate invalid input, no use in retrying so throw StopError */
                    if (responseEnvelope.status_code === 3 || responseEnvelope.status_code === 51 ||
                        responseEnvelope.status_code >= 100) {
                        reject(new retry.StopError(
                            `Status code ${responseEnvelope.status_code} received from RPC`
                        ));
                    }

                    /* These can be temporary so throw regular Error */
                    if (responseEnvelope.status_code !== 2 && responseEnvelope.status_code !== 1) {
                        reject(Error(
                            `Status code ${responseEnvelope.status_code} received from RPC`
                        ));
                        return;
                    }

                    var responses = [];

                    if (requests) {
                        if (requests.length !== responseEnvelope.returns.length) {
                            reject(Error('Request count does not match response count'));
                            return;
                        }

                        for (var i = 0; i < responseEnvelope.returns.length; i++) {
                            if (!requests[i].responseType) continue;

                            var responseMessage;
                            try {
                                responseMessage = requests[i].responseType.decode(
                                    responseEnvelope.returns[i]
                                );
                            } catch (e) {
                                self.emit('parse-response-error',
                                    responseEnvelope.returns[i].toBuffer(), e);
                                reject(new retry.StopError(e));
                                return;
                            }

                            if (self.options.includeRequestTypeInResponse) {
                                // eslint-disable-next-line no-underscore-dangle
                                responseMessage._requestType = requests[i].type;
                            }
                            responses.push(responseMessage);
                        }
                    }

                    self.emit('response', {
                        status_code: responseEnvelope.status_code,
                        request_id: responseEnvelope.request_id.toString(),
                        responses: responses.map((r, h) => ({
                            name: Utils.getEnumKeyByValue(
                                RequestType, requests[h].type
                            ),
                            type: requests[h].type,
                            data: r
                        }))
                    });

                    if (self.options.automaticLongConversion) {
                        responses = Utils.convertLongs(responses);
                    }

                    if (!responses.length) resolve(true);
                    else if (responses.length === 1) resolve(responses[0]);
                    else resolve(responses);
                });
            }));
    };

    /**
     * Processes the data received from the downloadSettings API call during init().
     * @private
     * @param {Object} settingsResponse - Response from API call
     * @return {Object} response - Unomdified response (to send back to Promise)
     */
    this.processSettingsResponse = function(settingsResponse) {
        // Extract the minimum delay of getMapObjects()
        if (settingsResponse &&
            !settingsResponse.error &&
            settingsResponse.settings &&
            settingsResponse.settings.map_settings &&
            settingsResponse.settings.map_settings.get_map_objects_min_refresh_seconds
        ) {
            self.setOption('mapObjectsMinDelay',
                settingsResponse.settings.map_settings.get_map_objects_min_refresh_seconds * 1000);
        }
        return settingsResponse;
    };

    /**
     * Makes an initial call to the hashing server to verify API version.
     * @private
     * @return {Promise}
     */
    this.initializeHashingServer = function() {
        if (!self.options.hashingServer) throw new Error('Hashing server enabled without host');
        if (!self.options.hashingKey) throw new Error('Hashing server enabled without key');

        if (self.options.hashingServer.slice(-1) !== '/') {
            self.setOption('hashingServer', self.options.hashingServer + '/');
        }

        return Signature.versions.getHashingEndpoint(self.options.hashingServer, self.options.version)
                .then(version => {
                    self.hashingVersion = version;
                });
    };

    /*
     * DEPRECATED METHODS
     */

    /**
     * Sets the authType and authToken options.
     * @deprecated Use options object or setOption() instead
     * @param {string} authType
     * @param {string} authToken
     */
    this.setAuthInfo = function(authType, authToken) {
        self.setOption('authType', authType);
        self.setOption('authToken', authToken);
    };

    /**
     * Sets the includeRequestTypeInResponse option.
     * @deprecated Use options object or setOption() instead
     * @param {bool} includeRequestTypeInResponse
     */
    this.setIncludeRequestTypeInResponse = function(includeRequestTypeInResponse) {
        self.setOption('includeRequestTypeInResponse', includeRequestTypeInResponse);
    };

    /**
     * Sets the maxTries option.
     * @deprecated Use options object or setOption() instead
     * @param {integer} maxTries
     */
    this.setMaxTries = function(maxTries) {
        self.setOption('maxTries', maxTries);
    };

    /**
     * Sets the proxy option.
     * @deprecated Use options object or setOption() instead
     * @param {string} proxy
     */
    this.setProxy = function(proxy) {
        self.setOption('proxy', proxy);
    };

    /**
     * Sets the mapObjectsThrottling option.
     * @deprecated Use options object or setOption() instead
     * @param {boolean} enable
     */
    this.setMapObjectsThrottlingEnabled = function(enable) {
        self.setOption('mapObjectsThrottling', enable);
    };

    /**
     * Sets a callback to be called for any envelope or request just before it is sent to
     * the server (mostly for debugging purposes).
     * @deprecated Use the raw-request event instead
     * @param {function} callback - function to call on requests
     */
    this.setRequestCallback = function(callback) {
        self.on('raw-request', callback);
    };

    /**
     * Sets a callback to be called for any envelope or response just after it has been
     * received from the server (mostly for debugging purposes).
     * @deprecated Use the raw-response event instead
     * @param {function} callback - function to call on responses
     */
    this.setResponseCallback = function(callback) {
        self.on('raw-response', callback);
    };

    /**
     * Sets the automaticLongConversion option.
     * @deprecated Use options object or setOption() instead
     * @param {boolean} enable
     */
    this.setAutomaticLongConversionEnabled = function(enable) {
        if (typeof enable !== 'boolean') return;
        self.setOption('automaticLongConversion', enable);
    };
}

Client.prototype = Object.create(EventEmitter.prototype);

module.exports = Client;