/**
 * Users
 * Pokemon Showdown - http://pokemonshowdown.com/
 *
 * Most of the communication with users happens here.
 *
 * There are two object types this file introduces:
 * User and Connection.
 *
 * A User object is a user, identified by username. A guest has a
 * username in the form "Guest 12". Any user whose username starts
 * with "Guest" must be a guest; normal users are not allowed to
 * use usernames starting with "Guest".
 *
 * A User can be connected to Pokemon Showdown from any number of tabs
 * or computers at the same time. Each connection is represented by
 * a Connection object. A user tracks its connections in
 * user.connections - if this array is empty, the user is offline.
 *
 * `Users.users` is the global table of all users, a `Map` of `ID:User`.
 * Users should normally be accessed with `Users.get(userid)`
 *
 * `Users.connections` is the global table of all connections, a `Map` of
 * `string:Connection` (the string is mostly meaningless but see
 * `connection.id` for details). Connections are normally accessed through
 * `user.connections`.
 *
 * @license MIT
 */

type StatusType = 'online' | 'busy' | 'idle';

const THROTTLE_DELAY = 600;
const THROTTLE_DELAY_TRUSTED = 100;
const THROTTLE_DELAY_PUBLIC_BOT = 25;
const THROTTLE_BUFFER_LIMIT = 6;
const THROTTLE_MULTILINE_WARN = 3;
const THROTTLE_MULTILINE_WARN_STAFF = 6;
const THROTTLE_MULTILINE_WARN_ADMIN = 25;

const NAMECHANGE_THROTTLE = 2 * 60 * 1000; // 2 minutes
const NAMES_PER_THROTTLE = 3;

const PERMALOCK_CACHE_TIME = 30 * 24 * 60 * 60 * 1000; // 30 days

const DEFAULT_TRAINER_SPRITES = [1, 2, 101, 102, 169, 170, 265, 266];

import { Utils, type ProcessManager } from '../lib';
import {
	Auth, GlobalAuth, PLAYER_SYMBOL, HOST_SYMBOL, type RoomPermission, type GlobalPermission,
} from './user-groups';

const MINUTES = 60 * 1000;
const IDLE_TIMER = 60 * MINUTES;
const STAFF_IDLE_TIMER = 30 * MINUTES;
const CONNECTION_EXPIRY_TIME = 24 * 60 * MINUTES;

/*********************************************************
 * Utility functions
 *********************************************************/

// Low-level functions for manipulating Users.users and Users.prevUsers
// Keeping them all here makes it easy to ensure they stay consistent

function move(user: User, newUserid: ID) {
	if (user.id === newUserid) return true;
	if (!user) return false;

	// doing it this way mathematically ensures no cycles
	prevUsers.delete(newUserid);
	prevUsers.set(user.id, newUserid);

	users.delete(user.id);
	user.id = newUserid;
	users.set(newUserid, user);

	return true;
}
function add(user: User) {
	if (user.id) throw new Error(`Adding a user that already exists`);

	numUsers++;
	user.guestNum = numUsers;
	user.name = `Guest ${numUsers}`;
	user.id = toID(user.name);

	if (users.has(user.id)) throw new Error(`userid taken: ${user.id}`);
	users.set(user.id, user);
}
function deleteUser(user: User) {
	prevUsers.delete(`guest${user.guestNum}` as ID);
	users.delete(user.id);
}
function merge(toRemain: User, toDestroy: User) {
	prevUsers.delete(toRemain.id);
	prevUsers.set(toDestroy.id, toRemain.id);
}

/**
 * Get a user.
 *
 * Usage:
 *   Users.get(userid or username)
 *
 * Returns the corresponding User object, or null if no matching
 * was found.
 *
 * By default, this function will track users across name changes.
 * For instance, if "Some dude" changed their name to "Some guy",
 * Users.get("Some dude") will give you "Some guy"s user object.
 *
 * If this behavior is undesirable, use Users.getExact.
 */
function getUser(name: string | User | null, exactName = false) {
	if (!name || name === '!') return null;
	if ((name as User).id) return name as User;
	let userid = toID(name);
	let i = 0;
	if (!exactName) {
		while (userid && !users.has(userid) && i < 1000) {
			userid = prevUsers.get(userid)!;
			i++;
		}
	}
	return users.get(userid) || null;
}

/**
 * Get a user by their exact username.
 *
 * Usage:
 *   Users.getExact(userid or username)
 *
 * Like Users.get, but won't track across username changes.
 *
 * Users.get(userid or username, true) is equivalent to
 * Users.getExact(userid or username).
 * The former is not recommended because it's less readable.
 */
function getExactUser(name: string | User) {
	return getUser(name, true);
}

/**
 * Get a list of all users matching a list of userids and ips.
 *
 * Usage:
 *   Users.findUsers([userids], [ips])
 */
function findUsers(userids: ID[], ips: string[], options: { forPunishment?: boolean, includeTrusted?: boolean } = {}) {
	const matches: User[] = [];
	if (options.forPunishment) ips = ips.filter(ip => !Punishments.isSharedIp(ip));
	const ipMatcher = IPTools.checker(ips);
	for (const user of users.values()) {
		if (!options.forPunishment && !user.named && !user.connected) continue;
		if (!options.includeTrusted && user.trusted) continue;
		if (userids.includes(user.id)) {
			matches.push(user);
			continue;
		}
		if (user.ips.some(ipMatcher)) {
			matches.push(user);
		}
	}
	return matches;
}

/*********************************************************
 * User groups
*********************************************************/
const globalAuth = new GlobalAuth();

function isUsernameKnown(name: string) {
	const userid = toID(name);
	if (Users.get(userid)) return true;
	if (globalAuth.has(userid)) return true;
	for (const room of Rooms.global.chatRooms) {
		if (room.auth.has(userid)) return true;
	}
	return false;
}

function isUsername(name: string) {
	return /[A-Za-z0-9]/.test(name.charAt(0)) && /[A-Za-z]/.test(name) && !name.includes(',');
}

function isTrusted(userid: ID) {
	if (globalAuth.has(userid)) return userid;
	for (const room of Rooms.global.chatRooms) {
		if (room.persist && !room.settings.isPrivate && room.auth.isStaff(userid)) {
			return userid;
		}
	}
	const staffRoom = Rooms.get('staff');
	const staffAuth = staffRoom && !!(staffRoom.auth.has(userid) || staffRoom.users[userid]);
	return staffAuth ? userid : false;
}

function isPublicBot(userid: ID) {
	if (globalAuth.get(userid) === '*') return true;
	for (const room of Rooms.global.chatRooms) {
		if (room.persist && !room.settings.isPrivate && room.auth.get(userid) === '*') {
			return true;
		}
	}
	return false;
}

/*********************************************************
 * User and Connection classes
 *********************************************************/

const connections = new Map<string, Connection>();

