/**
 * Rooms
 * Pokemon Showdown - http://pokemonshowdown.com/
 *
 * Every chat room and battle is a room, and what they do is done in
 * rooms.ts. There's also a global room which every user is in, and
 * handles miscellaneous things like welcoming the user.
 *
 * `Rooms.rooms` is the global table of all rooms, a `Map` of `RoomID:Room`.
 * Rooms should normally be accessed with `Rooms.get(roomid)`.
 *
 * All rooms extend `BasicRoom`, whose important properties like `.users`
 * and `.game` are documented near the the top of its class definition.
 *
 * @license MIT
 */

const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'.split('');

const TIMEOUT_EMPTY_DEALLOCATE = 10 * 60 * 1000;
const TIMEOUT_INACTIVE_DEALLOCATE = 40 * 60 * 1000;
const REPORT_USER_STATS_INTERVAL = 10 * 60 * 1000;
const MAX_CHATROOM_ID_LENGTH = 225;

const CRASH_REPORT_THROTTLE = 60 * 60 * 1000;

const LAST_BATTLE_WRITE_THROTTLE = 10;

const RETRY_AFTER_LOGIN = null;

import {FS, Utils, Streams} from '../lib';
import {RoomSection, RoomSections} from './chat-commands/room-settings';
import {QueuedHunt} from './chat-plugins/scavengers';
import {ScavengerGameTemplate} from './chat-plugins/scavenger-games';
import {RepeatedPhrase} from './chat-plugins/repeats';
import {
	PM as RoomBattlePM, RoomBattle, RoomBattlePlayer, RoomBattleTimer, type RoomBattleOptions,
} from "./room-battle";
import {BestOfGame} from './room-battle-bestof';
import {RoomGame, SimpleRoomGame, RoomGamePlayer} from './room-game';
import {MinorActivity, MinorActivityData} from './room-minor-activity';
import {Roomlogs, type Roomlog} from './roomlogs';
import {RoomAuth} from './user-groups';
import {PartialModlogEntry, mainModlog} from './modlog';
import {Replays} from './replays';

/*********************************************************
 * the Room object.
 *********************************************************/

interface MuteEntry {
	userid: ID;
	time: number;
	guestNum: number;
	autoconfirmed: string;
}

interface ChatRoomTable {
	title: string;
	desc: string;
	userCount: number;
	section?: string;
	subRooms?: string[];
	spotlight?: string;
	privacy: RoomSettings['isPrivate'];
}

interface ShowRequest {
	name: string;
	link: string;
	comment: string;
	dimensions?: [number, number, boolean];
}

interface BattleRoomTable {
	p1?: string;
	p2?: string;
	minElo?: 'tour' | number;
}

interface UserTable {
	[userid: string]: User;
}

export interface RoomSettings {
	title: string;
	auth: {[userid: string]: GroupSymbol};
	creationTime: number;
	section?: RoomSection;

	readonly autojoin?: boolean;
	aliases?: string[];
	banwords?: string[];
	isPrivate?: PrivacySetting;
	modjoin?: AuthLevel | true | null;
	modchat?: AuthLevel | null;
	staffRoom?: boolean;
	language?: ID | false;
	slowchat?: number | false;
	events?: {[k: string]: RoomEvent | RoomEventAlias | RoomEventCategory};
	filterStretching?: boolean;
	filterEmojis?: boolean;
	filterCaps?: boolean;
	filterLinks?: boolean;
	jeopardyDisabled?: boolean;
	mafiaDisabled?: boolean;
	unoDisabled?: boolean;
	hangmanDisabled?: boolean;
	auctionDisabled?: boolean;
	gameNumber?: number;
	highTraffic?: boolean;
	spotlight?: string;
	parentid?: string | null;
	desc?: string | null;
	introMessage?: string | null;
	staffMessage?: string | null;
	rulesLink?: string | null;
	dataCommandTierDisplay?: 'tiers' | 'doubles tiers' | 'National Dex tiers' | 'numbers';
	requestShowEnabled?: boolean | null;
	permissions?: {[k: string]: GroupSymbol};
	minorActivity?: PollData | AnnouncementData;
	minorActivityQueue?: MinorActivityData[];
	repeats?: RepeatedPhrase[];
	topics?: string[];
	autoModchat?: {
		rank: GroupSymbol,
		time: number,
		// stores previous modchat setting. if true, modchat was fully off
		active: boolean | AuthLevel,
	};
	tournaments?: TournamentRoomSettings;
	defaultFormat?: string;

	scavSettings?: AnyObject;
	scavQueue?: QueuedHunt[];

	// should not ever be saved because they're inapplicable to persistent rooms
	/** This includes groupchats, battles, and help-ticket rooms. */
	isPersonal?: boolean;
	isHelp?: boolean;
	noLogTimes?: boolean;
	noAutoTruncate?: boolean;
	isMultichannel?: boolean;
}

export type MessageHandler = (room: BasicRoom, message: string) => void;
export type Room = GameRoom | ChatRoom;
export type PrivacySetting = boolean | 'hidden' | 'voice' | 'unlisted';

import type {AnnouncementData} from './chat-plugins/announcements';
import type {PollData} from './chat-plugins/poll';
import type {AutoResponder} from './chat-plugins/responder';
import type {RoomEvent, RoomEventAlias, RoomEventCategory} from './chat-plugins/room-events';
import type {Tournament, TournamentRoomSettings} from './tournaments/index';

export abstract class BasicRoom {
	/** to rename use room.rename */
	readonly roomid: RoomID;
	title: string;
	readonly type: 'chat' | 'battle';
	readonly users: UserTable;
	/**
	 * Scrollback log. This is the log that's sent to users when
	 * joining the room. Should roughly match what's on everyone's
	 * screen.
	 */
	readonly log: Roomlog;
	/**
	 * The room's current RoomGame, if it exists. Each room can have 0 to 2
	 * `RoomGame`s, and `this.game.room === this`.
	 * Rooms may also have an additional game in `this.subGame`.
	 * However, `subGame`s do not update `user.game`.
	 */
	game: RoomGame | null;
	subGame: RoomGame | null;
	/**
	 * The room's current battle. Battles are a type of RoomGame, so in battle
	 * rooms (which can only be `GameRoom`s), `this.battle === this.game`.
	 * In all other rooms, `this.battle` is `null`.
	 */
	battle: RoomBattle | null;
	/**
	 * The room's current best-of set. Best-of sets are a type of RoomGame, so in best-of set
	 * rooms (which can only be `GameRoom`s), `this.bestof === this.game`.
	 * In all other rooms, `this.bestof` is `null`.
	 */
	bestOf: BestOfGame | null;
	/**
	 * The game room's current tournament. If the room is a battle room whose
	 * battle is part of a tournament, `this.tour === this.parent.game`.
	 * In all other rooms, `this.tour` is `null`.
	 */
	tour: Tournament | null;

	auth: RoomAuth;
	/** use `setParent` to set this */
	readonly parent: Room | null;
	/** use `subroom.setParent` to set this, or `clearSubRooms` to clear it */
	readonly subRooms: ReadonlyMap<string, Room> | null;

	readonly muteQueue: MuteEntry[];
	userCount: number;
	active: boolean;
	muteTimer: NodeJS.Timer | null;
	modchatTimer: NodeJS.Timer | null;
	lastUpdate: number;
	lastBroadcast: string;
	lastBroadcastTime: number;
	settings: RoomSettings;
	/** If true, this room's settings will be saved in config/chatrooms.json, allowing it to stay past restarts. */
	persist: boolean;

	scavgame: ScavengerGameTemplate | null;
	scavLeaderboard: AnyObject;
	responder?: AutoResponder | null;
	privacySetter?: Set<ID> | null;
	hideReplay: boolean;

	reportJoins: boolean;
	batchJoins: number;
	reportJoinsInterval: NodeJS.Timer | null;

	minorActivity: MinorActivity | null;
	minorActivityQueue: MinorActivityData[] | null;
	banwordRegex: RegExp | true | null;
	logUserStatsInterval: NodeJS.Timer | null;
	expireTimer: NodeJS.Timer | null;
	userList: string;
	pendingApprovals: Map<string, ShowRequest> | null;

	messagesSent: number;
	/**
	 * These handlers will be invoked every n messages.
	 * handler:number-of-messages map
	 */
	nthMessageHandlers: Map<MessageHandler, number>;

	constructor(roomid: RoomID, title?: string, options: Partial<RoomSettings> = {}) {
		this.users = Object.create(null);
		this.type = 'chat';
		this.muteQueue = [];

		this.battle = null;
		this.bestOf = null;
		this.game = null;
		this.subGame = null;
		this.tour = null;

		this.roomid = roomid;
		this.title = (title || roomid);
		this.parent = null;

		this.userCount = 0;

		this.game = null;
		this.active = false;

		this.muteTimer = null;

		this.lastUpdate = 0;
		this.lastBroadcast = '';
		this.lastBroadcastTime = 0;

		// room settings

		this.settings = {
			title: this.title,
			auth: Object.create(null),
			creationTime: Date.now(),
		};
		this.persist = false;
		this.hideReplay = false;
		this.subRooms = null;
		this.scavgame = null;
		this.scavLeaderboard = {};
		this.auth = new RoomAuth(this);

		this.reportJoins = true;
		this.batchJoins = 0;
		this.reportJoinsInterval = null;

		options.title = this.title;
		if (options.isHelp) options.noAutoTruncate = true;
		this.reportJoins = !!(Config.reportjoins || options.isPersonal);
		this.batchJoins = options.isPersonal ? 0 : Config.reportjoinsperiod || 0;
		if (!options.auth) options.auth = {};

		this.log = Roomlogs.create(this, options);

		this.banwordRegex = null;

		this.settings = options as RoomSettings;
		if (!this.settings.creationTime) this.settings.creationTime = Date.now();
		this.auth.load();

		if (!options.isPersonal) this.persist = true;

		this.minorActivity = null;
		this.minorActivityQueue = null;
		if (options.parentid) {
			this.setParent(Rooms.get(options.parentid) || null);
		}

		this.subRooms = null;

		this.active = false;
		this.muteTimer = null;
		this.modchatTimer = null;

		this.logUserStatsInterval = null;
		this.expireTimer = null;
		if (Config.logchat) {
			this.roomlog('NEW CHATROOM: ' + this.roomid);
			if (Config.loguserstats) {
				this.logUserStatsInterval = setInterval(() => this.logUserStats(), Config.loguserstats);
			}
		}

		this.userList = '';
		if (this.batchJoins) {
			this.userList = this.getUserList();
		}
		this.pendingApprovals = null;
		this.messagesSent = 0;
		this.nthMessageHandlers = new Map();
		this.tour = null;
		this.game = null;
		this.battle = null;
		this.validateTitle(this.title, this.roomid);
	}

