import config from "config";

import async from "async";

import request from "request";
import bcrypt from "bcrypt";
import sha256 from "sha256";
import { isAdminRequired, isLoginRequired } from "./hooks";

import moduleManager from "../../index";

const DBModule = moduleManager.modules.db;
const UtilsModule = moduleManager.modules.utils;
const IOModule = moduleManager.modules.io;
const CacheModule = moduleManager.modules.cache;
const MailModule = moduleManager.modules.mail;
const PunishmentsModule = moduleManager.modules.punishments;
const ActivitiesModule = moduleManager.modules.activities;

CacheModule.runJob("SUB", {
	channel: "user.updateUsername",
	cb: user => {
		IOModule.runJob("SOCKETS_FROM_USER", { userId: user._id }).then(response => {
			response.sockets.forEach(socket => {
				socket.emit("event:user.username.changed", user.username);
			});
		});
	}
});

CacheModule.runJob("SUB", {
	channel: "user.removeSessions",
	cb: userId => {
		IOModule.runJob("SOCKETS_FROM_USER_WITHOUT_CACHE", { userId }).then(response => {
			response.sockets.forEach(socket => {
				socket.emit("keep.event:user.session.removed");
			});
		});
	}
});

CacheModule.runJob("SUB", {
	channel: "user.linkPassword",
	cb: userId => {
		IOModule.runJob("SOCKETS_FROM_USER", { userId }).then(response => {
			response.sockets.forEach(socket => {
				socket.emit("event:user.linkPassword");
			});
		});
	}
});

CacheModule.runJob("SUB", {
	channel: "user.unlinkPassword",
	cb: userId => {
		IOModule.runJob("SOCKETS_FROM_USER", { userId }).then(response => {
			response.sockets.forEach(socket => {
				socket.emit("event:user.unlinkPassword");
			});
		});
	}
});

CacheModule.runJob("SUB", {
	channel: "user.linkGithub",
	cb: userId => {
		IOModule.runJob("SOCKETS_FROM_USER", { userId }).then(response => {
			response.sockets.forEach(socket => {
				socket.emit("event:user.linkGithub");
			});
		});
	}
});

CacheModule.runJob("SUB", {
	channel: "user.unlinkGithub",
	cb: userId => {
		IOModule.runJob("SOCKETS_FROM_USER", { userId }).then(response => {
			response.sockets.forEach(socket => {
				socket.emit("event:user.unlinkGithub");
			});
		});
	}
});

CacheModule.runJob("SUB", {
	channel: "user.ban",
	cb: data => {
		IOModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(response => {
			response.sockets.forEach(socket => {
				socket.emit("keep.event:banned", data.punishment);
				socket.disconnect(true);
			});
		});
	}
});

CacheModule.runJob("SUB", {
	channel: "user.favoritedStation",
	cb: data => {
		IOModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(response => {
			response.sockets.forEach(socket => {
				socket.emit("event:user.favoritedStation", data.stationId);
			});
		});
	}
});

CacheModule.runJob("SUB", {
	channel: "user.unfavoritedStation",
	cb: data => {
		IOModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(response => {
			response.sockets.forEach(socket => {
				socket.emit("event:user.unfavoritedStation", data.stationId);
			});
		});
	}
});