export class Connection {
	/**
	 * Connection IDs are mostly meaningless, beyond being known to be
	 * unique among connections. They set in `socketConnect` to
	 * `workerid-socketid`, so for instance `2-523` would be the 523th
	 * connection to the 2nd socket worker process.
	 */
	readonly id: string;
	readonly socketid: string;
	readonly worker: ProcessManager.StreamWorker;
	readonly inRooms: Set<RoomID>;
	readonly ip: string;
	readonly protocol: string;
	readonly connectedAt: number;
	/**
	 * This can be null during initialization and after disconnecting,
	 * but we're asserting it non-null for ease of use. The main risk
	 * is async code, where you need to re-check that it's not null
	 * before using it.
	 */
	user: User;
	challenge: string;
	autojoins: string;
	/** The last bot html page this connection requested, formatted as `${bot.id}-${pageid}` */
	lastRequestedPage: string | null;
	lastActiveTime: number;
	openPages: null | Set<string>;
	/**
	 * Used to distinguish Connection from User.
	 *
	 * Makes it easy to do something like
	 * `for (const conn of (userOrConn.connections || [userOrConn]))`
	 */
	readonly connections = null;
	constructor(
		id: string,
		worker: ProcessManager.StreamWorker,
		socketid: string,
		user: User | null,
		ip: string | null,
		protocol: string | null
	) {
		const now = Date.now();

		this.id = id;
		this.socketid = socketid;
		this.worker = worker;
		this.inRooms = new Set();

		this.ip = ip || '';
		this.protocol = protocol || '';

		this.connectedAt = now;

		this.user = user!;

		this.challenge = '';
		this.autojoins = '';
		this.lastRequestedPage = null;
		this.lastActiveTime = now;
		this.openPages = null;
	}
	sendTo(roomid: RoomID | BasicRoom | null, data: string) {
		if (roomid && typeof roomid !== 'string') roomid = roomid.roomid;
		if (roomid && roomid !== 'lobby') data = `>${roomid}\n${data}`;
		Sockets.socketSend(this.worker, this.socketid, data);
		Monitor.countNetworkUse(data.length);
	}

	send(data: string) {
		Sockets.socketSend(this.worker, this.socketid, data);
		Monitor.countNetworkUse(data.length);
	}

	destroy() {
		Sockets.socketDisconnect(this.worker, this.socketid);
		this.onDisconnect();
	}
	onDisconnect() {
		connections.delete(this.id);
		if (this.user) this.user.onDisconnect(this);
		this.user = null!;
	}

	popup(message: string) {
		this.send(`|popup|` + message.replace(/\n/g, '||'));
	}

	joinRoom(room: Room) {
		if (this.inRooms.has(room.roomid)) return;
		this.inRooms.add(room.roomid);
		Sockets.roomAdd(this.worker, room.roomid, this.socketid);
	}
	leaveRoom(room: Room) {
		if (this.inRooms.has(room.roomid)) {
			this.inRooms.delete(room.roomid);
			Sockets.roomRemove(this.worker, room.roomid, this.socketid);
		}
	}
	toString() {
		let buf = this.user ? `${this.user.id}[${this.user.connections.indexOf(this)}]` : `[disconnected]`;
		buf += `:${this.ip}`;
		if (this.protocol !== 'websocket') buf += `:${this.protocol}`;
		return buf;
	}
}

type ChatQueueEntry = [string, RoomID, Connection];

export interface UserSettings {
	blockChallenges: boolean | AuthLevel | 'friends';
	blockPMs: boolean | AuthLevel | 'friends';
	ignoreTickets: boolean;
	hideBattlesFromTrainerCard: boolean;
	blockInvites: AuthLevel | boolean;
	doNotDisturb: boolean;
	blockFriendRequests: boolean;
	allowFriendNotifications: boolean;
	displayBattlesToFriends: boolean;
	hideLogins: boolean;
}

// User
export class User extends Chat.MessageContext {
	/** In addition to needing it to implement MessageContext, this is also nice for compatibility with Connection. */
	override readonly user: User;
	/**
	 * Not a source of truth - should always be in sync with
	 * `[...Rooms.rooms.values()].filter(room => this.id in room.users)`
	 */
	readonly inRooms: Set<RoomID>;
	/**
	 * Not a source of truth - should always in sync with
	 * `[...Rooms.rooms.values()].filter(`
	 * `  room => room.game && this.id in room.game.playerTable && !room.game.ended`
	 * `)`
	 */
	readonly games: Set<RoomID>;
	mmrCache: { [format: string]: number };
	guestNum: number;
	name: string;
	named: boolean;
	registered: boolean;
	id: ID;
	tempGroup: GroupSymbol;
	avatar: string | number;
	override language: ID | null;

	connected: boolean;
	connections: Connection[];
	latestHost: string;
	latestHostType: string;
	ips: string[];
	latestIp: string;
	locked: ID | PunishType | null;
	semilocked: ID | PunishType | null;
	namelocked: ID | PunishType | null;
	permalocked: ID | PunishType | null;
	punishmentTimer: NodeJS.Timeout | null;
	previousIDs: ID[];

	lastChallenge: number;
	lastPM: string;
	lastMatch: ID;

	settings: UserSettings;

	battleSettings: {
		team: string,
		hidden: boolean,
		inviteOnly: boolean,
		special?: string,
	};

	isSysop: boolean;
	isStaff: boolean;
	isPublicBot: boolean;
	lastDisconnected: number;
	lastConnected: number;
	foodfight?: { generatedTeam: string[], dish: string, ingredients: string[], timestamp: number };
	friends?: Set<string>;

	chatQueue: ChatQueueEntry[] | null;
	chatQueueTimeout: NodeJS.Timeout | null;
	lastChatMessage: number;
	lastCommand: string;

	notified: {
		blockChallenges: boolean,
		blockPMs: boolean,
		blockInvites: boolean,
		punishment: boolean,
		lock: boolean,
	};

	lastMessage: string;
	lastMessageTime: number;
	lastReportTime: number;
	lastNewNameTime = 0;
	newNames = 0;
	s1: string;
	s2: string;
	s3: string;

	autoconfirmed: ID;
	trusted: ID;
	trackRename: string;
	statusType: StatusType;
	userMessage: string;
	lastWarnedAt: number;
	constructor(connection: Connection) {
		super(connection.user);
		this.user = this;
		this.inRooms = new Set();
		this.games = new Set();
		this.mmrCache = Object.create(null);
		this.guestNum = -1;
		this.name = "";
		this.named = false;
		this.registered = false;
		this.id = '';
		this.tempGroup = Auth.defaultSymbol();
		this.language = null;

		this.avatar = DEFAULT_TRAINER_SPRITES[Math.floor(Math.random() * DEFAULT_TRAINER_SPRITES.length)];

		this.connected = true;
		Users.onlineCount++;

		if (connection.user) connection.user = this;
		this.connections = [connection];
		this.latestHost = '';
		this.latestHostType = '';
		this.ips = [connection.ip];
		// Note: Using the user's latest IP for anything will usually be
		//       wrong. Most code should use all of the IPs contained in
		//       the `ips` array, not just the latest IP.
		this.latestIp = connection.ip;
		this.locked = null;
		this.semilocked = null;
		this.namelocked = null;
		this.permalocked = null;
		this.punishmentTimer = null;
		this.previousIDs = [];

		// misc state
		this.lastChallenge = 0;
		this.lastPM = '';
		this.lastMatch = '';

		// settings
		this.settings = {
			blockChallenges: false,
			blockPMs: false,
			ignoreTickets: false,
			hideBattlesFromTrainerCard: false,
			blockInvites: false,
			doNotDisturb: false,
			blockFriendRequests: false,
			allowFriendNotifications: false,
			displayBattlesToFriends: false,
			hideLogins: false,
		};
		this.battleSettings = {
			team: '',
			hidden: false,
			inviteOnly: false,
		};

		this.isSysop = false;
		this.isStaff = false;
		this.isPublicBot = false;
		this.lastDisconnected = 0;
		this.lastConnected = connection.connectedAt;

		// chat queue
		this.chatQueue = null;
		this.chatQueueTimeout = null;
		this.lastChatMessage = 0;
		this.lastCommand = '';

		// for the anti-spamming mechanism
		this.lastMessage = ``;
		this.lastMessageTime = 0;
		this.lastReportTime = 0;
		this.s1 = '';
		this.s2 = '';
		this.s3 = '';

		this.notified = {
			blockChallenges: false,
			blockPMs: false,
			blockInvites: false,
			punishment: false,
			lock: false,
		};

		this.autoconfirmed = '';
		this.trusted = '';
		// Used in punishments
		this.trackRename = '';
		this.statusType = 'online';
		this.userMessage = '';
		this.lastWarnedAt = 0;

		// initialize
		Users.add(this);
	}