	toString() {
		return this.roomid;
	}

	/**
	 * Send a room message to all users in the room, without recording it
	 * in the scrollback log.
	 */
	send(message: string) {
		if (this.roomid !== 'lobby') message = '>' + this.roomid + '\n' + message;
		if (this.userCount) Sockets.roomBroadcast(this.roomid, message);
	}
	sendMods(data: string) {
		this.sendRankedUsers(data, '*');
	}
	sendRankedUsers(data: string, minRank: GroupSymbol = '+') {
		if (this.settings.staffRoom) {
			if (!this.log) throw new Error(`Staff room ${this.roomid} has no log`);
			this.log.add(data);
			return;
		}

		for (const i in this.users) {
			const user = this.users[i];
			// hardcoded for performance reasons (this is an inner loop)
			if (user.isStaff || this.auth.atLeast(user, minRank)) {
				user.sendTo(this, data);
			}
		}
	}
	/**
	 * Send a room message to a single user.
	 */
	sendUser(user: Connection | User, message: string) {
		user.sendTo(this, message);
	}
	/**
	 * Add a room message to the room log, so it shows up in the room
	 * for everyone, and appears in the scrollback for new users who
	 * join.
	 */
	add(message: string) {
		this.log.add(message);
		return this;
	}
	roomlog(message: string) {
		this.log.roomlog(message);
		return this;
	}
	/**
	 * Writes an entry to the modlog for that room, and the global modlog if entry.isGlobal is true.
	 */
	modlog(entry: PartialModlogEntry) {
		const override = this.tour ? `${this.roomid} tournament: ${this.tour.roomid}` : undefined;
		this.log.modlog(entry, override);
		return this;
	}
	uhtmlchange(name: string, message: string) {
		this.log.uhtmlchange(name, message);
	}
	attributedUhtmlchange(user: User, name: string, message: string) {
		this.log.attributedUhtmlchange(user, name, message);
	}
	hideText(userids: ID[], lineCount = 0, hideRevealButton?: boolean) {
		const cleared = this.log.clearText(userids, lineCount);
		for (const userid of cleared) {
			this.send(`|hidelines|${hideRevealButton ? 'delete' : 'hide'}|${userid}|${lineCount}`);
		}
		this.update();
	}
	/**
	 * Inserts (sanitized) HTML into the room log.
	 */
	addRaw(message: string) {
		return this.add('|raw|' + message);
	}
	/**
	 * Inserts some text into the room log, attributed to user. The
	 * attribution will not appear, and is used solely as a hint not to
	 * highlight the user.
	 */
	addByUser(user: User, text: string): this {
		return this.add('|c|' + user.getIdentity(this) + '|/log ' + text);
	}
	/**
	 * Like addByUser, but without logging
	 */
	sendByUser(user: User | null, text: string) {
		this.send('|c|' + (user ? user.getIdentity(this) : '~') + '|/log ' + text);
	}
	/**
	 * Like addByUser, but sends to mods only.
	 */
	sendModsByUser(user: User, text: string) {
		this.sendMods('|c|' + user.getIdentity(this) + '|/log ' + text);
	}
	update() {
		if (!this.log.broadcastBuffer.length) return;
		if (this.reportJoinsInterval) {
			clearInterval(this.reportJoinsInterval);
			this.reportJoinsInterval = null;
			this.userList = this.getUserList();
		}
		this.send(this.log.broadcastBuffer.join('\n'));
		this.log.broadcastBuffer = [];
		this.log.truncate();

		this.pokeExpireTimer();
	}

	getUserList() {
		let buffer = '';
		let counter = 0;
		for (const i in this.users) {
			if (!this.users[i].named) {
				continue;
			}
			counter++;
			buffer += ',' + this.users[i].getIdentityWithStatus(this);
		}
		const msg = `|users|${counter}${buffer}`;
		return msg;
	}

	nextGameNumber() {
		const gameNumber = (this.settings.gameNumber || 0) + 1;
		this.settings.gameNumber = gameNumber;
		this.saveSettings();
		return gameNumber;
	}

	// mute handling

	runMuteTimer(forceReschedule = false) {
		if (forceReschedule && this.muteTimer) {
			clearTimeout(this.muteTimer);
			this.muteTimer = null;
		}
		if (this.muteTimer || this.muteQueue.length === 0) return;

		const timeUntilExpire = this.muteQueue[0].time - Date.now();
		if (timeUntilExpire <= 1000) { // one second of leeway
			this.unmute(this.muteQueue[0].userid, "Your mute in '" + this.title + "' has expired.");
			// runMuteTimer() is called again in unmute() so this function instance should be closed
			return;
		}
		this.muteTimer = setTimeout(() => {
			this.muteTimer = null;
			this.runMuteTimer(true);
		}, timeUntilExpire);
	}
	isMuted(user: User): ID | undefined {
		if (!user) return;
		if (this.muteQueue) {
			for (const entry of this.muteQueue) {
				if (user.id === entry.userid ||
					user.guestNum === entry.guestNum ||
					(user.autoconfirmed && user.autoconfirmed === entry.autoconfirmed)) {
					if (entry.time - Date.now() < 0) {
						this.unmute(user.id);
						return;
					} else {
						return entry.userid;
					}
				}
			}
		}
		if (this.parent) return this.parent.isMuted(user);
	}
	getMuteTime(user: User): number | undefined {
		const userid = this.isMuted(user);
		if (!userid) return;
		for (const entry of this.muteQueue) {
			if (userid === entry.userid) {
				return entry.time - Date.now();
			}
		}
		if (this.parent) return this.parent.getMuteTime(user);
	}
	// I think putting the `new` before the signature is confusing the linter
	// eslint-disable-next-line @typescript-eslint/type-annotation-spacing
	getGame<T extends RoomGame>(constructor: new (...args: any[]) => T, subGame = false): T | null {
		// TODO: switch to `static readonly gameid` when all game files are TypeScripted
		if (subGame && this.subGame && this.subGame.constructor.name === constructor.name) return this.subGame as T;
		if (this.game && this.game.constructor.name === constructor.name) return this.game as T;
		return null;
	}
	getMinorActivity<T extends MinorActivity>(constructor: new (...args: any[]) => T): T | null {
		if (this.minorActivity?.constructor.name === constructor.name) return this.minorActivity as T;
		return null;
	}
	getMinorActivityQueue(settings = false): MinorActivityData[] | null {
		const usedQueue = settings ? this.settings.minorActivityQueue : this.minorActivityQueue;
		if (!usedQueue?.length) return null;
		return usedQueue;
	}
	queueMinorActivity(activity: MinorActivityData): void {
		if (!this.minorActivityQueue) this.minorActivityQueue = [];
		this.minorActivityQueue.push(activity);
		this.settings.minorActivityQueue = this.minorActivityQueue;
	}
	clearMinorActivityQueue(slot?: number, depth = 1) {
		if (!this.minorActivityQueue) return;
		if (slot === undefined) {
			this.minorActivityQueue = null;
			delete this.settings.minorActivityQueue;
			this.saveSettings();
		} else {
			this.minorActivityQueue.splice(slot, depth);
			this.settings.minorActivityQueue = this.minorActivityQueue;
			this.saveSettings();
			if (!this.minorActivityQueue.length) this.clearMinorActivityQueue();
		}
	}
	setMinorActivity(activity: MinorActivity | null, noDisplay = false): void {
		this.minorActivity?.endTimer();
		this.minorActivity = activity;
		if (this.minorActivity) {
			this.minorActivity.save();
			if (!noDisplay) this.minorActivity.display();
		} else {
			delete this.settings.minorActivity;
			this.saveSettings();
		}
	}
	saveSettings() {
		if (!this.persist) return;

		if (!Rooms.global) return; // during initialization

		Rooms.global.writeChatRoomData();
	}
	checkModjoin(user: User) {
		if (user.id in this.users) return true;
		if (!this.settings.modjoin) return true;
		// users with a room rank can always join
		if (this.auth.has(user.id)) return true;

		const modjoinSetting = this.settings.modjoin !== true ? this.settings.modjoin : this.settings.modchat;
		if (!modjoinSetting) return true;
		if (!Users.Auth.isAuthLevel(modjoinSetting)) {
			Monitor.error(`Invalid modjoin setting in ${this.roomid}: ${modjoinSetting}`);
		}
		return (
			this.auth.atLeast(user, modjoinSetting) || Users.globalAuth.atLeast(user, modjoinSetting)
		);
	}
	mute(user: User, setTime?: number) {
		const userid = user.id;

		if (!setTime) setTime = 7 * 60000; // default time: 7 minutes
		if (setTime > 90 * 60000) setTime = 90 * 60000; // limit 90 minutes

		// If the user is already muted, the existing queue position for them should be removed
		if (this.isMuted(user)) this.unmute(userid);

		// Place the user in a queue for the unmute timer
		for (let i = 0; i <= this.muteQueue.length; i++) {
			const time = Date.now() + setTime;
			if (i === this.muteQueue.length || time < this.muteQueue[i].time) {
				const entry = {
					userid,
					time,
					guestNum: user.guestNum,
					autoconfirmed: user.autoconfirmed,
				};
				this.muteQueue.splice(i, 0, entry);
				// The timer needs to be switched to the new entry if it is to be unmuted
				// before the entry the timer is currently running for
				if (i === 0 && this.muteTimer) {
					clearTimeout(this.muteTimer);
					this.muteTimer = null;
				}
				break;
			}
		}
		this.runMuteTimer();

		user.updateIdentity();

		if (!(this.settings.isPrivate === true || this.settings.isPersonal)) {
			void Punishments.monitorRoomPunishments(user);
		}

		return userid;
	}
	unmute(userid: string, notifyText?: string) {
		let successUserid = '';
		const user = Users.get(userid);
		let autoconfirmed = '';
		if (user) {
			userid = user.id;
			autoconfirmed = user.autoconfirmed;
		}

		for (const [i, entry] of this.muteQueue.entries()) {
			if (entry.userid === userid ||
				(user && entry.guestNum === user.guestNum) ||
				(autoconfirmed && entry.autoconfirmed === autoconfirmed)) {
				if (i === 0) {
					this.muteQueue.splice(0, 1);
					this.runMuteTimer(true);
				} else {
					this.muteQueue.splice(i, 1);
				}
				successUserid = entry.userid;
				break;
			}
		}

		if (user && successUserid && userid in this.users) {
			user.updateIdentity();
			if (notifyText) user.popup(notifyText);
		}
		return successUserid;
	}