export default {
	/**
	 * Lists all Users
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {Function} cb - gets called with the result
	 */
	index: isAdminRequired(async function index(session, cb) {
		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);

		async.waterfall(
			[
				next => {
					userModel.find({}).exec(next);
				}
			],
			async (err, users) => {
				if (err) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log("ERROR", "USER_INDEX", `Indexing users failed. "${err}"`);
					return cb({ status: "failure", message: err });
				}
				this.log("SUCCESS", "USER_INDEX", `Indexing users successful.`);
				const filteredUsers = [];
				users.forEach(user => {
					filteredUsers.push({
						_id: user._id,
						username: user.username,
						role: user.role,
						liked: user.liked,
						disliked: user.disliked,
						songsRequested: user.statistics.songsRequested,
						email: {
							address: user.email.address,
							verified: user.email.verified
						},
						hasPassword: !!user.services.password,
						services: { github: user.services.github }
					});
				});
				return cb({ status: "success", data: filteredUsers });
			}
		);
	}),

	/**
	 * Logs user in
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} identifier - the email of the user
	 * @param {string} password - the plaintext of the user
	 * @param {Function} cb - gets called with the result
	 */
	async login(session, identifier, password, cb) {
		identifier = identifier.toLowerCase();
		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
		const sessionSchema = await CacheModule.runJob(
			"GET_SCHEMA",
			{
				schemaName: "session"
			},
			this
		);

		async.waterfall(
			[
				// check if a user with the requested identifier exists
				next => {
					userModel.findOne(
						{
							$or: [{ "email.address": identifier }]
						},
						next
					);
				},

				// if the user doesn't exist, respond with a failure
				// otherwise compare the requested password and the actual users password
				(user, next) => {
					if (!user) return next("User not found");
					if (!user.services.password || !user.services.password.password)
						return next("The account you are trying to access uses GitHub to log in.");

					return bcrypt.compare(sha256(password), user.services.password.password, (err, match) => {
						if (err) return next(err);
						if (!match) return next("Incorrect password");
						return next(null, user);
					});
				},

				(user, next) => {
					UtilsModule.runJob("GUID", {}, this).then(sessionId => {
						next(null, user, sessionId);
					});
				},

				(user, sessionId, next) => {
					CacheModule.runJob(
						"HSET",
						{
							table: "sessions",
							key: sessionId,
							value: sessionSchema(sessionId, user._id)
						},
						this
					)
						.then(() => {
							next(null, sessionId);
						})
						.catch(next);
				}
			],
			async (err, sessionId) => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log(
						"ERROR",
						"USER_PASSWORD_LOGIN",
						`Login failed with password for user "${identifier}". "${err}"`
					);
					return cb({ status: "failure", message: err });
				}

				this.log("SUCCESS", "USER_PASSWORD_LOGIN", `Login successful with password for user "${identifier}"`);

				return cb({
					status: "success",
					message: "Login successful",
					user: {},
					SID: sessionId
				});
			}
		);
	},

	/**
	 * Registers a new user
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} username - the username for the new user
	 * @param {string} email - the email for the new user
	 * @param {string} password - the plaintext password for the new user
	 * @param {object} recaptcha - the recaptcha data
	 * @param {Function} cb - gets called with the result
	 */
	async register(session, username, email, password, recaptcha, cb) {
		email = email.toLowerCase();
		const verificationToken = await UtilsModule.runJob(
			"GENERATE_RANDOM_STRING",
			{
				length: 64
			},
			this
		);
		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
		const verifyEmailSchema = await MailModule.runJob(
			"GET_SCHEMA",
			{
				schemaName: "verifyEmail"
			},
			this
		);

		async.waterfall(
			[
				next => {
					if (config.get("registrationDisabled") === true)
						return next("Registration is not allowed at this time.");
					return next();
				},

				next => {
					if (!DBModule.passwordValid(password))
						return next("Invalid password. Check if it meets all the requirements.");
					return next();
				},

				// verify the request with google recaptcha
				next => {
					if (config.get("apis.recaptcha.enabled") === true)
						request(
							{
								url: "https://www.google.com/recaptcha/api/siteverify",
								method: "POST",
								form: {
									secret: config.get("apis").recaptcha.secret,
									response: recaptcha
								}
							},
							next
						);
					else next(null, null, null);
				},

				// check if the response from Google recaptcha is successful
				// if it is, we check if a user with the requested username already exists
				(response, body, next) => {
					if (config.get("apis.recaptcha.enabled") === true) {
						const json = JSON.parse(body);
						if (json.success !== true) return next("Response from recaptcha was not successful.");
					}

					return userModel.findOne({ username: new RegExp(`^${username}$`, "i") }, next);
				},

				// if the user already exists, respond with that
				// otherwise check if a user with the requested email already exists
				(user, next) => {
					if (user) return next("A user with that username already exists.");
					return userModel.findOne({ "email.address": email }, next);
				},

				// if the user already exists, respond with that
				// otherwise, generate a salt to use with hashing the new users password
				(user, next) => {
					if (user) return next("A user with that email already exists.");
					return bcrypt.genSalt(10, next);
				},

				// hash the password
				(salt, next) => {
					bcrypt.hash(sha256(password), salt, next);
				},

				(hash, next) => {
					UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 12 }, this).then(_id => {
						next(null, hash, _id);
					});
				},

				// create the user object
				(hash, _id, next) => {
					next(null, {
						_id,
						username,
						email: {
							address: email,
							verificationToken
						},
						services: {
							password: {
								password: hash
							}
						}
					});
				},

				// generate the url for gravatar avatar
				(user, next) => {
					UtilsModule.runJob(
						"CREATE_GRAVATAR",
						{
							email: user.email.address
						},
						this
					).then(url => {
						user.avatar = {
							type: "gravatar",
							url
						};
						next(null, user);
					});
				},

				// save the new user to the database
				(user, next) => {
					userModel.create(user, next);
				},

				// respond with the new user
				(newUser, next) => {
					verifyEmailSchema(email, username, verificationToken, err => {
						next(err, newUser);
					});
				}
			],
			async (err, user) => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log(
						"ERROR",
						"USER_PASSWORD_REGISTER",
						`Register failed with password for user "${username}"."${err}"`
					);
					return cb({ status: "failure", message: err });
				}

				ActivitiesModule.runJob("ADD_ACTIVITY", {
					userId: user._id,
					activityType: "created_account"
				});
				this.log(
					"SUCCESS",
					"USER_PASSWORD_REGISTER",
					`Register successful with password for user "${username}".`
				);

				const result = await this.module.runJob(
					"RUN_ACTION2",
					{
						session,
						namespace: "users",
						action: "login",
						args: [email, password]
					},
					this
				);

				const obj = {
					status: "success",
					message: "Successfully registered."
				};
				if (result.status === "success") {
					obj.SID = result.SID;
				}

				return cb(obj);
			}
		);
	},

	/**
	 * Logs out a user
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {Function} cb - gets called with the result
	 */
	logout(session, cb) {
		async.waterfall(
			[
				next => {
					CacheModule.runJob(
						"HGET",
						{
							table: "sessions",
							key: session.sessionId
						},
						this
					)
						.then(session => {
							next(null, session);
						})
						.catch(next);
				},

				(session, next) => {
					if (!session) return next("Session not found");
					return next(null, session);
				},

				(session, next) => {
					CacheModule.runJob(
						"HDEL",
						{
							table: "sessions",
							key: session.sessionId
						},
						this
					)
						.then(() => {
							next();
						})
						.catch(next);
				}
			],
			async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log("ERROR", "USER_LOGOUT", `Logout failed. "${err}" `);
					cb({ status: "failure", message: err });
				} else {
					this.log("SUCCESS", "USER_LOGOUT", `Logout successful.`);
					cb({
						status: "success",
						message: "Successfully logged out."
					});
				}
			}
		);
	},

	/**
	 * Removes all sessions for a user
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} userId - the id of the user we are trying to delete the sessions of
	 * @param {Function} cb - gets called with the result
	 */
	removeSessions: isLoginRequired(async function removeSessions(session, userId, cb) {
		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
		async.waterfall(
			[
				next => {
					userModel.findOne({ _id: session.userId }, (err, user) => {
						if (err) return next(err);
						if (user.role !== "admin" && session.userId !== userId)
							return next("Only admins and the owner of the account can remove their sessions.");
						return next();
					});
				},

				next => {
					CacheModule.runJob("HGETALL", { table: "sessions" }, this)
						.then(sessions => {
							next(null, sessions);
						})
						.catch(next);
				},

				(sessions, next) => {
					if (!sessions) return next("There are no sessions for this user to remove.");

					const keys = Object.keys(sessions);

					return next(null, keys, sessions);
				},

				(keys, sessions, next) => {
					CacheModule.runJob("PUB", {
						channel: "user.removeSessions",
						value: userId
					});
					async.each(
						keys,
						(sessionId, callback) => {
							const session = sessions[sessionId];
							if (session.userId === userId) {
								// TODO Also maybe add this to this runJob
								CacheModule.runJob("HDEL", {
									channel: "sessions",
									key: sessionId
								})
									.then(() => {
										callback(null);
									})
									.catch(next);
							}
						},
						err => {
							next(err);
						}
					);
				}
			],
			async err => {
				if (err) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log(
						"ERROR",
						"REMOVE_SESSIONS_FOR_USER",
						`Couldn't remove all sessions for user "${userId}". "${err}"`
					);
					return cb({ status: "failure", message: err });
				}
				this.log("SUCCESS", "REMOVE_SESSIONS_FOR_USER", `Removed all sessions for user "${userId}".`);
				return cb({
					status: "success",
					message: "Successfully removed all sessions."
				});
			}
		);
	}),

	/**
	 * Gets user object from username (only a few properties)
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} username - the username of the user we are trying to find
	 * @param {Function} cb - gets called with the result
	 */
	findByUsername: async function findByUsername(session, username, cb) {
		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);

		async.waterfall(
			[
				next => {
					userModel.findOne({ username: new RegExp(`^${username}$`, "i") }, next);
				},

				(account, next) => {
					if (!account) return next("User not found.");
					return next(null, account);
				}
			],
			async (err, account) => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);

					this.log("ERROR", "FIND_BY_USERNAME", `User not found for username "${username}". "${err}"`);

					return cb({ status: "failure", message: err });
				}

				this.log("SUCCESS", "FIND_BY_USERNAME", `User found for username "${username}".`);

				return cb({
					status: "success",
					data: {
						_id: account._id,
						name: account.name,
						username: account.username,
						location: account.location,
						bio: account.bio,
						role: account.role,
						avatar: account.avatar,
						createdAt: account.createdAt
					}
				});
			}
		);
	},

	/**
	 * Gets a username from an userId
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} userId - the userId of the person we are trying to get the username from
	 * @param {Function} cb - gets called with the result
	 */
	async getUsernameFromId(session, userId, cb) {
		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
		userModel
			.findById(userId)
			.then(user => {
				if (user) {
					this.log("SUCCESS", "GET_USERNAME_FROM_ID", `Found username for userId "${userId}".`);

					return cb({
						status: "success",
						data: user.username
					});
				}

				this.log(
					"ERROR",
					"GET_USERNAME_FROM_ID",
					`Getting the username from userId "${userId}" failed. User not found.`
				);

				return cb({
					status: "failure",
					message: "Couldn't find the user."
				});
			})
			.catch(async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log(
						"ERROR",
						"GET_USERNAME_FROM_ID",
						`Getting the username from userId "${userId}" failed. "${err}"`
					);
					cb({ status: "failure", message: err });
				}
			});
	},

	// TODO Fix security issues
	/**
	 * Gets user info from session
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {Function} cb - gets called with the result
	 */
	async findBySession(session, cb) {
		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);

		async.waterfall(
			[
				next => {
					CacheModule.runJob(
						"HGET",
						{
							table: "sessions",
							key: session.sessionId
						},
						this
					)
						.then(session => {
							next(null, session);
						})
						.catch(next);
				},

				(session, next) => {
					if (!session) return next("Session not found.");
					return next(null, session);
				},

				(session, next) => {
					userModel.findOne({ _id: session.userId }, next);
				},

				(user, next) => {
					if (!user) return next("User not found.");
					return next(null, user);
				}
			],
			async (err, user) => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log("ERROR", "FIND_BY_SESSION", `User not found. "${err}"`);
					return cb({ status: "failure", message: err });
				}

				const data = {
					email: {
						address: user.email.address
					},
					avatar: user.avatar,
					username: user.username,
					name: user.name,
					location: user.location,
					bio: user.bio
				};

				if (user.services.password && user.services.password.password) data.password = true;
				if (user.services.github && user.services.github.id) data.github = true;

				this.log("SUCCESS", "FIND_BY_SESSION", `User found. "${user.username}".`);
				return cb({
					status: "success",
					data
				});
			}
		);
	},

	/**
	 * Updates a user's username
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} updatingUserId - the updating user's id
	 * @param {string} newUsername - the new username
	 * @param {Function} cb - gets called with the result
	 */
	updateUsername: isLoginRequired(async function updateUsername(session, updatingUserId, newUsername, cb) {
		const userModel = await DBModule.runJob(
			"GET_MODEL",
			{
				modelName: "user"
			},
			this
		);
		async.waterfall(
			[
				next => {
					if (updatingUserId === session.userId) return next(null, true);
					return userModel.findOne({ _id: session.userId }, next);
				},

				(user, next) => {
					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
					return userModel.findOne({ _id: updatingUserId }, next);
				},

				(user, next) => {
					if (!user) return next("User not found.");
					if (user.username === newUsername)
						return next("New username can't be the same as the old username.");
					return next(null);
				},

				next => {
					userModel.findOne({ username: new RegExp(`^${newUsername}$`, "i") }, next);
				},

				(user, next) => {
					if (!user) return next();
					if (user._id === updatingUserId) return next();
					return next("That username is already in use.");
				},

				next => {
					userModel.updateOne(
						{ _id: updatingUserId },
						{ $set: { username: newUsername } },
						{ runValidators: true },
						next
					);
				}
			],
			async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);

					this.log(
						"ERROR",
						"UPDATE_USERNAME",
						`Couldn't update username for user "${updatingUserId}" to username "${newUsername}". "${err}"`
					);

					return cb({ status: "failure", message: err });
				}

				CacheModule.runJob("PUB", {
					channel: "user.updateUsername",
					value: {
						username: newUsername,
						_id: updatingUserId
					}
				});

				this.log(
					"SUCCESS",
					"UPDATE_USERNAME",
					`Updated username for user "${updatingUserId}" to username "${newUsername}".`
				);

				return cb({
					status: "success",
					message: "Username updated successfully"
				});
			}
		);
	}),

	/**
	 * Updates a user's email
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} updatingUserId - the updating user's id
	 * @param {string} newEmail - the new email
	 * @param {Function} cb - gets called with the result
	 */
	updateEmail: isLoginRequired(async function updateEmail(session, updatingUserId, newEmail, cb) {
		newEmail = newEmail.toLowerCase();
		const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);

		const userModel = await DBModule.runJob(
			"GET_MODEL",
			{
				modelName: "user"
			},
			this
		);
		const verifyEmailSchema = await MailModule.runJob(
			"GET_SCHEMA",
			{
				schemaName: "verifyEmail"
			},
			this
		);

		async.waterfall(
			[
				next => {
					if (updatingUserId === session.userId) return next(null, true);
					return userModel.findOne({ _id: session.userId }, next);
				},

				(user, next) => {
					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
					return userModel.findOne({ _id: updatingUserId }, next);
				},

				(user, next) => {
					if (!user) return next("User not found.");
					if (user.email.address === newEmail)
						return next("New email can't be the same as your the old email.");
					return next();
				},

				next => {
					userModel.findOne({ "email.address": newEmail }, next);
				},

				(user, next) => {
					if (!user) return next();
					if (user._id === updatingUserId) return next();
					return next("That email is already in use.");
				},

				// regenerate the url for gravatar avatar
				next => {
					UtilsModule.runJob("CREATE_GRAVATAR", { email: newEmail }, this).then(url => {
						next(null, url);
					});
				},

				(newAvatarUrl, next) => {
					userModel.updateOne(
						{ _id: updatingUserId },
						{
							$set: {
								"avatar.url": newAvatarUrl,
								"email.address": newEmail,
								"email.verified": false,
								"email.verificationToken": verificationToken
							}
						},
						{ runValidators: true },
						next
					);
				},

				(res, next) => {
					userModel.findOne({ _id: updatingUserId }, next);
				},

				(user, next) => {
					verifyEmailSchema(newEmail, user.username, verificationToken, err => {
						next(err);
					});
				}
			],
			async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);

					this.log(
						"ERROR",
						"UPDATE_EMAIL",
						`Couldn't update email for user "${updatingUserId}" to email "${newEmail}". '${err}'`
					);

					return cb({ status: "failure", message: err });
				}

				this.log(
					"SUCCESS",
					"UPDATE_EMAIL",
					`Updated email for user "${updatingUserId}" to email "${newEmail}".`
				);

				return cb({
					status: "success",
					message: "Email updated successfully."
				});
			}
		);
	}),

	/**
	 * Updates a user's name
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} updatingUserId - the updating user's id
	 * @param {string} newBio - the new name
	 * @param {Function} cb - gets called with the result
	 */
	updateName: isLoginRequired(async function updateName(session, updatingUserId, newName, cb) {
		const userModel = await DBModule.runJob(
			"GET_MODEL",
			{
				modelName: "user"
			},
			this
		);

		async.waterfall(
			[
				next => {
					if (updatingUserId === session.userId) return next(null, true);
					return userModel.findOne({ _id: session.userId }, next);
				},

				(user, next) => {
					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
					return userModel.findOne({ _id: updatingUserId }, next);
				},

				(user, next) => {
					if (!user) return next("User not found.");
					return userModel.updateOne(
						{ _id: updatingUserId },
						{ $set: { name: newName } },
						{ runValidators: true },
						next
					);
				}
			],
			async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log(
						"ERROR",
						"UPDATE_NAME",
						`Couldn't update name for user "${updatingUserId}" to name "${newName}". "${err}"`
					);
					cb({ status: "failure", message: err });
				} else {
					this.log(
						"SUCCESS",
						"UPDATE_NAME",
						`Updated name for user "${updatingUserId}" to name "${newName}".`
					);
					cb({
						status: "success",
						message: "Name updated successfully"
					});
				}
			}
		);
	}),

	/**
	 * Updates a user's location
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} updatingUserId - the updating user's id
	 * @param {string} newLocation - the new location
	 * @param {Function} cb - gets called with the result
	 */
	updateLocation: isLoginRequired(async function updateLocation(session, updatingUserId, newLocation, cb) {
		const userModel = await DBModule.runJob(
			"GET_MODEL",
			{
				modelName: "user"
			},
			this
		);

		async.waterfall(
			[
				next => {
					if (updatingUserId === session.userId) return next(null, true);
					return userModel.findOne({ _id: session.userId }, next);
				},

				(user, next) => {
					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
					return userModel.findOne({ _id: updatingUserId }, next);
				},

				(user, next) => {
					if (!user) return next("User not found.");
					return userModel.updateOne(
						{ _id: updatingUserId },
						{ $set: { location: newLocation } },
						{ runValidators: true },
						next
					);
				}
			],
			async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);

					this.log(
						"ERROR",
						"UPDATE_LOCATION",
						`Couldn't update location for user "${updatingUserId}" to location "${newLocation}". "${err}"`
					);

					return cb({ status: "failure", message: err });
				}

				this.log(
					"SUCCESS",
					"UPDATE_LOCATION",
					`Updated location for user "${updatingUserId}" to location "${newLocation}".`
				);

				return cb({
					status: "success",
					message: "Location updated successfully"
				});
			}
		);
	}),

	/**
	 * Updates a user's bio
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} updatingUserId - the updating user's id
	 * @param {string} newBio - the new bio
	 * @param {Function} cb - gets called with the result
	 */
	updateBio: isLoginRequired(async function updateBio(session, updatingUserId, newBio, cb) {
		const userModel = await DBModule.runJob(
			"GET_MODEL",
			{
				modelName: "user"
			},
			this
		);

		async.waterfall(
			[
				next => {
					if (updatingUserId === session.userId) return next(null, true);
					return userModel.findOne({ _id: session.userId }, next);
				},

				(user, next) => {
					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
					return userModel.findOne({ _id: updatingUserId }, next);
				},

				(user, next) => {
					if (!user) return next("User not found.");
					return userModel.updateOne(
						{ _id: updatingUserId },
						{ $set: { bio: newBio } },
						{ runValidators: true },
						next
					);
				}
			],
			async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log(
						"ERROR",
						"UPDATE_BIO",
						`Couldn't update bio for user "${updatingUserId}" to bio "${newBio}". "${err}"`
					);
					cb({ status: "failure", message: err });
				} else {
					this.log("SUCCESS", "UPDATE_BIO", `Updated bio for user "${updatingUserId}" to bio "${newBio}".`);
					cb({
						status: "success",
						message: "Bio updated successfully"
					});
				}
			}
		);
	}),

	/**
	 * Updates the type of a user's avatar
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} updatingUserId - the updating user's id
	 * @param {string} newType - the new type
	 * @param {Function} cb - gets called with the result
	 */
	updateAvatarType: isLoginRequired(async function updateAvatarType(session, updatingUserId, newType, cb) {
		const userModel = await DBModule.runJob(
			"GET_MODEL",
			{
				modelName: "user"
			},
			this
		);

		async.waterfall(
			[
				next => {
					if (updatingUserId === session.userId) return next(null, true);
					return userModel.findOne({ _id: session.userId }, next);
				},

				(user, next) => {
					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
					return userModel.findOne({ _id: updatingUserId }, next);
				},

				(user, next) => {
					if (!user) return next("User not found.");
					return userModel.findOneAndUpdate(
						{ _id: updatingUserId },
						{ $set: { "avatar.type": newType } },
						{ new: true, runValidators: true },
						next
					);
				}
			],
			async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log(
						"ERROR",
						"UPDATE_AVATAR_TYPE",
						`Couldn't update avatar type for user "${updatingUserId}" to type "${newType}". "${err}"`
					);
					return cb({ status: "failure", message: err });
				}

				this.log(
					"SUCCESS",
					"UPDATE_AVATAR_TYPE",
					`Updated avatar type for user "${updatingUserId}" to type "${newType}".`
				);

				return cb({
					status: "success",
					message: "Avatar type updated successfully"
				});
			}
		);
	}),

	/**
	 * Updates a user's role
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} updatingUserId - the updating user's id
	 * @param {string} newRole - the new role
	 * @param {Function} cb - gets called with the result
	 */
	updateRole: isAdminRequired(async function updateRole(session, updatingUserId, newRole, cb) {
		newRole = newRole.toLowerCase();
		const userModel = await DBModule.runJob(
			"GET_MODEL",
			{
				modelName: "user"
			},
			this
		);
		async.waterfall(
			[
				next => {
					userModel.findOne({ _id: updatingUserId }, next);
				},

				(user, next) => {
					if (!user) return next("User not found.");
					if (user.role === newRole) return next("New role can't be the same as the old role.");
					return next();
				},
				next => {
					userModel.updateOne(
						{ _id: updatingUserId },
						{ $set: { role: newRole } },
						{ runValidators: true },
						next
					);
				}
			],
			async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);

					this.log(
						"ERROR",
						"UPDATE_ROLE",
						`User "${session.userId}" couldn't update role for user "${updatingUserId}" to role "${newRole}". "${err}"`
					);

					return cb({ status: "failure", message: err });
				}

				this.log(
					"SUCCESS",
					"UPDATE_ROLE",
					`User "${session.userId}" updated the role of user "${updatingUserId}" to role "${newRole}".`
				);

				return cb({
					status: "success",
					message: "Role successfully updated."
				});
			}
		);
	}),

	/**
	 * Updates a user's password
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} previousPassword - the previous password
	 * @param {string} newPassword - the new password
	 * @param {Function} cb - gets called with the result
	 */
	updatePassword: isLoginRequired(async function updatePassword(session, previousPassword, newPassword, cb) {
		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);

		async.waterfall(
			[
				next => {
					userModel.findOne({ _id: session.userId }, next);
				},

				(user, next) => {
					if (!user.services.password) return next("This account does not have a password set.");
					return next(null, user.services.password.password);
				},

				(storedPassword, next) => {
					bcrypt.compare(sha256(previousPassword), storedPassword).then(res => {
						if (res) return next();
						return next("Please enter the correct previous password.");
					});
				},

				next => {
					if (!DBModule.passwordValid(newPassword))
						return next("Invalid new password. Check if it meets all the requirements.");
					return next();
				},

				next => {
					bcrypt.genSalt(10, next);
				},

				// hash the password
				(salt, next) => {
					bcrypt.hash(sha256(newPassword), salt, next);
				},

				(hashedPassword, next) => {
					userModel.updateOne(
						{ _id: session.userId },
						{
							$set: {
								"services.password.password": hashedPassword
							}
						},
						next
					);
				}
			],
			async err => {
				if (err) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log(
						"ERROR",
						"UPDATE_PASSWORD",
						`Failed updating user password of user '${session.userId}'. '${err}'.`
					);
					return cb({ status: "failure", message: err });
				}

				this.log("SUCCESS", "UPDATE_PASSWORD", `User '${session.userId}' updated their password.`);
				return cb({
					status: "success",
					message: "Password successfully updated."
				});
			}
		);
	}),

	/**
	 * Requests a password for a session
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} email - the email of the user that requests a password reset
	 * @param {Function} cb - gets called with the result
	 */
	requestPassword: isLoginRequired(async function requestPassword(session, cb) {
		const code = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 8 }, this);
		const passwordRequestSchema = await MailModule.runJob(
			"GET_SCHEMA",
			{
				schemaName: "passwordRequest"
			},
			this
		);
		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
		async.waterfall(
			[
				next => {
					userModel.findOne({ _id: session.userId }, next);
				},

				(user, next) => {
					if (!user) return next("User not found.");
					if (user.services.password && user.services.password.password)
						return next("You already have a password set.");
					return next(null, user);
				},

				(user, next) => {
					const expires = new Date();
					expires.setDate(expires.getDate() + 1);
					userModel.findOneAndUpdate(
						{ "email.address": user.email.address },
						{
							$set: {
								"services.password": {
									set: { code, expires }
								}
							}
						},
						{ runValidators: true },
						next
					);
				},

				(user, next) => {
					passwordRequestSchema(user.email.address, user.username, code, next);
				}
			],
			async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);

					this.log(
						"ERROR",
						"REQUEST_PASSWORD",
						`UserId '${session.userId}' failed to request password. '${err}'`
					);

					return cb({ status: "failure", message: err });
				}

				this.log(
					"SUCCESS",
					"REQUEST_PASSWORD",
					`UserId '${session.userId}' successfully requested a password.`
				);

				return cb({
					status: "success",
					message: "Successfully requested password."
				});
			}
		);
	}),

	/**
	 * Verifies a password code
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} code - the password code
	 * @param {Function} cb - gets called with the result
	 */
	verifyPasswordCode: isLoginRequired(async function verifyPasswordCode(session, code, cb) {
		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
		async.waterfall(
			[
				next => {
					if (!code || typeof code !== "string") return next("Invalid code.");
					return userModel.findOne(
						{
							"services.password.set.code": code,
							_id: session.userId
						},
						next
					);
				},

				(user, next) => {
					if (!user) return next("Invalid code.");
					if (user.services.password.set.expires < new Date()) return next("That code has expired.");
					return next(null);
				}
			],
			async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log("ERROR", "VERIFY_PASSWORD_CODE", `Code '${code}' failed to verify. '${err}'`);
					cb({ status: "failure", message: err });
				} else {
					this.log("SUCCESS", "VERIFY_PASSWORD_CODE", `Code '${code}' successfully verified.`);
					cb({
						status: "success",
						message: "Successfully verified password code."
					});
				}
			}
		);
	}),

	/**
	 * Adds a password to a user with a code
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} code - the password code
	 * @param {string} newPassword - the new password code
	 * @param {Function} cb - gets called with the result
	 */
	changePasswordWithCode: isLoginRequired(async function changePasswordWithCode(session, code, newPassword, cb) {
		const userModel = await DBModule.runJob(
			"GET_MODEL",
			{
				modelName: "user"
			},
			this
		);
		async.waterfall(
			[
				next => {
					if (!code || typeof code !== "string") return next("Invalid code.");
					return userModel.findOne({ "services.password.set.code": code }, next);
				},

				(user, next) => {
					if (!user) return next("Invalid code.");
					if (!user.services.password.set.expires > new Date()) return next("That code has expired.");
					return next();
				},

				next => {
					if (!DBModule.passwordValid(newPassword))
						return next("Invalid password. Check if it meets all the requirements.");
					return next();
				},

				next => {
					bcrypt.genSalt(10, next);
				},

				// hash the password
				(salt, next) => {
					bcrypt.hash(sha256(newPassword), salt, next);
				},

				(hashedPassword, next) => {
					userModel.updateOne(
						{ "services.password.set.code": code },
						{
							$set: {
								"services.password.password": hashedPassword
							},
							$unset: { "services.password.set": "" }
						},
						{ runValidators: true },
						next
					);
				}
			],
			async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log("ERROR", "ADD_PASSWORD_WITH_CODE", `Code '${code}' failed to add password. '${err}'`);
					cb({ status: "failure", message: err });
				} else {
					this.log("SUCCESS", "ADD_PASSWORD_WITH_CODE", `Code '${code}' successfully added password.`);
					CacheModule.runJob("PUB", {
						channel: "user.linkPassword",
						value: session.userId
					});
					cb({
						status: "success",
						message: "Successfully added password."
					});
				}
			}
		);
	}),

	/**
	 * Unlinks password from user
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {Function} cb - gets called with the result
	 */
	unlinkPassword: isLoginRequired(async function unlinkPassword(session, cb) {
		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
		async.waterfall(
			[
				next => {
					userModel.findOne({ _id: session.userId }, next);
				},

				(user, next) => {
					if (!user) return next("Not logged in.");
					if (!user.services.github || !user.services.github.id)
						return next("You can't remove password login without having GitHub login.");
					return userModel.updateOne({ _id: session.userId }, { $unset: { "services.password": "" } }, next);
				}
			],
			async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log(
						"ERROR",
						"UNLINK_PASSWORD",
						`Unlinking password failed for userId '${session.userId}'. '${err}'`
					);
					cb({ status: "failure", message: err });
				} else {
					this.log(
						"SUCCESS",
						"UNLINK_PASSWORD",
						`Unlinking password successful for userId '${session.userId}'.`
					);
					CacheModule.runJob("PUB", {
						channel: "user.unlinkPassword",
						value: session.userId
					});
					cb({
						status: "success",
						message: "Successfully unlinked password."
					});
				}
			}
		);
	}),

	/**
	 * Unlinks GitHub from user
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {Function} cb - gets called with the result
	 */
	unlinkGitHub: isLoginRequired(async function unlinkGitHub(session, cb) {
		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
		async.waterfall(
			[
				next => {
					userModel.findOne({ _id: session.userId }, next);
				},

				(user, next) => {
					if (!user) return next("Not logged in.");
					if (!user.services.password || !user.services.password.password)
						return next("You can't remove GitHub login without having password login.");
					return userModel.updateOne({ _id: session.userId }, { $unset: { "services.github": "" } }, next);
				}
			],
			async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log(
						"ERROR",
						"UNLINK_GITHUB",
						`Unlinking GitHub failed for userId '${session.userId}'. '${err}'`
					);
					cb({ status: "failure", message: err });
				} else {
					this.log("SUCCESS", "UNLINK_GITHUB", `Unlinking GitHub successful for userId '${session.userId}'.`);
					CacheModule.runJob("PUB", {
						channel: "user.unlinkGithub",
						value: session.userId
					});
					cb({
						status: "success",
						message: "Successfully unlinked GitHub."
					});
				}
			}
		);
	}),

	/**
	 * Requests a password reset for an email
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} email - the email of the user that requests a password reset
	 * @param {Function} cb - gets called with the result
	 */
	async requestPasswordReset(session, email, cb) {
		const code = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 8 }, this);
		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);

		const resetPasswordRequestSchema = await MailModule.runJob(
			"GET_SCHEMA",
			{
				schemaName: "resetPasswordRequest"
			},
			this
		);

		async.waterfall(
			[
				next => {
					if (!email || typeof email !== "string") return next("Invalid email.");
					email = email.toLowerCase();
					return userModel.findOne({ "email.address": email }, next);
				},

				(user, next) => {
					if (!user) return next("User not found.");
					if (!user.services.password || !user.services.password.password)
						return next("User does not have a password set, and probably uses GitHub to log in.");
					return next(null, user);
				},

				(user, next) => {
					const expires = new Date();
					expires.setDate(expires.getDate() + 1);
					userModel.findOneAndUpdate(
						{ "email.address": email },
						{
							$set: {
								"services.password.reset": {
									code,
									expires
								}
							}
						},
						{ runValidators: true },
						next
					);
				},

				(user, next) => {
					resetPasswordRequestSchema(user.email.address, user.username, code, next);
				}
			],
			async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log(
						"ERROR",
						"REQUEST_PASSWORD_RESET",
						`Email '${email}' failed to request password reset. '${err}'`
					);
					cb({ status: "failure", message: err });
				} else {
					this.log(
						"SUCCESS",
						"REQUEST_PASSWORD_RESET",
						`Email '${email}' successfully requested a password reset.`
					);
					cb({
						status: "success",
						message: "Successfully requested password reset."
					});
				}
			}
		);
	},

	/**
	 * Verifies a reset code
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} code - the password reset code
	 * @param {Function} cb - gets called with the result
	 */
	async verifyPasswordResetCode(session, code, cb) {
		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
		async.waterfall(
			[
				next => {
					if (!code || typeof code !== "string") return next("Invalid code.");
					return userModel.findOne({ "services.password.reset.code": code }, next);
				},

				(user, next) => {
					if (!user) return next("Invalid code.");
					if (!user.services.password.reset.expires > new Date()) return next("That code has expired.");
					return next(null);
				}
			],
			async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log("ERROR", "VERIFY_PASSWORD_RESET_CODE", `Code '${code}' failed to verify. '${err}'`);
					cb({ status: "failure", message: err });
				} else {
					this.log("SUCCESS", "VERIFY_PASSWORD_RESET_CODE", `Code '${code}' successfully verified.`);
					cb({
						status: "success",
						message: "Successfully verified password reset code."
					});
				}
			}
		);
	},

	/**
	 * Changes a user's password with a reset code
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} code - the password reset code
	 * @param {string} newPassword - the new password reset code
	 * @param {Function} cb - gets called with the result
	 */
	async changePasswordWithResetCode(session, code, newPassword, cb) {
		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
		async.waterfall(
			[
				next => {
					if (!code || typeof code !== "string") return next("Invalid code.");
					return userModel.findOne({ "services.password.reset.code": code }, next);
				},

				(user, next) => {
					if (!user) return next("Invalid code.");
					if (!user.services.password.reset.expires > new Date()) return next("That code has expired.");
					return next();
				},

				next => {
					if (!DBModule.passwordValid(newPassword))
						return next("Invalid password. Check if it meets all the requirements.");
					return next();
				},

				next => {
					bcrypt.genSalt(10, next);
				},

				// hash the password
				(salt, next) => {
					bcrypt.hash(sha256(newPassword), salt, next);
				},

				(hashedPassword, next) => {
					userModel.updateOne(
						{ "services.password.reset.code": code },
						{
							$set: {
								"services.password.password": hashedPassword
							},
							$unset: { "services.password.reset": "" }
						},
						{ runValidators: true },
						next
					);
				}
			],
			async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log(
						"ERROR",
						"CHANGE_PASSWORD_WITH_RESET_CODE",
						`Code '${code}' failed to change password. '${err}'`
					);
					cb({ status: "failure", message: err });
				} else {
					this.log(
						"SUCCESS",
						"CHANGE_PASSWORD_WITH_RESET_CODE",
						`Code '${code}' successfully changed password.`
					);
					cb({
						status: "success",
						message: "Successfully changed password."
					});
				}
			}
		);
	},

	/**
	 * Bans a user by userId
	 *
	 * @param {object} session - the session object automatically added by socket.io
	 * @param {string} value - the user id that is going to be banned
	 * @param {string} reason - the reason for the ban
	 * @param {string} expiresAt - the time the ban expires
	 * @param {Function} cb - gets called with the result
	 */
	banUserById: isAdminRequired(function banUserById(session, userId, reason, expiresAt, cb) {
		async.waterfall(
			[
				next => {
					if (!userId) return next("You must provide a userId to ban.");
					if (!reason) return next("You must provide a reason for the ban.");
					return next();
				},

				next => {
					if (!expiresAt || typeof expiresAt !== "string") return next("Invalid expire date.");
					const date = new Date();
					switch (expiresAt) {
						case "1h":
							expiresAt = date.setHours(date.getHours() + 1);
							break;
						case "12h":
							expiresAt = date.setHours(date.getHours() + 12);
							break;
						case "1d":
							expiresAt = date.setDate(date.getDate() + 1);
							break;
						case "1w":
							expiresAt = date.setDate(date.getDate() + 7);
							break;
						case "1m":
							expiresAt = date.setMonth(date.getMonth() + 1);
							break;
						case "3m":
							expiresAt = date.setMonth(date.getMonth() + 3);
							break;
						case "6m":
							expiresAt = date.setMonth(date.getMonth() + 6);
							break;
						case "1y":
							expiresAt = date.setFullYear(date.getFullYear() + 1);
							break;
						case "never":
							expiresAt = new Date(3093527980800000);
							break;
						default:
							return next("Invalid expire date.");
					}

					return next();
				},

				next => {
					PunishmentsModule.runJob(
						"ADD_PUNISHMENT",
						{
							type: "banUserId",
							value: userId,
							reason,
							expiresAt,
							punishedBy: "" // needs changed
						},
						this
					)
						.then(punishment => next(null, punishment))
						.catch(next);
				},

				(punishment, next) => {
					CacheModule.runJob("PUB", {
						channel: "user.ban",
						value: { userId, punishment }
					});
					next();
				}
			],
			async err => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log(
						"ERROR",
						"BAN_USER_BY_ID",
						`User ${session.userId} failed to ban user ${userId} with the reason ${reason}. '${err}'`
					);
					cb({ status: "failure", message: err });
				} else {
					this.log(
						"SUCCESS",
						"BAN_USER_BY_ID",
						`User ${session.userId} has successfully banned user ${userId} with the reason ${reason}.`
					);
					cb({
						status: "success",
						message: "Successfully banned user."
					});
				}
			}
		);
	}),

	getFavoriteStations: isLoginRequired(async function getFavoriteStations(session, cb) {
		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
		async.waterfall(
			[
				next => {
					userModel.findOne({ _id: session.userId }, next);
				},

				(user, next) => {
					if (!user) return next("User not found.");
					return next(null, user);
				}
			],
			async (err, user) => {
				if (err && err !== true) {
					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
					this.log(
						"ERROR",
						"GET_FAVORITE_STATIONS",
						`User ${session.userId} failed to get favorite stations. '${err}'`
					);
					cb({ status: "failure", message: err });
				} else {
					this.log("SUCCESS", "GET_FAVORITE_STATIONS", `User ${session.userId} got favorite stations.`);
					cb({
						status: "success",
						favoriteStations: user.favoriteStations
					});
				}
			}
		);
	})
};