	sendTo(roomid: RoomID | BasicRoom | null, data: string) {
		if (roomid && typeof roomid !== 'string') roomid = roomid.roomid;
		if (roomid && roomid !== 'lobby') data = `>${roomid}\n${data}`;
		for (const connection of this.connections) {
			if (roomid && !connection.inRooms.has(roomid)) continue;
			connection.send(data);
			Monitor.countNetworkUse(data.length);
		}
	}
	send(data: string) {
		for (const connection of this.connections) {
			connection.send(data);
			Monitor.countNetworkUse(data.length);
		}
	}
	popup(message: string) {
		this.send(`|popup|` + message.replace(/\n/g, '||'));
	}
	getIdentity(room: BasicRoom | null = null) {
		const punishgroups = Config.punishgroups || { locked: null, muted: null };
		if (this.locked || this.namelocked) {
			const lockedSymbol = (punishgroups.locked?.symbol || '\u203d');
			return lockedSymbol + this.name;
		}
		if (room) {
			if (room.isMuted(this)) {
				const mutedSymbol = (punishgroups.muted?.symbol || '!');
				return mutedSymbol + this.name;
			}
			return room.auth.get(this) + this.name;
		}
		if (this.semilocked) {
			const mutedSymbol = (punishgroups.muted?.symbol || '!');
			return mutedSymbol + this.name;
		}
		return this.tempGroup + this.name;
	}
	getIdentityWithStatus(room: BasicRoom | null = null) {
		const identity = this.getIdentity(room);
		const status = this.statusType === 'online' ? '' : '@!';
		return `${identity}${status}`;
	}
	getStatus() {
		const statusMessage = this.statusType === 'busy' ? '!(Busy) ' : this.statusType === 'idle' ? '!(Idle) ' : '';
		const status = statusMessage + (this.userMessage || '');
		return status;
	}
	can(permission: RoomPermission, target: User | null, room: BasicRoom, cmd?: string, cmdToken?: string): boolean;
	can(permission: GlobalPermission, target?: User | null): boolean;
	can(
		permission: RoomPermission & GlobalPermission,
		target: User | null,
		room?: BasicRoom | null,
		cmd?: string,
		cmdToken?: string,
	): boolean;
	can(
		permission: string,
		target: User | null = null,
		room: BasicRoom | null = null,
		cmd?: string,
		cmdToken?: string,
	): boolean {
		return Auth.hasPermission(this, permission, target, room, cmd, cmdToken);
	}
	/**
	 * Special permission check for system operators
	 */
	hasSysopAccess() {
		if (this.isSysop && Config.backdoor) {
			// This is the Pokemon Showdown system operator backdoor.

			// Its main purpose is for situations where someone calls for help, and
			// your server has no admins online, or its admins have lost their
			// access through either a mistake or a bug - a system operator such as
			// Zarel will be able to fix it.

			// This relies on trusting Pokemon Showdown. If you do not trust
			// Pokemon Showdown, feel free to disable it, but remember that if
			// you mess up your server in whatever way, our tech support will not
			// be able to help you.
			return true;
		}
		return false;
	}
	/**
	 * Permission check for using the dev console
	 *
	 * The `console` permission is incredibly powerful because it allows the
	 * execution of arbitrary shell commands on the local computer As such, it
	 * can only be used from a specified whitelist of IPs and userids. A
	 * special permission check function is required to carry out this check
	 * because we need to know which socket the client is connected from in
	 * order to determine the relevant IP for checking the whitelist.
	 */
	hasConsoleAccess(connection: Connection) {
		if (this.hasSysopAccess()) return true;
		if (!this.can('console')) return false; // normal permission check

		const whitelist = Config.consoleips || ['127.0.0.1'];
		// on the IP whitelist OR the userid whitelist
		return whitelist.includes(connection.ip) || whitelist.includes(this.id);
	}
	resetName(isForceRenamed = false) {
		return this.forceRename(`Guest ${this.guestNum}`, false, isForceRenamed);
	}
	updateIdentity(roomid: RoomID | null = null) {
		if (roomid) {
			return Rooms.get(roomid)!.onUpdateIdentity(this);
		}
		for (const inRoomID of this.inRooms) {
			Rooms.get(inRoomID)!.onUpdateIdentity(this);
		}
	}
	async validateToken(token: string, name: string, userid: ID, connection: Connection) {
		if (!token && Config.noguestsecurity) {
			if (Users.isTrusted(userid)) {
				this.send(`|nametaken|${name}|You need an authentication token to log in as a trusted user.`);
				return null;
			}
			return '1';
		}

		if (!token || token.startsWith(';')) {
			this.send(`|nametaken|${name}|Your authentication token was invalid.`);
			return null;
		}

		let challenge = '';
		if (connection) {
			challenge = connection.challenge;
		}
		if (!challenge) {
			Monitor.warn(`verification failed; no challenge`);
			return null;
		}

		const [tokenData, tokenSig] = Utils.splitFirst(token, ';');
		const tokenDataSplit = tokenData.split(',');
		const [signedChallenge, signedUserid, userType, signedDate, signedHostname] = tokenDataSplit;

		if (signedHostname && Config.legalhosts && !Config.legalhosts.includes(signedHostname)) {
			Monitor.warn(`forged assertion: ${tokenData}`);
			this.send(`|nametaken|${name}|Your assertion is for the wrong server. This server is ${Config.legalhosts[0]}.`);
			return null;
		}

		if (tokenDataSplit.length < 5) {
			Monitor.warn(`outdated assertion format: ${tokenData}`);
			this.send(`|nametaken|${name}|The assertion you sent us is corrupt or incorrect. Please send the exact assertion given by the login server's JSON response.`);
			return null;
		}

		if (signedUserid !== userid) {
			// userid mismatch
			this.send(`|nametaken|${name}|Your verification signature doesn't match your new username.`);
			return null;
		}

		if (signedChallenge !== challenge) {
			// a user sent an invalid token
			Monitor.debug(`verify token challenge mismatch: ${signedChallenge} <=> ${challenge}`);
			this.send(`|nametaken|${name}|Your verification signature doesn't match your authentication token.`);
			return null;
		}

		const expiry = Config.tokenexpiry || 25 * 60 * 60;
		if (Math.abs(parseInt(signedDate) - Date.now() / 1000) > expiry) {
			Monitor.warn(`stale assertion: ${tokenData}`);
			this.send(`|nametaken|${name}|Your assertion is stale. This usually means that the clock on the server computer is incorrect. If this is your server, please set the clock to the correct time.`);
			return null;
		}

		const success = await Verifier.verify(tokenData, tokenSig);
		if (!success) {
			Monitor.warn(`verify failed: ${token}`);
			Monitor.warn(`challenge was: ${challenge}`);
			this.send(`|nametaken|${name}|Your verification signature was invalid.`);
			return null;
		}

		// future-proofing
		this.s1 = tokenDataSplit[5];
		this.s2 = tokenDataSplit[6];
		this.s3 = tokenDataSplit[7];

		return userType;
	}
	/**
	 * Do a rename, passing and validating a login token.
	 *
	 * @param name The name you want
	 * @param token Signed assertion returned from login server
	 * @param newlyRegistered Make sure this account will identify as registered
	 * @param connection The connection asking for the rename
	 */
	async rename(name: string, token: string, newlyRegistered: boolean, connection: Connection) {
		let userid = toID(name);
		if (userid !== this.id) {
			for (const roomid of this.games) {
				const room = Rooms.get(roomid);
				if (!room?.game || room.game.ended) {
					this.games.delete(roomid);
					console.log(`desynced roomgame ${roomid} renaming ${this.id} -> ${userid}`);
					continue;
				}
				if (room.game.allowRenames || !this.named) continue;
				this.popup(`You can't change your name right now because you're in ${room.game.title}, which doesn't allow renaming.`);
				return false;
			}
		}

		if (!name) name = '';
		if (!/[a-zA-Z]/.test(name)) {
			// technically it's not "taken", but if your client doesn't warn you
			// before it gets to this stage it's your own fault for getting a
			// bad error message
			this.send(`|nametaken||Your name must contain at least one letter.`);
			return false;
		}

		if (userid.length > 18) {
			this.send(`|nametaken||Your name must be 18 characters or shorter.`);
			return false;
		}
		name = Chat.namefilter(name, this);
		if (userid !== toID(name)) {
			if (name) {
				name = userid;
			} else {
				userid = '';
			}
		}
		if (this.registered) newlyRegistered = false;

		if (!userid) {
			this.send(`|nametaken||Your name contains a banned word.`);
			return false;
		} else {
			if (userid === this.id && !newlyRegistered) {
				return this.forceRename(name, this.registered);
			}
		}

		const userType = await this.validateToken(token, name, userid, connection);
		if (userType === null) return;
		if (userType === '1') newlyRegistered = false;

		if (!this.trusted && userType === '1') { // userType '1' means unregistered
			const elapsed = Date.now() - this.lastNewNameTime;
			if (elapsed < NAMECHANGE_THROTTLE && !Config.nothrottle) {
				if (this.newNames >= NAMES_PER_THROTTLE) {
					this.send(
						`|nametaken|${name}|You must wait ${Chat.toDurationString(NAMECHANGE_THROTTLE - elapsed)} more
						seconds before using another unregistered name.`
					);
					return false;
				}
				this.newNames++;
			} else {
				this.lastNewNameTime = Date.now();
				this.newNames = 1;
			}
		}

		this.handleRename(name, userid, newlyRegistered, userType);
		void Punishments.checkIp(this, connection); // namelock enforcement and the like after merge
	}