	logUserStats() {
		let total = 0;
		let guests = 0;
		const groups: {[k: string]: number} = {};
		for (const group of Config.groupsranking) {
			groups[group] = 0;
		}
		for (const i in this.users) {
			const user = this.users[i];
			++total;
			if (!user.named) {
				++guests;
			}
			++groups[this.auth.get(user.id)];
		}
		let entry = '|userstats|total:' + total + '|guests:' + guests;
		for (const i in groups) {
			entry += '|' + i + ':' + groups[i];
		}
		this.roomlog(entry);
	}

	pokeExpireTimer() {
		if (this.expireTimer) clearTimeout(this.expireTimer);
		if (this.settings.isPersonal) {
			this.expireTimer = setTimeout(() => this.expire(), TIMEOUT_INACTIVE_DEALLOCATE);
		} else {
			this.expireTimer = null;
		}
	}
	expire() {
		this.send('|expire|');
		this.destroy();
	}
	reportJoin(type: 'j' | 'l' | 'n', entry: string, user: User) {
		const canTalk = this.auth.atLeast(user, this.settings.modchat ?? 'unlocked') && !this.isMuted(user);
		if (this.reportJoins && (canTalk || this.auth.has(user.id))) {
			this.add(`|${type}|${entry}`).update();
			return;
		}
		let ucType = '';
		switch (type) {
		case 'j': ucType = 'J'; break;
		case 'l': ucType = 'L'; break;
		case 'n': ucType = 'N'; break;
		}
		entry = `|${ucType}|${entry}`;
		if (this.batchJoins) {
			this.log.broadcastBuffer.push(entry);

			if (!this.reportJoinsInterval) {
				this.reportJoinsInterval = setTimeout(
					() => this.update(), this.batchJoins
				);
			}
		} else {
			this.send(entry);
		}
		this.roomlog(entry);
	}
	getIntroMessage(user: User) {
		let message = Utils.html`\n|raw|<div class="infobox"> You joined ${this.title}`;
		if (this.settings.modchat) {
			message += ` [${this.settings.modchat} or higher to talk]`;
		}
		if (this.settings.modjoin) {
			const modjoin = this.settings.modjoin === true ? this.settings.modchat : this.settings.modjoin;
			message += ` [${modjoin} or higher to join]`;
		}
		if (this.settings.slowchat) {
			message += ` [Slowchat ${this.settings.slowchat}s]`;
		}
		message += `</div>`;
		if (this.settings.introMessage) {
			message += `\n|raw|<div class="infobox infobox-roomintro"><div ${(this.settings.section !== 'official' ? 'class="infobox-limited"' : '')}>` +
				this.settings.introMessage.replace(/\n/g, '') +
				`</div></div>`;
		}
		const staffIntro = this.getStaffIntroMessage(user);
		if (staffIntro) message += `\n${staffIntro}`;
		return message;
	}
	getStaffIntroMessage(user: User) {
		if (!user.can('mute', null, this)) return ``;
		const messages = [];
		if (this.settings.staffMessage) {
			messages.push(`|raw|<div class="infobox">(Staff intro:)<br /><div>` +
				this.settings.staffMessage.replace(/\n/g, '') +
				`</div>`);
		}
		if (this.pendingApprovals?.size) {
			let message = `|raw|<div class="infobox">`;
			message += `<details open><summary>(Pending media requests: ${this.pendingApprovals.size})</summary>`;
			for (const [userid, entry] of this.pendingApprovals) {
				message += `<div class="infobox">`;
				message += `<strong>Requester ID:</strong> ${userid}<br />`;
				if (entry.dimensions) {
					const [width, height, resized] = entry.dimensions;
					message += `<strong>Link:</strong><br /> <img src="${entry.link}" width="${width}" height="${height}"><br />`;
					if (resized) message += `(Resized)<br />`;
				} else {
					message += `<strong>Link:</strong><br /> <a href="${entry.link}"">Link</a><br />`;
				}
				message += `<strong>Comment:</strong> ${entry.comment ? entry.comment : 'None.'}<br />`;
				message += `<button class="button" name="send" value="/approveshow ${userid}">Approve</button>` +
				`<button class="button" name="send" value="/denyshow ${userid}">Deny</button></div>`;
				message += `<hr />`;
			}
			message += `</details></div>`;
			messages.push(message);
		}
		if (!this.settings.isPrivate && !this.settings.isPersonal &&
				this.settings.modchat && this.settings.modchat !== 'autoconfirmed') {
			messages.push(`|raw|<div class="broadcast-red">Modchat currently set to ${this.settings.modchat}</div>`);
		}
		return messages.join('\n');
	}
	getSubRooms(includeSecret = false) {
		if (!this.subRooms) return [];
		return [...this.subRooms.values()].filter(
			room => includeSecret ? true : !room.settings.isPrivate && !room.settings.isPersonal
		);
	}
	validateTitle(newTitle: string, newID?: string, oldID?: string) {
		if (!newID) newID = toID(newTitle);
		// `,` is a delimiter used by a lot of /commands
		// `|` and `[` are delimiters used by the protocol
		// `-` has special meaning in roomids
		if (newTitle.includes(',') || newTitle.includes('|')) {
			throw new Chat.ErrorMessage(`Room title "${newTitle}" can't contain any of: ,|`);
		}
		if ((!newID.includes('-') || newID.startsWith('groupchat-')) && newTitle.includes('-')) {
			throw new Chat.ErrorMessage(`Room title "${newTitle}" can't contain -`);
		}
		if (newID.length > MAX_CHATROOM_ID_LENGTH) throw new Chat.ErrorMessage("The given room title is too long.");
		if (newID !== oldID && Rooms.search(newTitle)) throw new Chat.ErrorMessage(`The room '${newTitle}' already exists.`);
	}
	setParent(room: Room | null) {
		if (this.parent === room) return;

		if (this.parent) {
			(this.parent.subRooms as any).delete(this.roomid);
			if (!this.parent.subRooms!.size) {
				(this.parent.subRooms as any) = null;
			}
		}
		(this as any).parent = room;
		if (room) {
			if (!room.subRooms) {
				(room as any).subRooms = new Map();
			}
			(room as any).subRooms.set(this.roomid, this);
			this.settings.parentid = room.roomid;
		} else {
			delete this.settings.parentid;
		}

		this.saveSettings();

		for (const userid in this.users) {
			this.users[userid].updateIdentity(this.roomid);
		}
	}
	clearSubRooms() {
		if (!this.subRooms) return;
		for (const room of this.subRooms.values()) {
			(room as any).parent = null;
		}
		(this as any).subRooms = null;

		// this doesn't update parentid or subroom user symbols because it's
		// intended to be used for cleanup only
	}
	setPrivate(privacy: PrivacySetting) {
		this.settings.isPrivate = privacy;
		this.saveSettings();

		if (privacy) {
			for (const user of Object.values(this.users)) {
				if (!user.named) {
					user.leaveRoom(this.roomid);
					user.popup(`The room <<${this.roomid}>> has been made private; you must log in to be in private rooms.`);
				}
			}
		}

		if (this.battle || this.bestOf) {
			if (privacy) {
				if (this.roomid.endsWith('pw')) return true;

				// This is the same password generation approach as genPassword in the client replays.lib.php
				// but obviously will not match given mt_rand there uses a different RNG and seed.
				let password = '';
				for (let i = 0; i < 31; i++) password += ALPHABET[Math.floor(Math.random() * ALPHABET.length)];

				this.rename(this.title, `${this.roomid}-${password}pw` as RoomID, true);
			} else {
				if (!this.roomid.endsWith('pw')) return true;

				const lastDashIndex = this.roomid.lastIndexOf('-');
				if (lastDashIndex < 0) throw new Error(`invalid battle ID ${this.roomid}`);

				this.rename(this.title, this.roomid.slice(0, lastDashIndex) as RoomID);
			}
		}
		this.bestOf?.setPrivacyOfGames(privacy);

		if (this.game) {
			for (const player of this.game.players) {
				player.getUser()?.updateSearch();
			}
		}
	}
	validateSection(section: string) {
		const target = toID(section);
		if (!RoomSections.sections.includes(target as any)) {
			throw new Chat.ErrorMessage(`"${target}" is not a valid room section. Valid categories include: ${RoomSections.sections.join(', ')}`);
		}
		return target as RoomSection;
	}
	setSection(section?: string) {
		if (!this.persist) {
			throw new Chat.ErrorMessage(`You cannot change the section of temporary rooms.`);
		}
		if (section) {
			const validatedSection = this.validateSection(section);
			if (this.settings.isPrivate && [true, 'hidden'].includes(this.settings.isPrivate)) {
				throw new Chat.ErrorMessage(`Only public rooms can change their section.`);
			}
			const oldSection = this.settings.section;
			if (oldSection === section) {
				throw new Chat.ErrorMessage(`${this.title}'s room section is already set to "${RoomSections.sectionNames[oldSection]}".`);
			}
			this.settings.section = validatedSection;
			this.saveSettings();
			return validatedSection;
		}
		delete this.settings.section;
		this.saveSettings();
		return undefined;
	}

