text: "use strict";
  
  const _ = require("lodash");
  const log = require("./log");
  const colors = require("chalk");
  const Chan = require("./models/chan");
  const crypto = require("crypto");
  const Msg = require("./models/msg");
  const Network = require("./models/network");
  const Helper = require("./helper");
  const UAParser = require("ua-parser-js");
  const uuidv4 = require("uuid/v4");
  const escapeRegExp = require("lodash/escapeRegExp");
  
  const MessageStorage = require("./plugins/messageStorage/sqlite");
  const TextFileMessageStorage = require("./plugins/messageStorage/text");
  
  module.exports = Client;
  
  const events = [
  	"away",
  	"connection",
  	"unhandled",
  	"banlist",
  	"ctcp",
  	"chghost",
  	"error",
  	"invite",
  	"join",
  	"kick",
  	"mode",
  	"motd",
  	"message",
  	"names",
  	"nick",
  	"part",
  	"quit",
  	"topic",
  	"welcome",
  	"list",
  	"whois",
  ];
  const inputs = [
  	"ban",
  	"ctcp",
  	"msg",
  	"part",
  	"rejoin",
  	"action",
  	"away",
  	"connect",
  	"disconnect",
  	"ignore",
  	"invite",
  	"kick",
  	"kill",
  	"mode",
  	"nick",
  	"notice",
  	"query",
  	"quit",
  	"raw",
  	"topic",
  	"list",
  	"whois",
  ].reduce(function(plugins, name) {
  	const plugin = require(`./plugins/inputs/${name}`);
  	plugin.commands.forEach((command) => plugins[command] = plugin);
  	return plugins;
  }, {});
  
  function Client(manager, name, config = {}) {
  	_.merge(this, {
  		awayMessage: config.awayMessage || "",
  		lastActiveChannel: -1,
  		attachedClients: {},
  		config: config,
  		id: uuidv4(),
  		idChan: 1,
  		idMsg: 1,
  		name: name,
  		networks: [],
  		sockets: manager.sockets,
  		manager: manager,
  		messageStorage: [],
  		highlightRegex: null,
  	});
  
  	const client = this;
  	let delay = 0;
  
  	if (!Helper.config.public && client.config.log) {
  		if (Helper.config.messageStorage.includes("sqlite")) {
  			client.messageStorage.push(new MessageStorage(client));
  		}
  
  		if (Helper.config.messageStorage.includes("text")) {
  			client.messageStorage.push(new TextFileMessageStorage(client));
  		}
  
  		for (const messageStorage of client.messageStorage) {
  			messageStorage.enable();
  		}
  	}
  
  	(client.config.networks || []).forEach((n) => {
  		setTimeout(function() {
  			client.connect(n);
  		}, delay);
  		delay += 1000;
  	});
  
  	if (typeof client.config.sessions !== "object") {
  		client.config.sessions = {};
  	}
  
  	if (typeof client.config.clientSettings !== "object") {
  		client.config.clientSettings = {};
  	}
  
  	client.compileCustomHighlights();
  
  	_.forOwn(client.config.sessions, (session) => {
  		if (session.pushSubscription) {
  			this.registerPushSubscription(session, session.pushSubscription, true);
  		}
  	});
  
  	if (client.name) {
  		log.info(`User ${colors.bold(client.name)} loaded`);
  	}
  }
  
  Client.prototype.createChannel = function(attr) {
  	const chan = new Chan(attr);
  	chan.id = this.idChan++;
  
  	return chan;
  };
  
  Client.prototype.emit = function(event, data) {
  	if (this.sockets !== null) {
  		this.sockets.in(this.id).emit(event, data);
  	}
  };
  
  Client.prototype.find = function(channelId) {
  	let network = null;
  	let chan = null;
  
  	for (const i in this.networks) {
  		const n = this.networks[i];
  		chan = _.find(n.channels, {id: channelId});
  
  		if (chan) {
  			network = n;
  			break;
  		}
  	}
  
  	if (network && chan) {
  		return {network, chan};
  	}
  
  	return false;
  };
  
  Client.prototype.connect = function(args) {
  	const client = this;
  	let channels = [];
  
  	// Get channel id for lobby before creating other channels for nicer ids
  	const lobbyChannelId = client.idChan++;
  
  	if (args.channels) {
  		let badName = false;
  
  		args.channels.forEach((chan) => {
  			if (!chan.name) {
  				badName = true;
  				return;
  			}
  
  			channels.push(client.createChannel({
  				name: chan.name,
  				key: chan.key || "",
  				type: chan.type,
  			}));
  		});
  
  		if (badName && client.name) {
  			log.warn("User '" + client.name + "' on network '" + args.name + "' has an invalid channel which has been ignored");
  		}
  	// `join` is kept for backwards compatibility when updating from versions <2.0
  	// also used by the "connect" window
  	} else if (args.join) {
  		channels = args.join
  			.replace(/,/g, " ")
  			.split(/\s+/g)
  			.map((chan) => {
  				if (!chan.match(/^[#&!+]/)) {
  					chan = `#${chan}`;
  				}
  
  				return client.createChannel({
  					name: chan,
  				});
  			});
  	}
  
  	const network = new Network({
  		uuid: args.uuid,
  		name: String(args.name || (Helper.config.displayNetwork ? "" : Helper.config.defaults.name) || ""),
  		host: String(args.host || ""),
  		port: parseInt(args.port, 10),
  		tls: !!args.tls,
  		userDisconnected: !!args.userDisconnected,
  		rejectUnauthorized: !!args.rejectUnauthorized,
  		password: String(args.password || ""),
  		nick: String(args.nick || ""),
  		username: String(args.username || ""),
  		realname: String(args.realname || ""),
  		commands: args.commands || [],
  		ip: args.ip || (client.config && client.config.ip) || client.ip,
  		hostname: args.hostname || (client.config && client.config.hostname) || client.hostname,
  		channels: channels,
  		ignoreList: args.ignoreList ? args.ignoreList : [],
  	});
  
  	// Set network lobby channel id
  	network.channels[0].id = lobbyChannelId;
  
  	client.networks.push(network);
  	client.emit("network", {
  		networks: [network.getFilteredClone(this.lastActiveChannel, -1)],
  	});
  
  	if (!network.validate(client)) {
  		return;
  	}
  
  	network.createIrcFramework(client);
  
  	events.forEach((plugin) => {
  		require(`./plugins/irc-events/${plugin}`).apply(client, [
  			network.irc,
  			network,
  		]);
  	});
  
  	if (network.userDisconnected) {
  		network.channels[0].pushMessage(client, new Msg({
  			text: "You have manually disconnected from this network before, use the /connect command to connect again.",
  		}), true);
  	} else {
  		network.irc.connect();
  	}
  
  	client.save();
  
  	channels.forEach((channel) => channel.loadMessages(client, network));
  };
  
  Client.prototype.generateToken = function(callback) {
  	crypto.randomBytes(64, (err, buf) => {
  		if (err) {
  			throw err;
  		}
  
  		callback(buf.toString("hex"));
  	});
  };
  
  Client.prototype.calculateTokenHash = function(token) {
  	return crypto.createHash("sha512").update(token).digest("hex");
  };
  
  Client.prototype.updateSession = function(token, ip, request) {
  	const client = this;
  	const agent = UAParser(request.headers["user-agent"] || "");
  	let friendlyAgent = "";
  
  	if (agent.browser.name) {
  		friendlyAgent = `${agent.browser.name} ${agent.browser.major}`;
  	} else {
  		friendlyAgent = "Unknown browser";
  	}
  
  	if (agent.os.name) {
  		friendlyAgent += ` on ${agent.os.name}`;
  
  		if (agent.os.version) {
  			friendlyAgent += ` ${agent.os.version}`;
  		}
  	}
  
  	client.config.sessions[token] = _.assign(client.config.sessions[token], {
  		lastUse: Date.now(),
  		ip: ip,
  		agent: friendlyAgent,
  	});
  
  	client.manager.updateUser(client.name, {
  		sessions: client.config.sessions,
  	});
  };
  
  Client.prototype.setPassword = function(hash, callback) {
  	const client = this;
  
  	client.manager.updateUser(client.name, {
  		password: hash,
  	}, function(err) {
  		if (err) {
  			return callback(false);
  		}
  
  		client.config.password = hash;
  		return callback(true);
  	});
  };
  
  Client.prototype.input = function(data) {
  	const client = this;
  	data.text.split("\n").forEach((line) => {
  		data.text = line;
  		client.inputLine(data);
  	});
  };
  
  Client.prototype.inputLine = function(data) {
  	const client = this;
  	const target = client.find(data.target);
  
  	if (!target) {
  		return;
  	}
  
  	// Sending a message to a channel is higher priority than merely opening one
  	// so that reloading the page will open this channel
  	this.lastActiveChannel = target.chan.id;
  
  	let text = data.text;
  
  	// This is either a normal message or a command escaped with a leading '/'
  	if (text.charAt(0) !== "/" || text.charAt(1) === "/") {
  		if (target.chan.type === Chan.Type.LOBBY) {
  			target.chan.pushMessage(this, new Msg({
  				type: Msg.Type.ERROR,
  				text: "Messages can not be sent to lobbies.",
  			}));
  			return;
  		}
  
  		text = "say " + text.replace(/^\//, "");
  	} else {
  		text = text.substr(1);
  	}
  
  	const args = text.split(" ");
  	const cmd = args.shift().toLowerCase();
  
  	const irc = target.network.irc;
  	let connected = irc && irc.connection && irc.connection.connected;
  
  	if (inputs.hasOwnProperty(cmd) && typeof inputs[cmd].input === "function") {
  		const plugin = inputs[cmd];
  
  		if (connected || plugin.allowDisconnected) {
  			connected = true;
  			plugin.input.apply(client, [target.network, target.chan, cmd, args]);
  		}
  	} else if (connected) {
  		irc.raw(text);
  	}
  
  	if (!connected) {
  		target.chan.pushMessage(this, new Msg({
  			type: Msg.Type.ERROR,
  			text: "You are not connected to the IRC network, unable to send your command.",
  		}));
  	}
  };
  
  Client.prototype.compileCustomHighlights = function() {
  	const client = this;
  
  	if (typeof client.config.clientSettings.highlights !== "string") {
  		client.highlightRegex = null;
  		return;
  	}
  
  	// Ensure we don't have empty string in the list of highlights
  	// otherwise, users get notifications for everything
  	const highlightsTokens = client.config.clientSettings.highlights
  		.split(",")
  		.map((highlight) => escapeRegExp(highlight.trim()))
  		.filter((highlight) => highlight.length > 0);
  
  	if (highlightsTokens.length === 0) {
  		client.highlightRegex = null;
  		return;
  	}
  
  	client.highlightRegex = new RegExp(`(?:^| |\t)(?:${highlightsTokens.join("|")})(?:\t| |$)`, "i");
  };
  
  Client.prototype.more = function(data) {
  	const client = this;
  	const target = client.find(data.target);
  
  	if (!target) {
  		return null;
  	}
  
  	const chan = target.chan;
  	let messages = [];
  	let index = 0;
  
  	// If client requests -1, send last 100 messages
  	if (data.lastId < 0) {
  		index = chan.messages.length;
  	} else {
  		index = chan.messages.findIndex((val) => val.id === data.lastId);
  	}
  
  	// If requested id is not found, an empty array will be sent
  	if (index > 0) {
  		messages = chan.messages.slice(Math.max(0, index - 100), index);
  	}
  
  	return {
  		chan: chan.id,
  		messages: messages,
  		moreHistoryAvailable: index > 100,
  	};
  };
  
  Client.prototype.open = function(socketId, target) {
  	// Due to how socket.io works internally, normal events may arrive later than
  	// the disconnect event, and because we can't control this timing precisely,
  	// process this event normally even if there is no attached client anymore.
  	const attachedClient = this.attachedClients[socketId] || {};
  
  	// Opening a window like settings
  	if (target === null) {
  		attachedClient.openChannel = -1;
  		return;
  	}
  
  	target = this.find(target);
  
  	if (!target) {
  		return;
  	}
  
  	target.chan.unread = 0;
  	target.chan.highlight = 0;
  
  	if (target.chan.messages.length > 0) {
  		target.chan.firstUnread = target.chan.messages[target.chan.messages.length - 1].id;
  	}
  
  	attachedClient.openChannel = target.chan.id;
  	this.lastActiveChannel = target.chan.id;
  
  	this.emit("open", target.chan.id);
  };
  
  Client.prototype.sort = function(data) {
  	const order = data.order;
  
  	if (!_.isArray(order)) {
  		return;
  	}
  
  	switch (data.type) {
  	case "networks":
  		this.networks.sort((a, b) => order.indexOf(a.uuid) - order.indexOf(b.uuid));
  
  		// Sync order to connected clients
  		this.emit("sync_sort", {
  			order: this.networks.map((obj) => obj.uuid),
  			type: data.type,
  		});
  
  		break;
  
  	case "channels": {
  		const network = _.find(this.networks, {uuid: data.target});
  
  		if (!network) {
  			return;
  		}
  
  		network.channels.sort((a, b) => {
  			// Always sort lobby to the top regardless of what the client has sent
  			// Because there's a lot of code that presumes channels[0] is the lobby
  			if (a.type === Chan.Type.LOBBY) {
  				return -1;
  			} else if (b.type === Chan.Type.LOBBY) {
  				return 1;
  			}
  
  			return order.indexOf(a.id) - order.indexOf(b.id);
  		});
  
  		// Sync order to connected clients
  		this.emit("sync_sort", {
  			order: network.channels.map((obj) => obj.id),
  			type: data.type,
  			target: network.uuid,
  		});
  
  		break;
  	}
  	}
  
  	this.save();
  };
  
  Client.prototype.names = function(data) {
  	const client = this;
  	const target = client.find(data.target);
  
  	if (!target) {
  		return;
  	}
  
  	client.emit("names", {
  		id: target.chan.id,
  		users: target.chan.getSortedUsers(target.network.irc),
  	});
  };
  
  Client.prototype.quit = function(signOut) {
  	const sockets = this.sockets.sockets;
  	const room = sockets.adapter.rooms[this.id];
  
  	if (room && room.sockets) {
  		for (const user in room.sockets) {
  			const socket = sockets.connected[user];
  
  			if (socket) {
  				if (signOut) {
  					socket.emit("sign-out");
  				}
  
  				socket.disconnect();
  			}
  		}
  	}
  
  	this.networks.forEach((network) => {
  		if (network.irc) {
  			network.irc.quit(Helper.config.leaveMessage);
  		}
  
  		network.destroy();
  	});
  
  	for (const messageStorage of this.messageStorage) {
  		messageStorage.close();
  	}
  };
  
  Client.prototype.clientAttach = function(socketId, token) {
  	const client = this;
  	let save = false;
  
  	if (client.awayMessage && _.size(client.attachedClients) === 0) {
  		client.networks.forEach(function(network) {
  			// Only remove away on client attachment if
  			// there is no away message on this network
  			if (network.irc && !network.awayMessage) {
  				network.irc.raw("AWAY");
  			}
  		});
  	}
  
  	const openChannel = client.lastActiveChannel;
  	client.attachedClients[socketId] = {token, openChannel};
  
  	// Update old networks to store ip and hostmask
  	client.networks.forEach((network) => {
  		if (!network.ip) {
  			save = true;
  			network.ip = (client.config && client.config.ip) || client.ip;
  		}
  
  		if (!network.hostname) {
  			const hostmask = (client.config && client.config.hostname) || client.hostname;
  
  			if (hostmask) {
  				save = true;
  				network.hostmask = hostmask;
  			}
  		}
  	});
  
  	if (save) {
  		client.save();
  	}
  };
  
  Client.prototype.clientDetach = function(socketId) {
  	const client = this;
  
  	delete this.attachedClients[socketId];
  
  	if (client.awayMessage && _.size(client.attachedClients) === 0) {
  		client.networks.forEach(function(network) {
  			// Only set away on client deattachment if
  			// there is no away message on this network
  			if (network.irc && !network.awayMessage) {
  				network.irc.raw("AWAY", client.awayMessage);
  			}
  		});
  	}
  };
  
  Client.prototype.registerPushSubscription = function(session, subscription, noSave) {
  	if (!_.isPlainObject(subscription) || !_.isPlainObject(subscription.keys)
  	|| typeof subscription.endpoint !== "string" || !/^https?:\/\//.test(subscription.endpoint)
  	|| typeof subscription.keys.p256dh !== "string" || typeof subscription.keys.auth !== "string") {
  		session.pushSubscription = null;
  		return;
  	}
  
  	const data = {
  		endpoint: subscription.endpoint,
  		keys: {
  			p256dh: subscription.keys.p256dh,
  			auth: subscription.keys.auth,
  		},
  	};
  
  	session.pushSubscription = data;
  
  	if (!noSave) {
  		this.manager.updateUser(this.name, {
  			sessions: this.config.sessions,
  		});
  	}
  
  	return data;
  };
  
  Client.prototype.unregisterPushSubscription = function(token) {
  	this.config.sessions[token].pushSubscription = null;
  	this.manager.updateUser(this.name, {
  		sessions: this.config.sessions,
  	});
  };
  
  Client.prototype.save = _.debounce(function SaveClient() {
  	if (Helper.config.public) {
  		return;
  	}
  
  	const client = this;
  	const json = {};
  	json.networks = this.networks.map((n) => n.export());
  	client.manager.updateUser(client.name, json);
  }, 1000, {maxWait: 10000});
  