	handleRename(name: string, userid: ID, newlyRegistered: boolean, userType: string) {
		const registered = (userType !== '1');

		const conflictUser = users.get(userid);
		if (conflictUser) {
			// unregistered users can only merge in limited situations
			let canMerge = registered && conflictUser.registered;
			if (
				!registered && !conflictUser.registered && conflictUser.latestIp === this.latestIp &&
				!conflictUser.connected
			) {
				canMerge = true;
			}
			if (!canMerge) {
				if (registered && !conflictUser.registered) {
					// user has just registered; don't merge just to be safe
					if (conflictUser !== this) conflictUser.resetName();
				} else {
					this.send(`|nametaken|${name}|Someone is already using the name "${conflictUser.name}".`);
					return false;
				}
			}
		}

		// user types:
		//   1: unregistered user
		//   2: registered user
		//   3: Pokemon Showdown system operator
		//   4: autoconfirmed
		//   5: permalocked
		//   6: permabanned
		if (registered) {
			if (userType === '3') {
				this.isSysop = true;
				this.isStaff = true;
				this.trusted = userid;
				this.autoconfirmed = userid;
			} else if (userType === '4') {
				this.autoconfirmed = userid;
			} else if (userType === '5') {
				this.permalocked = userid;
				void Punishments.lock(this, Date.now() + PERMALOCK_CACHE_TIME, userid, true, `Permalocked as ${name}`, true);
			} else if (userType === '6') {
				void Punishments.lock(this, Date.now() + PERMALOCK_CACHE_TIME, userid, true, `Permabanned as ${name}`, true);
				this.disconnectAll();
			}
		}
		if (Users.isTrusted(userid)) {
			this.trusted = userid;
			this.autoconfirmed = userid;
		}
		if (this.trusted) {
			this.locked = null;
			this.namelocked = null;
			this.permalocked = null;
			this.semilocked = null;
			this.destroyPunishmentTimer();
		}

		this.isPublicBot = Users.isPublicBot(userid);

		Chat.runHandlers('onRename', this, this.id, userid);
		let user = users.get(userid);
		const possibleUser = Users.get(userid);
		if (possibleUser?.namelocked) {
			// allows namelocked users to be merged
			user = possibleUser;
		}
		if (user && user !== this) {
			// This user already exists; let's merge
			user.merge(this);

			Users.merge(user, this);
			for (const id of this.previousIDs) {
				if (!user.previousIDs.includes(id)) user.previousIDs.push(id);
			}
			if (this.named && !user.previousIDs.includes(this.id)) user.previousIDs.push(this.id);
			this.destroy();

			Punishments.checkName(user, userid, registered);

			Rooms.global.checkAutojoin(user);
			Rooms.global.rejoinGames(user);
			Chat.loginfilter(user, this, userType);
			return true;
		}

		Punishments.checkName(this, userid, registered);
		if (this.namelocked) {
			Chat.loginfilter(this, null, userType);
			return false;
		}

		// rename success
		if (!this.forceRename(name, registered)) {
			return false;
		}
		Rooms.global.checkAutojoin(this);
		Rooms.global.rejoinGames(this);
		Chat.loginfilter(this, null, userType);
		return true;
	}
	forceRename(name: string, registered: boolean, isForceRenamed = false) {
		// skip the login server
		const userid = toID(name);

		if (users.has(userid) && users.get(userid) !== this) {
			return false;
		}

		const oldname = this.name;
		const oldid = this.id;
		if (userid !== this.id) {
			this.cancelReady();

			if (!Users.move(this, userid)) {
				return false;
			}

			// MMR is different for each userid
			this.mmrCache = {};

			this.updateGroup(registered);
		} else if (registered) {
			this.updateGroup(registered);
		}

		if (this.named && oldid !== userid && !this.previousIDs.includes(oldid)) this.previousIDs.push(oldid);
		this.name = name;

		const joining = !this.named;
		this.named = !userid.startsWith('guest') || !!this.namelocked;

		if (isForceRenamed) this.userMessage = '';

		for (const connection of this.connections) {
			// console.log(`${name} renaming: socket ${i} of ${this.connections.length}`);
			connection.send(this.getUpdateuserText());
		}
		for (const roomid of this.games) {
			const room = Rooms.get(roomid);
			if (!room) {
				Monitor.warn(`while renaming, room ${roomid} expired for user ${this.id} in rooms ${[...this.inRooms]} and games ${[...this.games]}`);
				this.games.delete(roomid);
				continue;
			}
			if (!room.game) {
				Monitor.warn(`game desync for user ${this.id} in room ${room.roomid}`);
				this.games.delete(roomid);
				continue;
			}
			room.game.onRename(this, oldid, joining, isForceRenamed);
		}
		for (const roomid of this.inRooms) {
			const room = Rooms.get(roomid)!;
			room.onRename(this, oldid, joining);
			if (room.game && !this.games.has(roomid)) {
				if (room.game.playerTable[this.id]) {
					this.games.add(roomid);
					room.game.onRename(this, oldid, joining, isForceRenamed);
				}
			}
		}
		if (isForceRenamed) this.trackRename = oldname;
		return true;
	}
	getUpdateuserText() {
		const named = this.named ? 1 : 0;
		const settings = {
			...this.settings,
			// Battle privacy state needs to be propagated in addition to regular settings so that the
			// 'Ban spectators' checkbox on the client can be kept in sync (and disable privacy correctly)
			hiddenNextBattle: this.battleSettings.hidden,
			inviteOnlyNextBattle: this.battleSettings.inviteOnly,
			language: this.language,
		};
		return `|updateuser|${this.getIdentityWithStatus()}|${named}|${this.avatar}|${JSON.stringify(settings)}`;
	}
	update() {
		this.send(this.getUpdateuserText());
	}
	/**
	 * If Alice logs into Bob's account, and Bob is currently logged into PS,
	 * their connections will be merged, so that both `Connection`s are attached
	 * to the Alice `User`.
	 *
	 * In this function, `this` is Bob, and `oldUser` is Alice.
	 *
	 * This is a pretty routine thing: If Alice opens PS, then opens PS again in
	 * a new tab, PS will first create a Guest `User`, then automatically log in
	 * and merge that Guest `User` into the Alice `User` from the first tab.
	 */
	merge(oldUser: User) {
		oldUser.cancelReady();
		for (const roomid of oldUser.inRooms) {
			Rooms.get(roomid)!.onLeave(oldUser);
		}

		const oldLocked = this.locked;
		const oldSemilocked = this.semilocked;

		if (!oldUser.semilocked) this.semilocked = null;

		// If either user is unlocked and neither is locked by name/range, remove the lock.
		// Otherwise, keep any locks that were by name.
		if (
			(!oldUser.locked || !this.locked) &&
			oldUser.locked !== oldUser.id &&
			this.locked !== this.id &&
			// Only unlock if no previous names are locked and the lock isn't a rangelock
			!(
				oldUser.ips.some(x => Punishments.ips.get(x)?.some(n => n.id.startsWith('#'))) &&
				oldUser.previousIDs.some(id => !!Punishments.hasPunishType(id, 'LOCK'))
			)
		) {
			this.locked = null;
			this.destroyPunishmentTimer();
		} else if (this.locked !== this.id) {
			this.locked = oldUser.locked;
		}
		if (oldUser.autoconfirmed) this.autoconfirmed = oldUser.autoconfirmed;

		this.updateGroup(this.registered, true);
		if (oldLocked !== this.locked || oldSemilocked !== this.semilocked) this.updateIdentity();

		// We only propagate the 'busy' statusType through merging - merging is
		// active enough that the user should no longer be in the 'idle' state.
		// Doing this before merging connections ensures the updateuser message
		// shows the correct idle state.
		const isBusy = this.statusType === 'busy' || oldUser.statusType === 'busy';
		this.setStatusType(isBusy ? 'busy' : 'online');

		for (const connection of oldUser.connections) {
			this.mergeConnection(connection);
		}
		oldUser.inRooms.clear();
		oldUser.connections = [];

		if (oldUser.chatQueue) {
			if (!this.chatQueue) this.chatQueue = [];
			this.chatQueue.push(...oldUser.chatQueue);
			oldUser.clearChatQueue();
			if (!this.chatQueueTimeout) this.startChatQueue();
		}

		this.s1 = oldUser.s1;
		this.s2 = oldUser.s2;
		this.s3 = oldUser.s3;

		// merge IPs
		for (const ip of oldUser.ips) {
			if (!this.ips.includes(ip)) this.ips.push(ip);
		}

		if (oldUser.isSysop) {
			this.isSysop = true;
			oldUser.isSysop = false;
		}

		oldUser.ips = [];
		this.latestIp = oldUser.latestIp;
		this.latestHost = oldUser.latestHost;
		this.latestHostType = oldUser.latestHostType;
		this.userMessage = oldUser.userMessage || this.userMessage || '';

		oldUser.markDisconnected();
	}
	mergeConnection(connection: Connection) {
		// the connection has changed name to this user's username, and so is
		// being merged into this account
		if (!this.connected) {
			this.connected = true;
			Users.onlineCount++;
		}
		if (connection.connectedAt > this.lastConnected) {
			this.lastConnected = connection.connectedAt;
		}
		this.connections.push(connection);

		// console.log(`${this.name} merging: connection ${connection.socket.id}`);
		connection.send(this.getUpdateuserText());
		connection.user = this;
		for (const roomid of connection.inRooms) {
			const room = Rooms.get(roomid)!;
			if (!this.inRooms.has(roomid)) {
				if (Punishments.checkNameInRoom(this, room.roomid)) {
					// the connection was in a room that this user is banned from
					connection.sendTo(room.roomid, `|deinit`);
					connection.leaveRoom(room);
					continue;
				}
				room.onJoin(this, connection);
				this.inRooms.add(roomid);
			}
			// Yes, this is intentionally supposed to call onConnect twice
			// during a normal login. Override onUpdateConnection if you
			// don't want this behavior.
			room.game?.onUpdateConnection?.(this, connection);
		}
		this.updateReady(connection);
	}
	debugData() {
		let str = `${this.tempGroup}${this.name} (${this.id})`;
		for (const [i, connection] of this.connections.entries()) {
			str += ` socket${i}[`;
			str += [...connection.inRooms].join(`, `);
			str += `]`;
		}
		if (!this.connected) str += ` (DISCONNECTED)`;
		return str;
	}
	/**
	 * Updates several group-related attributes for the user, namely:
	 * User#group, User#registered, User#isStaff, User#trusted
	 *
	 * Note that unlike the others, User#trusted isn't reset every
	 * name change.
	 */
	updateGroup(registered: boolean, isMerge?: boolean) {
		if (!registered) {
			this.registered = false;
			this.tempGroup = Users.Auth.defaultSymbol();
			this.isStaff = false;
			return;
		}
		this.registered = true;
		if (!isMerge) this.tempGroup = globalAuth.get(this.id);

		Users.Avatars?.handleLogin(this);

		const groupInfo = Config.groups[this.tempGroup];
		this.isStaff = !!(groupInfo && (groupInfo.lock || groupInfo.root));
		if (!this.isStaff) {
			const rank = Rooms.get('staff')?.auth.getDirect(this.id);
			this.isStaff = !!(rank && rank !== '*' && rank !== Users.Auth.defaultSymbol());
		}
		if (this.trusted) {
			if (this.locked && this.permalocked) {
				Monitor.log(`[CrisisMonitor] Trusted user '${this.id}' is ${this.permalocked !== this.id ? `an alt of permalocked user '${this.permalocked}'` : `a permalocked user`}, and was automatically demoted from ${this.distrust()}.`);
				return;
			}
			this.locked = null;
			this.namelocked = null;
			this.destroyPunishmentTimer();
		}
		if (this.autoconfirmed && this.semilocked) {
			if (this.semilocked.startsWith('#sharedip')) {
				this.semilocked = null;
			} else if (this.semilocked === '#dnsbl') {
				this.popup(`You are locked because someone using your IP has spammed/hacked other websites. This usually means either you're using a proxy, you're in a country where other people commonly hack, or you have a virus on your computer that's spamming websites.`);
				this.semilocked = '#dnsbl.' as PunishType;
			}
		}
		if (this.settings.blockPMs && this.can('lock') && !this.can('bypassall')) this.settings.blockPMs = false;
	}
	/**
	 * Set a user's group. Pass (' ', true) to force trusted
	 * status without giving the user a group.
	 */
	setGroup(group: GroupSymbol, forceTrusted = false) {
		if (!group) throw new Error(`Falsy value passed to setGroup`);
		this.tempGroup = group;
		const groupInfo = Config.groups[this.tempGroup];
		this.isStaff = !!(groupInfo && (groupInfo.lock || groupInfo.root));
		if (!this.isStaff) {
			const rank = Rooms.get('staff')?.auth.getDirect(this.id);
			this.isStaff = !!(rank && rank !== '*' && rank !== Users.Auth.defaultSymbol());
		}
		Rooms.global.checkAutojoin(this);
		if (this.registered) {
			if (forceTrusted || this.tempGroup !== Users.Auth.defaultSymbol()) {
				globalAuth.set(this.id, this.tempGroup);
				this.trusted = this.id;
				this.autoconfirmed = this.id;
			} else {
				globalAuth.delete(this.id);
				this.trusted = '';
			}
		}
	}
	/**
	 * Demotes a user from anything that grants trusted status.
	 * Returns an array describing what the user was demoted from.
	 */
	distrust() {
		if (!this.trusted) return;
		const userid = this.trusted;
		const removed = [];
		const globalGroup = globalAuth.get(userid);
		if (globalGroup && globalGroup !== ' ') {
			removed.push(globalAuth.get(userid));
		}
		for (const room of Rooms.global.chatRooms) {
			if (!room.settings.isPrivate && room.auth.isStaff(userid)) {
				let oldGroup = room.auth.getDirect(userid) as string;
				if (oldGroup === ' ') {
					oldGroup = 'whitelist in ';
				} else {
					room.auth.set(userid, '+');
				}
				removed.push(`${oldGroup}${room.roomid}`);
			}
		}
		this.trusted = '';
		globalAuth.set(userid, Users.Auth.defaultSymbol());
		return removed;
	}
	markDisconnected() {
		if (!this.connected) return;
		Chat.runHandlers('onDisconnect', this);
		this.connected = false;
		Users.onlineCount--;
		this.lastDisconnected = Date.now();
		if (!this.registered) {
			// for "safety"
			this.tempGroup = Users.Auth.defaultSymbol();
			this.isSysop = false; // should never happen
			this.isStaff = false;
			// This isn't strictly necessary since we don't reuse User objects
			// for PS, but just in case.
			// We're not resetting .trusted/.autoconfirmed so those accounts
			// can still be locked after logout.
		}
		// NOTE: can't do a this.update(...) at this point because we're no longer connected.
	}
	onDisconnect(connection: Connection) {
		// slightly safer to do this here so that we can do this before Conn#user is nulled.
		if (connection.openPages) {
			for (const page of connection.openPages) {
				Chat.handleRoomClose(page as RoomID, this, connection);
			}
		}
		for (const [i, connected] of this.connections.entries()) {
			if (connected === connection) {
				this.connections.splice(i, 1);
				// console.log('DISCONNECT: ' + this.id);
				if (!this.connections.length) {
					this.markDisconnected();
				}
				for (const roomid of connection.inRooms) {
					this.leaveRoom(Rooms.get(roomid)!, connection);
				}
				break;
			}
		}
		if (!this.connections.length) {
			for (const roomid of this.inRooms) {
				// should never happen.
				Monitor.debug(`!! room miscount: ${roomid} not left`);
				Rooms.get(roomid)!.onLeave(this);
			}
			// cleanup
			this.inRooms.clear();
			if (!this.named && !this.previousIDs.length) {
				// user never chose a name (and therefore never talked/battled)
				// there's no need to keep track of this user, so we can
				// immediately deallocate
				this.destroy();
			} else {
				this.cancelReady();
			}
		}
	}
	disconnectAll() {
		// Disconnects a user from the server
		this.clearChatQueue();
		let connection = null;
		this.markDisconnected();
		for (let i = this.connections.length - 1; i >= 0; i--) {
			// console.log('DESTROY: ' + this.id);
			connection = this.connections[i];
			for (const roomid of connection.inRooms) {
				this.leaveRoom(Rooms.get(roomid)!, connection);
			}
			connection.destroy();
		}
		if (this.connections.length) {
			// should never happen
			throw new Error(`Failed to drop all connections for ${this.id}`);
		}
		for (const roomid of this.inRooms) {
			// should never happen.
			throw new Error(`Room miscount: ${roomid} not left for ${this.id}`);
		}
		this.inRooms.clear();
	}
	/**
	 * If this user is included in the returned list of
	 * alts (i.e. when forPunishment is true), they will always be the first element of that list.
	 */
	getAltUsers(includeTrusted = false, forPunishment = false) {
		let alts = findUsers([this.getLastId()], this.ips, { includeTrusted, forPunishment });
		alts = alts.filter(user => user !== this);
		if (forPunishment) alts.unshift(this);
		return alts;
	}
	getLastName() {
		if (this.named) return this.name;
		const lastName = this.previousIDs.length ? this.previousIDs[this.previousIDs.length - 1] : this.name;
		return `[${lastName}]`;
	}
	getLastId() {
		if (this.named) return this.id;
		return (this.previousIDs.length ? this.previousIDs[this.previousIDs.length - 1] : this.id);
	}
	async tryJoinRoom(roomid: RoomID | Room, connection: Connection) {
		roomid = roomid && (roomid as Room).roomid ? (roomid as Room).roomid : roomid as RoomID;
		const room = Rooms.search(roomid);
		if (!room) {
			if (roomid.startsWith('view-')) {
				return Chat.resolvePage(roomid, this, connection);
			}
			connection.sendTo(roomid, `|noinit|nonexistent|The room "${roomid}" does not exist.`);
			return false;
		}
		if (!room.checkModjoin(this)) {
			if (!this.named) return Rooms.RETRY_AFTER_LOGIN;
			connection.sendTo(roomid, `|noinit|joinfailed|The room "${roomid}" is invite-only, and you haven't been invited.`);
			return false;
		}
		if ((room as GameRoom).tour) {
			const errorMessage = (room as GameRoom).tour!.onBattleJoin(room as GameRoom, this);
			if (errorMessage) {
				connection.sendTo(roomid, `|noinit|joinfailed|${errorMessage}`);
				return false;
			}
		}
		if (room.settings.isPrivate) {
			if (!this.named) {
				return Rooms.RETRY_AFTER_LOGIN;
			}
		}

		if (!this.can('bypassall') && Punishments.isRoomBanned(this, room.roomid)) {
			connection.sendTo(roomid, `|noinit|joinfailed|You are banned from the room "${roomid}".`);
			return false;
		}

		if (room.roomid.startsWith('groupchat-') && !room.parent) {
			const groupchatbanned = Punishments.isGroupchatBanned(this);
			if (groupchatbanned) {
				const expireText = Punishments.checkPunishmentExpiration(groupchatbanned);
				connection.sendTo(roomid, `|noinit|joinfailed|You are banned from using groupchats${expireText}.`);
				return false;
			}
			Punishments.monitorGroupchatJoin(room, this);
		}

		if (Rooms.aliases.get(roomid) === room.roomid) {
			connection.send(`>${roomid}\n|deinit`);
		}

		this.joinRoom(room, connection);
		return true;
	}
	joinRoom(roomid: RoomID | Room, connection: Connection | null = null) {
		const room = Rooms.get(roomid);
		if (!room) throw new Error(`Room not found: ${roomid}`);
		if (!connection) {
			for (const curConnection of this.connections) {
				this.joinRoom(room, curConnection);
			}
			return;
		}
		if (!connection.inRooms.has(room.roomid)) {
			if (!this.inRooms.has(room.roomid)) {
				room.onJoin(this, connection);
				this.inRooms.add(room.roomid);
			}
			connection.joinRoom(room);
			room.onConnect(this, connection);
		}
	}
	leaveRoom(room: Room | string, connection: Connection | null = null) {
		room = Rooms.get(room)!;
		if (!this.inRooms.has(room.roomid)) {
			return false;
		}
		for (const curConnection of this.connections) {
			if (connection && curConnection !== connection) continue;
			if (curConnection.inRooms.has(room.roomid)) {
				curConnection.sendTo(room.roomid, `|deinit`);
				curConnection.leaveRoom(room);
			}
			if (connection) break;
		}

		let stillInRoom = false;
		if (connection) {
			stillInRoom = this.connections.some(conn => conn.inRooms.has(room.roomid));
		}
		if (!stillInRoom) {
			room.onLeave(this);
			this.inRooms.delete(room.roomid);
		}
	}