	/**
	 * Displays a warning popup to all non-staff users users in the room.
	 * Returns a list of all the user IDs that were warned.
	 */
	warnParticipants(message: string) {
		const warned = Object.values(this.users).filter(u => !u.can('lock'));
		for (const user of warned) {
			user.popup(`|modal|${message}`);
		}
		return warned;
	}

	/**
	 * @param newID Add this param if the roomid is different from `toID(newTitle)`
	 * @param noAlias Set this param to true to not redirect aliases and the room's old name to its new name.
	 */
	rename(newTitle: string, newID?: RoomID, noAlias?: boolean) {
		if (!newID) newID = toID(newTitle) as RoomID;
		const oldID = this.roomid;
		this.validateTitle(newTitle, newID, oldID);
		if (this.type === 'chat' && this.game) {
			throw new Chat.ErrorMessage(`Please finish your game (${this.game.title}) before renaming ${this.roomid}.`);
		}
		(this as any).roomid = newID;
		this.title = this.settings.title = newTitle;
		this.saveSettings();
		if (newID === oldID) {
			for (const user of Object.values(this.users)) {
				user.sendTo(this, `|title|${newTitle}`);
			}
			return;
		}

		Rooms.rooms.delete(oldID);
		Rooms.rooms.set(newID, this as Room);
		if (this.battle && oldID) {
			for (const player of this.battle.players) {
				if (player.invite) {
					const chall = Ladders.challenges.searchByRoom(player.invite, oldID);
					if (chall) chall.roomid = this.roomid;
				}
			}
		}

		if (oldID === 'lobby') {
			Rooms.lobby = null;
		} else if (newID === 'lobby') {
			Rooms.lobby = this as ChatRoom;
		}

		if (!noAlias) {
			for (const [alias, roomid] of Rooms.aliases.entries()) {
				if (roomid === oldID) {
					Rooms.aliases.set(alias, newID);
				}
			}

			// add an alias from the old id
			Rooms.aliases.set(oldID, newID);
			if (!this.settings.aliases) this.settings.aliases = [];
			// resolve an old (fixed) bug in /renameroom
			if (!this.settings.aliases.includes(oldID)) this.settings.aliases.push(oldID);
		} else {
			// clear aliases
			for (const [alias, roomid] of Rooms.aliases.entries()) {
				if (roomid === oldID) {
					Rooms.aliases.delete(alias);
				}
			}
			this.settings.aliases = undefined;
		}

		this.game?.renameRoom(newID);

		for (const user of Object.values(this.users)) {
			user.moveConnections(oldID, newID);
			user.send(`>${oldID}\n|noinit|rename|${newID}|${newTitle}`);
		}

		if (this.parent && this.parent.subRooms) {
			(this as any).parent.subRooms.delete(oldID);
			(this as any).parent.subRooms.set(newID, this as ChatRoom);
		}
		if (this.subRooms) {
			for (const subRoom of this.subRooms.values()) {
				(subRoom as any).parent = this as ChatRoom;
				subRoom.settings.parentid = newID;
			}
		}

		this.saveSettings();

		Punishments.renameRoom(oldID, newID);

		void this.log.rename(newID);
	}

	onConnect(user: User, connection: Connection) {
		const userList = this.userList ? this.userList : this.getUserList();
		this.sendUser(
			connection,
			'|init|chat\n|title|' + this.title + '\n' + userList + '\n' + this.log.getScrollback() + this.getIntroMessage(user)
		);
		this.minorActivity?.onConnect?.(user, connection);
		this.game?.onConnect?.(user, connection);
	}
	onJoin(user: User, connection: Connection) {
		if (!user) return false; // ???
		if (this.users[user.id]) return false;

		Chat.runHandlers('onBeforeRoomJoin', this, user, connection);
		if (user.named) {
			this.reportJoin('j', user.getIdentityWithStatus(this), user);
		}

		const staffIntro = this.getStaffIntroMessage(user);
		if (staffIntro) this.sendUser(user, staffIntro);

		this.users[user.id] = user;
		this.userCount++;
		this.checkAutoModchat(user);

		this.game?.onJoin?.(user, connection);
		Chat.runHandlers('onRoomJoin', this, user, connection);
		return true;
	}
	onRename(user: User, oldid: ID, joining: boolean) {
		if (user.id === oldid) {
			return this.onUpdateIdentity(user);
		}
		if (!this.users[oldid]) {
			Monitor.crashlog(new Error(`user ${oldid} not in room ${this.roomid}`));
		}
		if (this.users[user.id]) {
			Monitor.crashlog(new Error(`user ${user.id} already in room ${this.roomid}`));
		}
		delete this.users[oldid];
		this.users[user.id] = user;
		if (joining) {
			this.reportJoin('j', user.getIdentityWithStatus(this), user);
			const staffIntro = this.getStaffIntroMessage(user);
			if (staffIntro) this.sendUser(user, staffIntro);
		} else if (!user.named) {
			this.reportJoin('l', ' ' + oldid, user);
		} else {
			this.reportJoin('n', user.getIdentityWithStatus(this) + '|' + oldid, user);
		}
		this.minorActivity?.onRename?.(user, oldid, joining);
		this.checkAutoModchat(user);
		return true;
	}
	/**
	 * onRename, but without a userid change
	 */
	onUpdateIdentity(user: User) {
		if (user?.connected) {
			if (!this.users[user.id]) return false;
			if (user.named) {
				this.reportJoin('n', user.getIdentityWithStatus(this) + '|' + user.id, user);
			}
		}
		return true;
	}
	onLeave(user: User) {
		if (!user) return false; // ...

		if (!(user.id in this.users)) {
			Monitor.crashlog(new Error(`user ${user.id} already left`));
			return false;
		}
		delete this.users[user.id];
		this.userCount--;

		if (user.named) {
			this.reportJoin('l', user.getIdentity(this), user);
		}
		this.game?.onLeave?.(user);
		this.runAutoModchat();

		return true;
	}

	runAutoModchat() {
		if (!this.settings.autoModchat || this.settings.autoModchat.active) return;
		// they are staff and online
		const staff = Object.values(this.users).filter(u => this.auth.atLeast(u, '%'));
		if (!staff.length) {
			const {time} = this.settings.autoModchat;
			if (!time || time < 5) {
				throw new Error(`Invalid time setting for automodchat (${Utils.visualize(this.settings.autoModchat)})`);
			}
			if (this.modchatTimer) return;
			this.modchatTimer = setTimeout(() => {
				if (!this.settings.autoModchat) return;
				const {rank} = this.settings.autoModchat;
				const oldSetting = this.settings.modchat;
				this.settings.modchat = rank;
				this.add(
					// always gonna be minutes so we can just use the number directly lol
					`|raw|<div class="broadcast-blue"><strong>This room has had no active staff for ${time} minutes,` +
					` and has had modchat set to ${rank}.</strong></div>`
				).update();
				this.modlog({
					action: 'AUTOMODCHAT ACTIVATE',
				});
				// automodchat will always exist
				this.settings.autoModchat.active = oldSetting || true;
				this.saveSettings();
				this.modchatTimer = null;
			}, time * 60 * 1000);
		}
	}

	checkAutoModchat(user: User) {
		if (user.can('mute', null, this, 'modchat')) {
			if (this.modchatTimer) {
				clearTimeout(this.modchatTimer);
			}
			if (this.settings.autoModchat?.active) {
				const oldSetting = this.settings.autoModchat.active;
				if (typeof oldSetting === 'string') {
					this.settings.modchat = oldSetting;
				} else {
					delete this.settings.modchat;
				}
				this.settings.autoModchat.active = false;
				this.saveSettings();
			}
		}
	}

	destroy(): void {
		// deallocate ourself

		if (this.game) {
			this.game.destroy();
			this.game = null;
			this.battle = null;
			this.tour = null;
		}

		// remove references to ourself
		for (const i in this.users) {
			this.users[i].leaveRoom(this as Room, null);
			delete this.users[i];
		}

		this.setParent(null);
		this.clearSubRooms();

		Chat.runHandlers('onRoomDestroy', this.roomid);

		Rooms.global.deregisterChatRoom(this.roomid);
		Rooms.global.delistChatRoom(this.roomid);

		if (this.settings.aliases) {
			for (const alias of this.settings.aliases) {
				Rooms.aliases.delete(alias);
			}
		}

		this.active = false;

		// Ensure there aren't any pending messages that could restart the expire timer
		this.update();

		// Clear any active timers for the room
		if (this.muteTimer) {
			clearTimeout(this.muteTimer);
			this.muteTimer = null;
		}
		if (this.expireTimer) {
			clearTimeout(this.expireTimer);
			this.expireTimer = null;
		}
		if (this.reportJoinsInterval) {
			clearInterval(this.reportJoinsInterval);
		}
		this.reportJoinsInterval = null;
		if (this.logUserStatsInterval) {
			clearInterval(this.logUserStatsInterval);
		}
		this.logUserStatsInterval = null;

		void this.log.destroy();

		Rooms.rooms.delete(this.roomid);
		if (this.roomid === 'lobby') Rooms.lobby = null;
	}
	tr(strings: string | TemplateStringsArray, ...keys: any[]) {
		return Chat.tr(this.settings.language || 'english' as ID, strings, ...keys);
	}
}

export class GlobalRoomState {
	readonly settingsList: RoomSettings[];
	readonly chatRooms: ChatRoom[];
	/**
	 * Rooms that users autojoin upon connecting
	 */
	readonly autojoinList: RoomID[];
	/**
	 * Rooms that users autojoin upon logging in
	 */
	readonly modjoinedAutojoinList: RoomID[];
	readonly ladderIpLog: Streams.WriteStream;
	readonly reportUserStatsInterval: NodeJS.Timeout;
	lockdown: boolean | 'pre' | 'ddos';
	battleCount: number;
	lastReportedCrash: number;
	lastBattle: number;
	lastWrittenBattle: number;
	maxUsers: number;
	maxUsersDate: number;
	formatList: string;

	constructor() {
		this.settingsList = [];
		try {
			this.settingsList = require(FS('config/chatrooms.json').path);
			if (!Array.isArray(this.settingsList)) this.settingsList = [];
		} catch {} // file doesn't exist [yet]

		if (!this.settingsList.length) {
			this.settingsList = [{
				title: 'Lobby',
				auth: {},
				creationTime: Date.now(),
				autojoin: true,
				section: 'official',
			}, {
				title: 'Staff',
				auth: {},
				creationTime: Date.now(),
				isPrivate: 'hidden',
				modjoin: '%',
				autojoin: true,
			}];
		}

		this.chatRooms = [];

		this.autojoinList = [];
		this.modjoinedAutojoinList = [];
		for (const [i, settings] of this.settingsList.entries()) {
			if (!settings?.title) {
				Monitor.warn(`ERROR: Room number ${i} has no data and could not be loaded.`);
				continue;
			}
			if ((settings as any).staffAutojoin) {
				// convert old staffAutojoin format
				delete (settings as any).staffAutojoin;
				(settings as any).autojoin = true;
				if (!settings.modjoin) settings.modjoin = '%';
				if (settings.isPrivate === true) settings.isPrivate = 'hidden';
			}

			// We're okay with assinging type `ID` to `RoomID` here
			// because the hyphens in chatrooms don't have any special
			// meaning, unlike in helptickets, groupchats, battles etc
			// where they are used for shared modlogs and the like
			const id = toID(settings.title) as RoomID;
			Monitor.notice("RESTORE CHATROOM: " + id);
			const room = Rooms.createChatRoom(id, settings.title, settings);
			if (room.settings.aliases) {
				for (const alias of room.settings.aliases) {
					Rooms.aliases.set(alias, id);
				}
			}

			this.chatRooms.push(room);
			if (room.settings.autojoin) {
				if (room.settings.modjoin) {
					this.modjoinedAutojoinList.push(id);
				} else {
					this.autojoinList.push(id);
				}
			}
		}
		Rooms.lobby = Rooms.rooms.get('lobby') as ChatRoom;

		// init battle room logging
		if (Config.logladderip) {
			this.ladderIpLog = Monitor.logPath('ladderip/ladderip.txt').createAppendStream();
		} else {
			// Prevent there from being two possible hidden classes an instance
			// of GlobalRoom can have.
			this.ladderIpLog = new Streams.WriteStream({write() { return undefined; }});
		}

		this.reportUserStatsInterval = setInterval(
			() => this.reportUserStats(),
			REPORT_USER_STATS_INTERVAL
		);

		// init users
		this.maxUsers = 0;
		this.maxUsersDate = 0;
		this.lockdown = false;

		this.battleCount = 0;
		this.lastReportedCrash = 0;

		this.formatList = '';

		let lastBattle;
		try {
			lastBattle = Monitor.logPath('lastbattle.txt').readSync('utf8');
		} catch {}
		this.lastBattle = Number(lastBattle) || 0;
		this.lastWrittenBattle = this.lastBattle;
		void this.loadBattles();
	}

	async serializeBattleRoom(room: Room) {
		if (!room.battle || room.battle.ended) return null;
		room.battle.frozen = true;
		const log = await room.battle.getLog();
		const players = room.battle.players.map(p => p.id).filter(Boolean);
		if (!players.length || !log?.length) return null; // shouldn't happen???
		// players can be empty right after `/importinputlog`
		return {
			roomid: room.roomid,
			inputLog: log.join('\n'),
			players,
			title: room.title,
			rated: room.battle.rated,
			timer: {
				...room.battle.timer.settings,
				active: !!room.battle.timer.timer || false,
			},
		};
	}
	deserializeBattleRoom(battle: NonNullable<Awaited<ReturnType<GlobalRoomState['serializeBattleRoom']>>>) {
		const {inputLog, players, roomid, title, rated, timer} = battle;
		const [, formatid] = roomid.split('-');
		const room = Rooms.createBattle({
			format: formatid,
			inputLog,
			roomid,
			title,
			rated: Number(rated),
			players: [],
			delayedTimer: timer.active,
		});
		if (!room || !room.battle) return false; // shouldn't happen???
		if (timer) { // json blob of settings
			Object.assign(room.battle.timer.settings, timer);
		}
		for (const [i, playerid] of players.entries()) {
			room.auth.set(playerid, Users.PLAYER_SYMBOL);
			const player = room.battle.players[i];
			(player.id as string) = playerid;
			room.battle.playerTable[playerid] = player;
			player.hasTeam = true;
			const user = Users.getExact(playerid);
			player.name = user?.name || playerid; // in case user hasn't reconnected yet
			user?.joinRoom(room);
		}
		return true;
	}

	async saveBattles() {
		let count = 0;
		const out = Monitor.logPath('battles.jsonl.progress').createAppendStream();
		for (const room of Rooms.rooms.values()) {
			if (!room.battle || room.battle.ended) continue;
			room.battle.frozen = true;
			room.battle.timer.stop();
			const b = await this.serializeBattleRoom(room);
			if (!b) continue;
			await out.writeLine(JSON.stringify(b));
			count++;
		}
		await out.writeEnd();
		await Monitor.logPath('battles.jsonl.progress').rename(Monitor.logPath('battles.jsonl').path);
		return count;
	}

	battlesLoading = false;
	async loadBattles() {
		this.battlesLoading = true;
		for (const u of Users.users.values()) {
			u.send(
				`|pm|~|${u.getIdentity()}|/uhtml restartmsg,` +
				`<div class="broadcast-red"><b>Your battles are currently being restored.<br />Please be patient as they load.</div>`
			);
		}
		const startTime = Date.now();
		let count = 0;
		let input;
		try {
			const stream = Monitor.logPath('battles.jsonl').createReadStream();
			await stream.fd;
			input = stream.byLine();
		} catch (e) {
			return;
		}
		for await (const line of input) {
			if (!line) continue;
			if (this.deserializeBattleRoom(JSON.parse(line))) count++;
		}
		for (const u of Users.users.values()) {
			u.send(`|pm|~|${u.getIdentity()}|/uhtmlchange restartmsg,`);
		}
		await Monitor.logPath('battles.jsonl').unlinkIfExists();
		Monitor.notice(`Loaded ${count} battles in ${Date.now() - startTime}ms`);
		this.battlesLoading = false;
	}

	rejoinGames(user: User) {
		for (const room of Rooms.rooms.values()) {
			const player = room.game && !room.game.ended && room.game.playerTable[user.id];
			if (!player) continue;
			// prevents players from being re-added to games like Scavengers after they've finished
			if (player.completed) continue;
			user.games.add(room.roomid);
			player.name = user.name;
			user.joinRoom(room.roomid);
		}
	}

	modlog(entry: PartialModlogEntry, overrideID?: string) {
		void Rooms.Modlog.write('global', entry, overrideID);
	}

	writeChatRoomData() {
		FS('config/chatrooms.json').writeUpdate(() => (
			JSON.stringify(this.settingsList)
				.replace(/\{"title":/g, '\n{"title":')
				.replace(/\]$/, '\n]')
		), {throttle: 5000});
	}

	writeNumRooms() {
		if (this.lockdown) {
			if (this.lastBattle === this.lastWrittenBattle) return;
			this.lastWrittenBattle = this.lastBattle;
		} else {
			// batch writes so we don't have to write them every new battle
			// very probably premature optimization, considering by default we
			// write significantly larger log files every new battle
			if (this.lastBattle < this.lastWrittenBattle) return;
			this.lastWrittenBattle = this.lastBattle + LAST_BATTLE_WRITE_THROTTLE;
		}
		Monitor.logPath('lastbattle.txt').writeUpdate(
			() => `${this.lastWrittenBattle}`
		);
	}

	reportUserStats() {
		if (this.maxUsersDate) {
			void LoginServer.request('updateuserstats', {
				date: this.maxUsersDate,
				users: this.maxUsers,
			});
			this.maxUsersDate = 0;
		}
		void LoginServer.request('updateuserstats', {
			date: Date.now(),
			users: Users.onlineCount,
		});
	}

	get formatListText() {
		if (this.formatList) {
			return this.formatList;
		}
		this.formatList = '|formats' + (Ladders.formatsListPrefix || '');
		let section = '';
		let prevSection = '';
		let curColumn = 1;
		for (const format of Dex.formats.all()) {
			if (format.section) section = format.section;
			if (format.column) curColumn = format.column;
			if (!format.name) continue;
			if (!format.challengeShow && !format.searchShow && !format.tournamentShow) continue;

			if (section !== prevSection) {
				prevSection = section;
				this.formatList += '|,' + curColumn + '|' + section;
			}
			this.formatList += '|' + format.name;
			let displayCode = 0;
			if (format.team) displayCode |= 1;
			if (format.searchShow) displayCode |= 2;
			if (format.challengeShow) displayCode |= 4;
			if (format.tournamentShow) displayCode |= 8;
			const ruleTable = Dex.formats.getRuleTable(format);
			const level = ruleTable.adjustLevel || ruleTable.adjustLevelDown || ruleTable.maxLevel;
			if (level === 50) displayCode |= 16;
			 // 32 was previously used for Multi Battles
			if (format.bestOfDefault) displayCode |= 64;
			if (format.teraPreviewDefault) displayCode |= 128;
			this.formatList += ',' + displayCode.toString(16);
		}
		return this.formatList;
	}
	get configRankList() {
		if (Config.nocustomgrouplist) return '';

		// putting the resultant object in Config would enable this to be run again should config.js be reloaded.
		if (Config.rankList) {
			return Config.rankList;
		}
		const rankList = [];

		for (const rank in Config.groups) {
			if (!Config.groups[rank] || !rank) continue;

			const tarGroup = Config.groups[rank];
			const groupType = tarGroup.id === 'bot' || (!tarGroup.mute && !tarGroup.root) ?
				'normal' : (tarGroup.root || tarGroup.declare) ? 'leadership' : 'staff';

			rankList.push({
				symbol: rank,
				name: (Config.groups[rank].name || null),
				type: groupType}); // send the first character in the rank, incase they put a string several characters long
		}

		const typeOrder = ['punishment', 'normal', 'staff', 'leadership'];

		Utils.sortBy(rankList, rank => -typeOrder.indexOf(rank.type));

		// add the punishment types at the very end.
		for (const rank in Config.punishgroups) {
			rankList.push({symbol: Config.punishgroups[rank].symbol, name: Config.punishgroups[rank].name, type: 'punishment'});
		}

		Config.rankList = '|customgroups|' + JSON.stringify(rankList) + '\n';
		return Config.rankList;
	}