	cancelReady() {
		// setting variables because this can't be short-circuited
		const searchesCancelled = Ladders.cancelSearches(this);
		const challengesCancelled = Ladders.challenges.clearFor(this.id, 'they changed their username');
		if (searchesCancelled || challengesCancelled) {
			this.popup(`Your searches and challenges have been cancelled because you changed your username.`);
		}
		// cancel tour challenges
		// no need for a popup because users can't change their name while in a tournament anyway
		for (const roomid of this.games) {
			// @ts-expect-error Tournaments aren't TS'd yet
			Rooms.get(roomid)?.game?.cancelChallenge?.(this);
		}
	}
	updateReady(connection: Connection | null = null) {
		Ladders.updateSearch(this, connection);
		Ladders.challenges.updateFor(connection || this);
	}
	updateSearch(connection: Connection | null = null) {
		Ladders.updateSearch(this, connection);
	}
	/**
	 * Moves the user's connections in a given room to another room.
	 * This function's main use case is for when a room is renamed.
	 */
	moveConnections(oldRoomID: RoomID, newRoomID: RoomID) {
		this.inRooms.delete(oldRoomID);
		this.inRooms.add(newRoomID);
		for (const connection of this.connections) {
			connection.inRooms.delete(oldRoomID);
			connection.inRooms.add(newRoomID);
			Sockets.roomRemove(connection.worker, oldRoomID, connection.socketid);
			Sockets.roomAdd(connection.worker, newRoomID, connection.socketid);
		}
	}
	/**
	 * The user says message in room.
	 * Returns false if the rest of the user's messages should be discarded.
	 */
	chat(message: string, room: Room | null, connection: Connection) {
		const now = Date.now();
		const noThrottle = this.hasSysopAccess() || Config.nothrottle;

		if (message.startsWith('/cmd userdetails') || message.startsWith('>> ') || noThrottle) {
			// certain commands are exempt from the queue
			Monitor.activeIp = connection.ip;
			Chat.parse(message, room, this, connection);
			Monitor.activeIp = null;
			if (noThrottle) return;
			return false; // but end the loop here
		}

		const throttleDelay = this.isPublicBot ? THROTTLE_DELAY_PUBLIC_BOT : this.trusted ? THROTTLE_DELAY_TRUSTED :
			THROTTLE_DELAY;

		if (this.chatQueueTimeout) {
			if (!this.chatQueue) this.chatQueue = []; // this should never happen
			if (this.chatQueue.length >= THROTTLE_BUFFER_LIMIT - 1) {
				connection.sendTo(
					room,
					`|raw|<strong class="message-throttle-notice">Your message was not sent because you've been typing too quickly.</strong>`
				);
				return false;
			} else {
				this.chatQueue.push([message, room ? room.roomid : '', connection]);
			}
		} else if (now < this.lastChatMessage + throttleDelay) {
			this.chatQueue = [[message, room ? room.roomid : '', connection]];
			this.startChatQueue(throttleDelay - (now - this.lastChatMessage));
		} else {
			this.lastChatMessage = now;
			Monitor.activeIp = connection.ip;
			Chat.parse(message, room, this, connection);
			Monitor.activeIp = null;
		}
	}
	startChatQueue(delay: number | null = null) {
		if (delay === null) {
			delay = (this.isPublicBot ? THROTTLE_DELAY_PUBLIC_BOT : this.trusted ? THROTTLE_DELAY_TRUSTED :
				THROTTLE_DELAY) - (Date.now() - this.lastChatMessage);
		}

		this.chatQueueTimeout = setTimeout(
			() => this.processChatQueue(),
			delay
		);
	}
	clearChatQueue() {
		this.chatQueue = null;
		if (this.chatQueueTimeout) {
			clearTimeout(this.chatQueueTimeout);
			this.chatQueueTimeout = null;
		}
	}
	processChatQueue(): void {
		this.chatQueueTimeout = null;
		if (!this.chatQueue) return;
		const queueElement = this.chatQueue.shift();
		if (!queueElement) {
			this.chatQueue = null;
			return;
		}
		const [message, roomid, connection] = queueElement;
		if (!connection.user) {
			// connection disconnected, chat queue should not be big enough
			// for recursion to be an issue, also didn't ES6 spec tail
			// recursion at some point?
			return this.processChatQueue();
		}

		this.lastChatMessage = new Date().getTime();

		const room = Rooms.get(roomid);
		if (room || !roomid) {
			Monitor.activeIp = connection.ip;
			Chat.parse(message, room, this, connection);
			Monitor.activeIp = null;
		} else {
			// room no longer exists; do nothing
		}

		const throttleDelay = this.isPublicBot ? THROTTLE_DELAY_PUBLIC_BOT : this.trusted ? THROTTLE_DELAY_TRUSTED :
			THROTTLE_DELAY;

		if (this.chatQueue.length) {
			this.chatQueueTimeout = setTimeout(() => this.processChatQueue(), throttleDelay);
		} else {
			this.chatQueue = null;
		}
	}
	setStatusType(type: StatusType) {
		if (type === this.statusType) return;
		this.statusType = type;
		this.updateIdentity();
		this.update();
	}
	setUserMessage(message: string) {
		if (message === this.userMessage) return;
		this.userMessage = message;
		this.updateIdentity();
	}
	clearStatus(type: StatusType = this.statusType) {
		this.statusType = type;
		this.userMessage = '';
		this.updateIdentity();
	}
	getAccountStatusString() {
		return this.trusted === this.id ? `[trusted]` :
			this.autoconfirmed === this.id ? `[ac]` :
			this.registered ? `[registered]` :
			``;
	}
	destroy() {
		// deallocate user
		for (const roomid of this.games) {
			const game = Rooms.get(roomid)?.game;
			if (!game) {
				Monitor.warn(`while deallocating, room ${roomid} did not have a game for ${this.id} in rooms ${[...this.inRooms]} and games ${[...this.games]}`);
				this.games.delete(roomid);
				continue;
			}
			if (!game.ended) game.forfeit?.(this, " lost by being offline too long.");
		}
		this.clearChatQueue();
		this.destroyPunishmentTimer();
		Users.delete(this);
	}
	destroyPunishmentTimer() {
		if (this.punishmentTimer) {
			clearTimeout(this.punishmentTimer);
			this.punishmentTimer = null;
		}
	}
	override toString() {
		return this.id;
	}
}