	/**
	 * @param filter formatfilter, elofilter, usernamefilter
	 */
	getBattles(filter: string) {
		const rooms: GameRoom[] = [];
		const [formatFilter, eloFilterString, usernameFilter] = filter.split(',');
		const eloFilter = +eloFilterString;
		for (const room of Rooms.rooms.values()) {
			if (!room?.active || room.settings.isPrivate) continue;
			if (room.type !== 'battle') continue;
			if (formatFilter && formatFilter !== room.format) continue;
			if (eloFilter && (!room.rated || room.rated < eloFilter)) continue;
			if (usernameFilter && room.battle) {
				const p1userid = room.battle.p1.id;
				const p2userid = room.battle.p2.id;
				if (!p1userid || !p2userid) continue;
				if (!p1userid.startsWith(usernameFilter) && !p2userid.startsWith(usernameFilter)) continue;
			}
			rooms.push(room);
		}

		const roomTable: {[roomid: string]: BattleRoomTable} = {};
		for (let i = rooms.length - 1; i >= rooms.length - 100 && i >= 0; i--) {
			const room = rooms[i];
			const roomData: BattleRoomTable = {};
			if (room.active && room.battle) {
				if (room.battle.p1) roomData.p1 = room.battle.p1.name;
				if (room.battle.p2) roomData.p2 = room.battle.p2.name;
				if (room.tour) roomData.minElo = 'tour';
				if (room.rated) roomData.minElo = Math.floor(room.rated);
			}
			if (!roomData.p1 || !roomData.p2) continue;
			roomTable[room.roomid] = roomData;
		}
		return roomTable;
	}
	getRooms(user: User) {
		const roomsData: {
			chat: ChatRoomTable[],
			sectionTitles: string[],
			userCount: number,
			battleCount: number,
		} = {
			chat: [],
			sectionTitles: Object.values(RoomSections.sectionNames),
			userCount: Users.onlineCount,
			battleCount: this.battleCount,
		};
		for (const room of this.chatRooms) {
			if (!room) continue;
			if (room.parent) continue;
			if (
				room.settings.modjoin ||
				(room.settings.isPrivate && !(['hidden', 'voice'] as any).includes(room.settings.isPrivate)) ||
				(room.settings.isPrivate === 'voice' && user.tempGroup === ' ')
			) continue;
			const roomData: ChatRoomTable = {
				title: room.title,
				desc: room.settings.desc || '',
				userCount: room.userCount,
				section: room.settings.section ?
					(RoomSections.sectionNames[room.settings.section] || room.settings.section) : undefined,
				privacy: !room.settings.isPrivate ? undefined : room.settings.isPrivate,
			};
			const subrooms = room.getSubRooms().map(r => r.title);
			if (subrooms.length) roomData.subRooms = subrooms;
			if (room.settings.spotlight) roomData.spotlight = room.settings.spotlight;

			roomsData.chat.push(roomData);
		}
		return roomsData;
	}
	sendAll(message: string) {
		Sockets.roomBroadcast('', message);
	}
	addChatRoom(title: string) {
		const id = toID(title) as RoomID;
		if (['battles', 'rooms', 'ladder', 'teambuilder', 'home', 'all', 'public'].includes(id)) {
			return false;
		}
		if (Rooms.rooms.has(id)) return false;

		const settings = {
			title,
			auth: {},
			creationTime: Date.now(),
		};
		const room = Rooms.createChatRoom(id, title, settings);
		if (id === 'lobby') Rooms.lobby = room;
		this.settingsList.push(settings);
		this.chatRooms.push(room);
		this.writeChatRoomData();
		return true;
	}

	prepBattleRoom(format: string) {
		// console.log('BATTLE START BETWEEN: ' + p1.id + ' ' + p2.id);
		const roomPrefix = `battle-${toID(Dex.formats.get(format).name)}-`;
		let battleNum = this.lastBattle;
		let roomid: RoomID;
		do {
			roomid = `${roomPrefix}${++battleNum}` as RoomID;
		} while (Rooms.rooms.has(roomid));

		this.lastBattle = battleNum;
		this.writeNumRooms();
		return roomid;
	}

	onCreateBattleRoom(players: User[], room: GameRoom, options: AnyObject) {
		for (const player of players) {
			if (player.statusType === 'idle') {
				player.setStatusType('online');
			}
		}
		if (Config.reportbattles) {
			if (typeof Config.reportbattles === 'string') {
				Config.reportbattles = [Config.reportbattles];
			} else if (Config.reportbattles === true) {
				Config.reportbattles = ['lobby'];
			}
			for (const roomid of Config.reportbattles) {
				const reportRoom = Rooms.get(roomid);
				if (reportRoom) {
					const reportPlayers = players.map(p => p.getIdentity()).join('|');
					reportRoom
						.add(`|b|${room.roomid}|${reportPlayers}`)
						.update();
				}
			}
		}
		if (Config.logladderip && options.rated) {
			const ladderIpLogString = players.map(p => `${p.id}: ${p.latestIp}\n`).join('');
			void this.ladderIpLog.write(ladderIpLogString);
		}
		for (const player of players) {
			Chat.runHandlers('onBattleStart', player, room);
		}
	}