/*********************************************************
 * Inactive user pruning
 *********************************************************/

function pruneInactive(threshold: number) {
	const now = Date.now();
	for (const user of users.values()) {
		if (user.statusType === 'online') {
			// check if we should set status to idle
			const awayTimer = user.can('lock') ? STAFF_IDLE_TIMER : IDLE_TIMER;
			const bypass = !user.can('bypassall') && (
				user.can('bypassafktimer') ||
				Array.from(user.inRooms).some(room => user.can('bypassafktimer', null, Rooms.get(room)!))
			);
			if (!bypass && !user.connections.some(connection => now - connection.lastActiveTime < awayTimer)) {
				user.setStatusType('idle');
			}
		}
		if (!user.connected && (now - user.lastDisconnected) > threshold) {
			user.destroy();
		}
		if (!user.can('addhtml')) {
			const suspicious = global.Config?.isSuspicious?.(user) || false;
			for (const connection of user.connections) {
				if (
					// conn's been inactive for 24h, just kill it
					(now - connection.lastActiveTime > CONNECTION_EXPIRY_TIME) ||
					// they're connected and not named, but not namelocked. this is unusual behavior, ultimately just wasting resources.
					// people have been spamming us with conns as of writing this, so it appears to be largely bots doing this.
					// so we're just gonna go ahead and dc them. if they're a real user, they can rejoin and go back to... whatever.
					suspicious && (now - connection.connectedAt) > threshold
				) {
					connection.destroy();
				}
			}
		}
	}
}

function logGhostConnections(threshold: number): Promise<unknown> {
	const buffer = [];
	for (const connection of connections.values()) {
		// If the connection's been around for at least a week and it doesn't
		// use raw WebSockets (which doesn't have any kind of keepalive or
		// timeouts on it), log it.
		if (connection.protocol !== 'websocket-raw' && connection.connectedAt <= Date.now() - threshold) {
			const timestamp = Chat.toTimestamp(new Date(connection.connectedAt));
			const now = Chat.toTimestamp(new Date());
			const log = `Connection ${connection.id} from ${connection.ip} with protocol "${connection.protocol}" has been around since ${timestamp} (currently ${now}).`;
			buffer.push(log);
		}
	}
	return buffer.length ?
		Monitor.logPath(`ghosts-${process.pid}.log`).append(buffer.join('\r\n') + '\r\n') :
		Promise.resolve();
}

/*********************************************************
 * Routing
 *********************************************************/

function socketConnect(
	worker: ProcessManager.StreamWorker,
	workerid: number,
	socketid: string,
	ip: string,
	protocol: string
) {
	const id = `${workerid}-${socketid}`;
	const connection = new Connection(id, worker, socketid, null, ip, protocol);
	connections.set(id, connection);

	const banned = Punishments.checkIpBanned(connection);
	if (banned) {
		return connection.destroy();
	}
	// Emergency mode connections logging
	if (Config.emergency) {
		void Monitor.logPath('cons.emergency.log').append('[' + ip + ']\n');
	}

	const user = new User(connection);
	connection.user = user;
	void Punishments.checkIp(user, connection);
	// Generate 1024-bit challenge string.
	require('crypto').randomBytes(128, (err: Error | null, buffer: Buffer) => {
		if (err) {
			// It's not clear what sort of condition could cause this.
			// For now, we'll basically assume it can't happen.
			Monitor.crashlog(err, 'randomBytes');
			// This is pretty crude, but it's the easiest way to deal
			// with this case, which should be impossible anyway.
			user.disconnectAll();
		} else if (connection.user) {	// if user is still connected
			connection.challenge = buffer.toString('hex');
			// console.log('JOIN: ' + connection.user.name + ' [' + connection.challenge.substr(0, 15) + '] [' + socket.id + ']');
			const keyid = Config.loginserverpublickeyid || 0;
			connection.sendTo(null, `|challstr|${keyid}|${connection.challenge}`);
		}
	});

	Rooms.global.handleConnect(user, connection);
}
function socketDisconnect(worker: ProcessManager.StreamWorker, workerid: number, socketid: string) {
	const id = `${workerid}-${socketid}`;

	const connection = connections.get(id);
	if (!connection) return;
	connection.onDisconnect();
}
function socketDisconnectAll(worker: ProcessManager.StreamWorker, workerid: number) {
	for (const connection of connections.values()) {
		if (connection.worker === worker) {
			connection.onDisconnect();
		}
	}
}
function socketReceive(worker: ProcessManager.StreamWorker, workerid: number, socketid: string, message: string) {
	const id = `${workerid}-${socketid}`;

	const connection = connections.get(id);
	if (!connection) return;
	connection.lastActiveTime = Date.now();

	// Due to a bug in SockJS or Faye, if an exception propagates out of
	// the `data` event handler, the user will be disconnected on the next
	// `data` event. To prevent this, we log exceptions and prevent them
	// from propagating out of this function.

	// drop legacy JSON messages
	if (message.startsWith('{')) return;

	const pipeIndex = message.indexOf('|');
	if (pipeIndex < 0) {
		// drop invalid messages without a pipe character
		connection.popup(`Invalid message; messages should be in the format \`ROOMID|MESSAGE\`. See https://github.com/smogon/pokemon-showdown/blob/master/PROTOCOL.md`);
		return;
	}

	const user = connection.user;
	if (!user) return;

	// LEGACY: In the past, an empty room ID would default to Lobby,
	// but that is no longer supported
	const roomId = message.slice(0, pipeIndex) || '';
	message = message.slice(pipeIndex + 1);

	const room = Rooms.get(roomId) || null;
	const multilineMessage = Chat.multiLinePattern.test(message);
	if (multilineMessage) {
		user.chat(multilineMessage, room, connection);
		return;
	}

	const lines = message.split('\n');
	if (!lines[lines.length - 1]) lines.pop();
	const maxLineCount = (
		user.can('bypassall') ? THROTTLE_MULTILINE_WARN_ADMIN :
		(user.isStaff || room?.auth.isStaff(user.id)) ?
			THROTTLE_MULTILINE_WARN_STAFF : THROTTLE_MULTILINE_WARN
	);
	if (lines.length > maxLineCount && !Config.nothrottle) {
		connection.popup(`You're sending too many lines at once. Try using a paste service like [[Pastebin]].`);
		return;
	}
	// Emergency logging
	if (Config.emergency) {
		void Monitor.logPath('emergency.log').append(`[${user} (${connection.ip})] ${roomId}|${message}\n`);
	}

	for (const line of lines) {
		if (user.chat(line, room, connection) === false) break;
	}
}

const users = new Map<ID, User>();
const prevUsers = new Map<ID, ID>();
let numUsers = 0;

export const Users = {
	delete: deleteUser,
	move,
	add,
	merge,
	users,
	prevUsers,
	onlineCount: 0,
	get: getUser,
	getExact: getExactUser,
	findUsers,
	Auth,
	Avatars: null as typeof import('./chat-commands/avatars').Avatars | null,
	globalAuth,
	isUsernameKnown,
	isUsername,
	isTrusted,
	isPublicBot,
	PLAYER_SYMBOL,
	HOST_SYMBOL,
	connections,
	User,
	Connection,
	socketDisconnect,
	socketDisconnectAll,
	socketReceive,
	pruneInactive,
	pruneInactiveTimer: setInterval(() => {
		pruneInactive(Config.inactiveuserthreshold || 60 * MINUTES);
	}, 30 * MINUTES),
	logGhostConnections,
	logGhostConnectionsTimer: setInterval(() => {
		void logGhostConnections(7 * 24 * 60 * MINUTES);
	}, 7 * 24 * 60 * MINUTES),
	socketConnect,
};