	deregisterChatRoom(id: string) {
		id = toID(id);
		const room = Rooms.get(id);
		if (!room) return false; // room doesn't exist
		if (!room.persist) return false; // room isn't registered
		// deregister from global settings
		// looping from the end is a pretty trivial optimization, but the
		// assumption is that more recently added rooms are more likely to
		// be deleted
		for (let i = this.settingsList.length - 1; i >= 0; i--) {
			if (id === toID(this.settingsList[i].title)) {
				this.settingsList.splice(i, 1);
				this.writeChatRoomData();
				break;
			}
		}
		room.persist = false;
		return true;
	}
	delistChatRoom(id: RoomID) {
		id = toID(id) as RoomID;
		if (!Rooms.rooms.has(id)) return false; // room doesn't exist
		for (let i = this.chatRooms.length - 1; i >= 0; i--) {
			if (id === this.chatRooms[i].roomid) {
				this.chatRooms.splice(i, 1);
				break;
			}
		}
	}
	removeChatRoom(id: string) {
		id = toID(id);
		const room = Rooms.get(id);
		if (!room) return false; // room doesn't exist
		room.destroy();
		return true;
	}
	autojoinRooms(user: User, connection: Connection) {
		// we only autojoin regular rooms if the client requests it with /autojoin
		// note that this restriction doesn't apply to modjoined autojoin rooms
		let includesLobby = false;
		for (const roomName of this.autojoinList) {
			user.joinRoom(roomName, connection);
			if (roomName === 'lobby') includesLobby = true;
		}
		if (!includesLobby && Config.serverid !== 'showdown') user.send(`>lobby\n|deinit`);
	}
	checkAutojoin(user: User, connection?: Connection) {
		if (!user.named) return;
		for (let [i, roomid] of this.modjoinedAutojoinList.entries()) {
			const room = Rooms.get(roomid);
			if (!room) {
				this.modjoinedAutojoinList.splice(i, 1);
				i--;
				continue;
			}
			if (room.checkModjoin(user)) {
				user.joinRoom(room.roomid, connection);
			}
		}
		for (const conn of user.connections) {
			if (conn.autojoins) {
				const autojoins = conn.autojoins.split(',') as RoomID[];
				for (const roomName of autojoins) {
					void user.tryJoinRoom(roomName, conn);
				}
				conn.autojoins = '';
			}
		}
	}
	handleConnect(user: User, connection: Connection) {
		connection.send(user.getUpdateuserText() + '\n' + this.configRankList + this.formatListText);
		if (Users.users.size > this.maxUsers) {
			this.maxUsers = Users.users.size;
			this.maxUsersDate = Date.now();
		}
	}
	startLockdown(err: Error | null = null, slow = false) {
		if (this.lockdown && err) return;
		const devRoom = Rooms.get('development');
		// @ts-ignore
		const stack = (err ? Utils.escapeHTML(err.stack).split(`\n`).slice(0, 2).join(`<br />`) : ``);
		for (const [id, curRoom] of Rooms.rooms) {
			if (err) {
				if (id === 'staff' || id === 'development' || (!devRoom && id === 'lobby')) {
					curRoom.addRaw(`<div class="broadcast-red"><b>The server needs to restart because of a crash:</b> ${stack}<br />Please restart the server.</div>`);
					curRoom.addRaw(`<div class="broadcast-red">You will not be able to start new battles until the server restarts.</div>`);
					curRoom.update();
				} else {
					curRoom.addRaw(`<div class="broadcast-red"><b>The server needs to restart because of a crash.</b><br />No new battles can be started until the server is done restarting.</div>`).update();
				}
			} else {
				curRoom.addRaw(`<div class="broadcast-red"><b>The server is restarting soon.</b><br />Please finish your battles quickly. No new battles can be started until the server resets in a few minutes.</div>`).update();
			}
			const game = curRoom.game;
			// @ts-ignore TODO: revisit when game.timer is standardized
			if (!slow && game && game.timer && typeof game.timer.start === 'function' && !game.ended) {
				// @ts-ignore
				game.timer.start();
				if (curRoom.settings.modchat !== '+') {
					curRoom.settings.modchat = '+';
					curRoom.addRaw(`<div class="broadcast-red"><b>Moderated chat was set to +!</b><br />Only users of rank + and higher can talk.</div>`).update();
				}
			}
		}
		for (const user of Users.users.values()) {
			user.send(`|pm|~|${user.tempGroup}${user.name}|/raw <div class="broadcast-red"><b>The server is restarting soon.</b><br />Please finish your battles quickly. No new battles can be started until the server resets in a few minutes.</div>`);
		}

		this.lockdown = true;
		this.writeNumRooms();
		this.lastReportedCrash = Date.now();
	}
	automaticKillRequest() {
		const notifyPlaces: RoomID[] = ['development', 'staff', 'upperstaff'];
		if (Config.autolockdown === undefined) Config.autolockdown = true; // on by default

		if (Config.autolockdown && Rooms.global.lockdown === true && Rooms.global.battleCount === 0) {
			// The server is in lockdown, the final battle has finished, and the option is set
			// so we will now automatically kill the server here if it is not updating.
			if (Monitor.updateServerLock) {
				this.notifyRooms(
					notifyPlaces,
					`|html|<div class="broadcast-red"><b>Automatic server lockdown kill canceled.</b><br /><br />The server tried to automatically kill itself upon the final battle finishing, but the server was updating while trying to kill itself.</div>`
				);
				return;
			}

			// final warning
			this.notifyRooms(
				notifyPlaces,
				`|html|<div class="broadcast-red"><b>The server is about to automatically kill itself in 10 seconds.</b></div>`
			);

			// kill server in 10 seconds if it's still set to
			setTimeout(() => {
				if (Config.autolockdown && Rooms.global.lockdown === true) {
					// finally kill the server
					process.exit();
				} else {
					this.notifyRooms(
						notifyPlaces,
						`|html|<div class="broadcsat-red"><b>Automatic server lockdown kill canceled.</b><br /><br />In the last final seconds, the automatic lockdown was manually disabled.</div>`
					);
				}
			}, 10 * 1000);
		}
	}
	notifyRooms(rooms: RoomID[], message: string) {
		if (!rooms || !message) return;
		for (const roomid of rooms) {
			const curRoom = Rooms.get(roomid);
			if (curRoom) curRoom.add(message).update();
		}
	}
	reportCrash(err: Error | string, crasher = "The server") {
		const time = Date.now();
		if (time - this.lastReportedCrash < CRASH_REPORT_THROTTLE) {
			return;
		}
		this.lastReportedCrash = time;

		const stack = typeof err === 'string' ? err : err?.stack || err?.message || err?.name || '';
		const [stackFirst, stackRest] = Utils.splitFirst(Utils.escapeHTML(stack), `<br />`);
		let fullStack = `<b>${crasher} crashed:</b> ` + stackFirst;
		if (stackRest) fullStack = `<details class="readmore"><summary>${fullStack}</summary>${stackRest}</details>`;

		let crashMessage = `|html|<div class="broadcast-red">${fullStack}</div>`;
		let privateCrashMessage = null;

		const upperStaffRoom = Rooms.get('upperstaff');

		let hasPrivateTerm = stack.includes('private');
		for (const term of (Config.privatecrashterms || [])) {
			if (typeof term === 'string' ? stack.includes(term) : term.test(stack)) {
				hasPrivateTerm = true;
				break;
			}
		}

		if (hasPrivateTerm) {
			if (upperStaffRoom) {
				privateCrashMessage = crashMessage;
				crashMessage = `|html|<div class="broadcast-red"><b>${crasher} crashed in private code</b> <a href="/upperstaff">Read more</a></div>`;
			} else {
				crashMessage = `|html|<div class="broadcast-red"><b>${crasher} crashed in private code</b></div>`;
			}
		}
		const devRoom = Rooms.get('development');
		if (devRoom) {
			devRoom.add(crashMessage).update();
		} else {
			Rooms.lobby?.add(crashMessage).update();
			Rooms.get('staff')?.add(crashMessage).update();
		}
		if (privateCrashMessage) {
			upperStaffRoom!.add(privateCrashMessage).update();
		}
	}
	/**
	 * Destroys personal rooms of a (punished) user
	 * Returns a list of the user's remaining public auth
	 */
	destroyPersonalRooms(userid: ID) {
		const roomauth = [];
		for (const [id, curRoom] of Rooms.rooms) {
			if (curRoom.settings.isPersonal && curRoom.auth.get(userid) === Users.HOST_SYMBOL) {
				curRoom.destroy();
			} else {
				if (curRoom.settings.isPrivate || curRoom.battle || !curRoom.persist) {
					continue;
				}

				if (curRoom.auth.has(userid)) {
					let oldGroup = curRoom.auth.get(userid) as string;
					if (oldGroup === ' ') oldGroup = 'whitelist in ';
					roomauth.push(`${oldGroup}${id}`);
				}
			}
		}
		return roomauth;
	}
}

export class ChatRoom extends BasicRoom {
	// This is not actually used, this is just a fake class to keep
	// TypeScript happy
	override battle = null;
	override active = false as const;
	override type = 'chat' as const;
}

export class GameRoom extends BasicRoom {
	declare readonly type: 'battle';
	readonly format: string;
	p1: User | null;
	p2: User | null;
	p3: User | null;
	p4: User | null;
	/**
	 * The lower player's rating, for searching purposes.
	 * 0 for unrated battles. 1 for unknown ratings.
	 */
	rated: number;
	declare battle: RoomBattle | null;
	declare bestOf: BestOfGame | null;
	declare game: RoomGame;
	modchatUser: string;
	constructor(roomid: RoomID, title: string, options: Partial<RoomSettings & RoomBattleOptions>) {
		options.noLogTimes = true;
		options.noAutoTruncate = true;
		options.isMultichannel = true;
		super(roomid, title, options);
		this.reportJoins = !!Config.reportbattlejoins;
		this.settings.modchat = (Config.battlemodchat || null);

		this.type = 'battle';

		this.format = options.format || '';
		// console.log("NEW BATTLE");

		this.tour = options.tour || null;
		this.setParent((options as any).parent || (this.tour && this.tour.room) || null);

		this.p1 = options.players?.[0]?.user || null;
		this.p2 = options.players?.[1]?.user || null;
		this.p3 = options.players?.[2]?.user || null;
		this.p4 = options.players?.[3]?.user || null;

		this.rated = options.rated === true ? 1 : options.rated || 0;

		this.battle = null;
		this.bestOf = null;
		this.game = null!;

		this.modchatUser = '';

		this.active = false;
	}
	/**
	 * - logNum = 0          : spectator log (no exact HP)
	 * - logNum = 1, 2, 3, 4 : player log (exact HP for that player)
	 * - logNum = -1         : debug log (exact HP for all players)
	 */
	getLog(channel: -1 | 0 | 1 | 2 | 3 | 4 = 0) {
		return this.log.getScrollback(channel);
	}
	getLogForUser(user: User) {
		if (!(user.id in this.game.playerTable)) return this.getLog();
		return this.getLog(this.game.playerTable[user.id].num as 0);
	}
	update(excludeUser: User | null = null) {
		if (!this.log.broadcastBuffer.length) return;

		if (this.userCount) {
			Sockets.channelBroadcast(this.roomid, `>${this.roomid}\n${this.log.broadcastBuffer.join('\n')}`);
		}
		this.log.broadcastBuffer = [];

		this.pokeExpireTimer();
	}
	pokeExpireTimer() {
		// empty rooms time out after ten minutes
		if (!this.userCount) {
			if (this.expireTimer) clearTimeout(this.expireTimer);
			this.expireTimer = setTimeout(() => this.expire(), TIMEOUT_EMPTY_DEALLOCATE);
		} else {
			if (this.expireTimer) clearTimeout(this.expireTimer);
			this.expireTimer = setTimeout(() => this.expire(), TIMEOUT_INACTIVE_DEALLOCATE);
		}
	}
	requestModchat(user: User | null) {
		if (!user) {
			this.modchatUser = '';
			return;
		} else if (!this.modchatUser || this.modchatUser === user.id || this.auth.get(user.id) !== Users.PLAYER_SYMBOL) {
			this.modchatUser = user.id;
			return;
		} else {
			return "Modchat can only be changed by the user who turned it on, or by staff";
		}
	}
	onConnect(user: User, connection: Connection) {
		this.sendUser(connection, '|init|battle\n|title|' + this.title + '\n' + this.getLogForUser(user));
		this.game?.onConnect?.(user, connection);
	}
	onJoin(user: User, connection: Connection) {
		if (!user) return false; // ???
		if (this.users[user.id]) return false;

		Chat.runHandlers('onBeforeRoomJoin', this, user, connection);
		if (user.named) {
			this.reportJoin('j', user.getIdentityWithStatus(this), user);
		}

		this.users[user.id] = user;
		this.userCount++;
		this.checkAutoModchat(user);

		this.minorActivity?.onConnect?.(user, connection);
		this.game?.onJoin?.(user, connection);
		Chat.runHandlers('onRoomJoin', this, user, connection);
		return true;
	}
	/**
	 * Sends this room's replay to the connection to be uploaded to the replay
	 * server. To be clear, the replay goes:
	 *
	 * PS server -> user -> loginserver
	 *
	 * NOT: PS server -> loginserver
	 *
	 * That's why this function requires a connection. For details, see the top
	 * comment inside this function.
	 */
	async uploadReplay(user?: User, connection?: Connection, options?: 'forpunishment' | 'silent' | 'auto') {
		// The reason we don't upload directly to the loginserver, unlike every
		// other interaction with the loginserver, is because it takes so much
		// bandwidth that it can get identified as a DoS attack by PHP, Apache, or
		// Cloudflare, and blocked.

		// While I'm sure this is configurable, it's a huge pain, and getting it
		// wrong, especially while migrating infrastructure, leads to everything
		// being unusable and panic while we figure out how to unblock our servers
		// from each other. It's just easier to "spread out" the bandwidth.

		// TODO: My ideal long-term fix would be to just have a database (probably
		// Postgres) shared between client and server, acting as both the server's
		// battle logs as well as the client's replay database, which both client
		// and server have write access to.

		const battle = this.battle;
		if (!battle) return;

		// retrieve spectator log (0) if there are privacy concerns
		const format = Dex.formats.get(this.format, true);

		// custom games always show full details
		// random-team battles show full details if the battle is ended
		// otherwise, don't show full details
		let hideDetails = !format.id.includes('customgame');
		if (format.team && battle.ended) hideDetails = false;

		const log = this.getLog(hideDetails ? 0 : -1);
		let rating: number | undefined;
		if (battle.ended && this.rated) rating = this.rated;
		let {id, password} = this.getReplayData();
		const silent = options === 'forpunishment' || options === 'silent' || options === 'auto';
		if (silent) connection = undefined;
		const isPrivate = this.settings.isPrivate || this.hideReplay;
		const hidden = options === 'auto' ? 10 :
			options === 'forpunishment' || (this as any).unlistReplay ? 2 :
			isPrivate ? 1 :
			0;
		if (isPrivate && hidden === 10) {
			password = Replays.generatePassword();
		}
		if (battle.replaySaved !== true && hidden === 10) {
			battle.replaySaved = 'auto';
		} else {
			battle.replaySaved = true;
		}

		// If we have a direct connetion to a Replays database, just upload the replay
		// directly.

		if (Replays.db) {
			const idWithServer = Config.serverid === 'showdown' ? id : `${Config.serverid}-${id}`;
			try {
				const fullid = await Replays.add({
					id: idWithServer,
					log,
					players: battle.players.map(p => p.name),
					format: format.name,
					rating: rating || null,
					private: hidden,
					password,
					inputlog: battle.inputLog?.join('\n') || null,
					uploadtime: Math.trunc(Date.now() / 1000),
				});
				const url = `https://${Config.routes.replays}/${fullid}`;
				connection?.popup(
					`|html|<p>Your replay has been uploaded! It's available at:</p><p> ` +
					`<a class="no-panel-intercept" href="${url}" target="_blank">${url}</a> ` +
					`<copytext value="${url}">Copy</copytext>`
				);
			} catch (e) {
				connection?.popup(`Your replay could not be saved: ${e}`);
				throw e;
			}
			return;
		}

		// Otherwise, (we're probably a side server), upload the replay through LoginServer

		const [result] = await LoginServer.request('addreplay', {
			id,
			log,
			players: battle.players.map(p => p.name).join(','),
			format: format.name,
			rating, // will probably do nothing
			hidden: hidden === 0 ? '' : hidden,
			inputlog: battle.inputLog?.join('\n') || undefined,
			password,
		});
		if (result?.errorip) {
			connection?.popup(`This server's request IP ${result.errorip} is not a registered server.`);
			return;
		}

		const fullid = result?.replayid;
		const url = `https://${Config.routes.replays}/${fullid}`;
		connection?.popup(
			`|html|<p>Your replay has been uploaded! It's available at:</p><p> ` +
			`<a class="no-panel-intercept" href="${url}" target="_blank">${url}</a> ` +
			`<copytext value="${url}">Copy</copytext>`
		);
	}

	getReplayData() {
		if (!this.roomid.endsWith('pw')) return {id: this.roomid.slice(7), password: null};
		const end = this.roomid.length - 2;
		const lastHyphen = this.roomid.lastIndexOf('-', end);
		return {id: this.roomid.slice(7, lastHyphen), password: this.roomid.slice(lastHyphen + 1, end)};
	}
}

function getRoom(roomid?: string | BasicRoom) {
	if (typeof roomid === 'string') {
		// Accounts for private battles that were made public
		if ((roomid.startsWith('battle-') || roomid.startsWith('game-bestof')) && roomid.endsWith('pw')) {
			const room = Rooms.rooms.get(roomid.slice(0, roomid.lastIndexOf('-')) as RoomID);
			if (room) return room;
		}
		return Rooms.rooms.get(roomid as RoomID);
	}
	return roomid as Room;
}

export const Rooms = {
	Modlog: mainModlog,
	/**
	 * The main roomid:Room table. Please do not hold a reference to a
	 * room long-term; just store the roomid and grab it from here (with
	 * the Rooms.get(roomid) accessor) when necessary.
	 */
	rooms: new Map<RoomID, Room>(),
	aliases: new Map<string, RoomID>(),

	get: getRoom,
	search(name: string): Room | undefined {
		return getRoom(name) || getRoom(toID(name)) || getRoom(Rooms.aliases.get(toID(name)));
	},

	createGameRoom(roomid: RoomID, title: string, options: Partial<RoomSettings & RoomBattleOptions>) {
		if (Rooms.rooms.has(roomid)) throw new Error(`Room ${roomid} already exists`);
		Monitor.debug("NEW BATTLE ROOM: " + roomid);
		const room = new GameRoom(roomid, title, options);
		Rooms.rooms.set(roomid, room);
		return room;
	},
	createChatRoom(roomid: RoomID, title: string, options: Partial<RoomSettings>) {
		if (Rooms.rooms.has(roomid)) throw new Error(`Room ${roomid} already exists`);
		const room: ChatRoom = new (BasicRoom as any)(roomid, title, options);
		Rooms.rooms.set(roomid, room);
		return room;
	},
	/**
	 * Can return null during lockdown, so make sure to handle that case.
	 * No need for UI; this function sends popups to users.
	 */
	createBattle(options: RoomBattleOptions & Partial<RoomSettings>) {
		const players = options.players.map(player => player.user);
		const format = Dex.formats.get(options.format);
		if (players.length > format.playerCount) {
			throw new Error(`${players.length} players were provided, but the format is a ${format.playerCount}-player format.`);
		}
		if (new Set(players).size < players.length) {
			throw new Error(`Players can't battle themselves`);
		}

		for (const user of players) {
			Ladders.cancelSearches(user);
		}

		const isBestOf = Dex.formats.getRuleTable(format).valueRules.get('bestof');

		if (Rooms.global.lockdown === 'pre' && isBestOf && !options.isBestOfSubBattle) {
			for (const user of players) {
				user.popup(`The server will be restarting soon. Best-of-${isBestOf} battles cannot be started at this time.`);
			}
			return null;
		}

		// gotta allow new bo3 child battles to start
		if (Rooms.global.lockdown === true && !options.isBestOfSubBattle) {
			for (const user of players) {
				user.popup("The server is restarting. Battles will be available again in a few minutes.");
			}
			return null;
		}

		const p1Special = players.length ? players[0].battleSettings.special : undefined;
		let mismatch = `"${p1Special}"`;
		for (const user of players) {
			if (user.battleSettings.special !== p1Special) {
				mismatch += ` vs. "${user.battleSettings.special}"`;
			}
			user.battleSettings.special = undefined;
		}

		if (mismatch !== `"${p1Special}"`) {
			for (const user of players) {
				user.popup(`Your special battle settings don't match: ${mismatch}`);
			}
			return null;
		} else if (p1Special) {
			options.ratedMessage = p1Special;
		}

		// options.rated is a number representing the lowest player rating, for searching purposes
		// options.rated < 0 or falsy means "unrated", and will be converted to 0 here
		// options.rated === true is converted to 1 (used in tests sometimes)
		options.rated = Math.max(+options.rated! || 0, 0);
		const p1 = players[0];
		const p2 = players[1];
		const p1name = p1 ? p1.name : "Player 1";
		const p2name = p2 ? p2.name : "Player 2";
		let roomTitle;
		let roomid = options.roomid;
		if (format.gameType === 'multi') {
			roomTitle = `Team ${p1name} vs. Team ${p2name}`;
		} else if (format.gameType === 'freeforall') {
			// p1 vs. p2 vs. p3 vs. p4 is too long of a title
			roomTitle = `${p1name} and friends`;
		} else if (isBestOf && !options.isBestOfSubBattle) {
			roomTitle = `${p1name} vs. ${p2name}`;
			roomid ||= `game-bestof${isBestOf}-${format.id}-${++Rooms.global.lastBattle}` as RoomID;
		} else if (options.title) {
			roomTitle = options.title;
		} else {
			roomTitle = `${p1name} vs. ${p2name}`;
		}
		roomid ||= Rooms.global.prepBattleRoom(options.format);
		options.isPersonal = true;
		const room = Rooms.createGameRoom(roomid, roomTitle, options);
		let game: RoomBattle | BestOfGame;
		if (options.isBestOfSubBattle || !isBestOf) {
			game = new RoomBattle(room, options);
		} else {
			game = new BestOfGame(room, options);
		}
		room.game = game;
		if (options.isBestOfSubBattle && room.parent) {
			room.setPrivate(room.parent.settings.isPrivate || false);
		} else {
			game.checkPrivacySettings(options);
		}

		for (const p of players) {
			if (p) {
				p.joinRoom(room);
				Monitor.countBattle(p.latestIp, p.name);
			}
		}

		return room;
	},

	global: null! as GlobalRoomState,
	lobby: null as ChatRoom | null,

	BasicRoom,
	GlobalRoomState,
	GameRoom,
	ChatRoom: BasicRoom as typeof ChatRoom,

	RoomGame,
	SimpleRoomGame,
	RoomGamePlayer,

	MinorActivity,

	RETRY_AFTER_LOGIN,

	Roomlogs,

	RoomBattle,
	BestOfGame,
	RoomBattlePlayer,
	RoomBattleTimer,
	PM: RoomBattlePM,

	Replays,
};
