import {FS} from '../lib/fs';
import type {RoomSection} from './chat-commands/room-settings';
import {toID} from '../sim/dex-data';

export type GroupSymbol = '~' | '#' | '★' | '*' | '@' | '%' | '☆' | '§' | '+' | '^' | ' ' | '‽' | '!';
export type EffectiveGroupSymbol = GroupSymbol | 'whitelist';
export type AuthLevel = EffectiveGroupSymbol | 'unlocked' | 'trusted' | 'autoconfirmed';

export const PLAYER_SYMBOL: GroupSymbol = '\u2606';
export const HOST_SYMBOL: GroupSymbol = '\u2605';

export const ROOM_PERMISSIONS = [
	'addhtml', 'announce', 'ban', 'bypassafktimer', 'declare', 'editprivacy', 'editroom', 'exportinputlog', 'game', 'gamemanagement', 'gamemoderation', 'joinbattle', 'kick', 'minigame', 'modchat', 'modlog', 'mute', 'nooverride', 'receiveauthmessages', 'roombot', 'roomdriver', 'roommod', 'roomowner', 'roomvoice', 'roomprizewinner', 'show', 'showmedia', 'timer', 'tournaments', 'warn',
] as const;

export const GLOBAL_PERMISSIONS = [
	// administrative
	'bypassall', 'console', 'disableladder', 'lockdown', 'potd',
	// other
	'addhtml', 'alts', 'altsself', 'autotimer', 'globalban', 'bypassblocks', 'bypassafktimer', 'forcepromote', 'forcerename', 'forcewin', 'gdeclare', 'hiderank', 'ignorelimits', 'importinputlog', 'ip', 'ipself', 'lock', 'makeroom', 'modlog', 'rangeban', 'promote',
] as const;

export type RoomPermission = typeof ROOM_PERMISSIONS[number];
export type GlobalPermission = typeof GLOBAL_PERMISSIONS[number];

export type GroupInfo = {
	symbol: GroupSymbol,
	id: ID,
	name: string,
	rank: number,
	inherit?: GroupSymbol,
	jurisdiction?: string,

	globalonly?: boolean,
	roomonly?: boolean,
	battleonly?: boolean,
	root?: boolean,
	globalGroupInPersonalRoom?: GroupSymbol,
} & {
	[P in RoomPermission | GlobalPermission]?: string | boolean;
};

/**
 * Auth table - a Map for which users are in which groups.
 *
 * Notice that auth.get will return the default group symbol if the
 * user isn't in a group.
 */
export abstract class Auth extends Map<ID, GroupSymbol | ''> {
	/**
	 * Will return the default group symbol if the user isn't in a group.
	 *
	 * Passing a User will read `user.group`, which is relevant for unregistered
	 * users with temporary global auth.
	 */
	get(user: ID | User) {
		if (typeof user !== 'string') return user.tempGroup;
		return super.get(user) || Auth.defaultSymbol();
	}
	isStaff(userid: ID) {
		if (this.has(userid)) {
			const rank = this.get(userid);
			// At one point bots used to be ranked above drivers, so this checks
			// driver rank to make sure this function works on servers that
			// did not reorder the ranks.
			return Auth.atLeast(rank, '*') || Auth.atLeast(rank, '%');
		} else {
			return false;
		}
	}
	atLeast(user: User, group: AuthLevel) {
		if (user.hasSysopAccess()) return true;
		if (group === 'trusted' || group === 'autoconfirmed') {
			if (user.trusted && group === 'trusted') return true;
			if (user.autoconfirmed && !user.locked && group === 'autoconfirmed') return true;
			group = Config.groupsranking[1];
		}
		if (user.locked || user.semilocked) return false;
		if (group === 'unlocked') return true;
		if (group === 'whitelist' && this.has(user.id)) {
			return true;
		}
		if (!Config.groups[group]) return false;
		if (this.get(user.id) === ' ' && group !== ' ') return false;
		return Auth.atLeast(this.get(user.id), group);
	}

	static defaultSymbol() {
		return Config.groupsranking[0] as GroupSymbol;
	}
	static getGroup(symbol: EffectiveGroupSymbol): GroupInfo;
	static getGroup<T>(symbol: EffectiveGroupSymbol, fallback: T): GroupInfo | T;
	static getGroup(symbol: EffectiveGroupSymbol, fallback?: AnyObject) {
		if (Config.groups[symbol]) return Config.groups[symbol];

		if (fallback !== undefined) return fallback;

		// unidentified groups are treated as voice
		return {
			...(Config.groups['+'] || {}),
			symbol,
			id: 'voice',
			name: symbol,
		};
	}
	getEffectiveSymbol(user: User): EffectiveGroupSymbol {
		const group = this.get(user);
		if (this.has(user.id) && group === Auth.defaultSymbol()) {
			return 'whitelist';
		}
		return group;
	}
	static hasPermission(
		user: User,
		permission: string,
		target: User | EffectiveGroupSymbol | ID | null,
		room?: BasicRoom | null,
		cmd?: string,
		cmdToken?: string,
	): boolean {
		if (user.hasSysopAccess()) return true;

		const auth: Auth = room ? room.auth : Users.globalAuth;

		const symbol = auth.getEffectiveSymbol(user);

		let targetSymbol: EffectiveGroupSymbol | null;
		if (!target) {
			targetSymbol = null;
		} else if (typeof target === 'string' && !toID(target)) { // empty ID -> target is a group symbol
			targetSymbol = target as EffectiveGroupSymbol;
		} else {
			targetSymbol = auth.get(target as User | ID);
		}
		if (!targetSymbol || ['whitelist', 'trusted', 'autoconfirmed'].includes(targetSymbol)) {
			targetSymbol = Auth.defaultSymbol();
		}

		let group = Auth.getGroup(symbol);
		if (group['root']) return true;
		// Global drivers who are SLs should get room mod powers too
		if (
			room?.settings.section &&
			room.settings.section === Users.globalAuth.sectionLeaders.get(user.id) &&
			// But dont override ranks above moderator such as room owner
			(Auth.getGroup('@').rank > group.rank)
		) {
			group = Auth.getGroup('@');
		}

		let jurisdiction = group[permission as GlobalPermission | RoomPermission];
		if (jurisdiction === true && permission !== 'jurisdiction') {
			jurisdiction = group['jurisdiction'] || true;
		}
		const roomPermissions = room ? room.settings.permissions : null;
		if (roomPermissions) {
			let foundSpecificPermission = false;
			if (cmd) {
				if (!cmdToken) cmdToken = `/`;
				const namespace = cmd.slice(0, cmd.indexOf(' '));
				if (roomPermissions[`${cmdToken}${cmd}`]) {
					// this checks sub commands and command objects, but it checks to see if a sub-command
					// overrides (should a perm for the command object exist) first
					if (!auth.atLeast(user, roomPermissions[`${cmdToken}${cmd}`])) return false;
					jurisdiction = 'u';
					foundSpecificPermission = true;
				} else if (roomPermissions[`${cmdToken}${namespace}`]) {
					// if it's for one command object
					if (!auth.atLeast(user, roomPermissions[`${cmdToken}${namespace}`])) return false;
					jurisdiction = 'u';
					foundSpecificPermission = true;
				}
				if (foundSpecificPermission && targetSymbol === Users.Auth.defaultSymbol()) {
					// if /permissions has granted unranked users permission to use the command,
					// grant jurisdiction over unranked (since unranked users don't have jurisdiction over unranked)
					// see https://github.com/smogon/pokemon-showdown/pull/9534#issuecomment-1565719315
					jurisdiction += Users.Auth.defaultSymbol();
				}
			}
			if (!foundSpecificPermission && roomPermissions[permission]) {
				if (!auth.atLeast(user, roomPermissions[permission])) return false;
				jurisdiction = 'u';
			}
		}
		return Auth.hasJurisdiction(symbol, jurisdiction, targetSymbol as GroupSymbol);
	}
	static atLeast(symbol: EffectiveGroupSymbol, symbol2: EffectiveGroupSymbol) {
		return Auth.getGroup(symbol).rank >= Auth.getGroup(symbol2).rank;
	}
	static supportedRoomPermissions(room: Room | null = null) {
		const commands = [];
		for (const handler of Chat.allCommands()) {
			if (!handler.hasRoomPermissions && !handler.broadcastable) continue;

			// if it's only broadcast permissions, not use permissions, use the broadcast symbol
			const cmdPrefix = handler.hasRoomPermissions ? "/" : "!";
			commands.push(`${cmdPrefix}${handler.fullCmd}`);
			if (handler.aliases.length) {
				for (const alias of handler.aliases) {
					// kind of a hack but this is the only good way i could think of to
					// overwrite the alias without making assumptions about the string
					commands.push(`${cmdPrefix}${handler.fullCmd.replace(handler.cmd, alias)}`);
				}
			}
		}
		return [
			...ROOM_PERMISSIONS,
			...commands,
		];
	}
	static hasJurisdiction(
		symbol: EffectiveGroupSymbol,
		jurisdiction?: string | boolean,
		targetSymbol?: GroupSymbol | null
	) {
		if (!targetSymbol) {
			return !!jurisdiction;
		}
		if (typeof jurisdiction !== 'string') {
			return !!jurisdiction;
		}
		if (jurisdiction.includes(targetSymbol)) {
			return true;
		}
		if (jurisdiction.includes('a')) {
			return true;
		}
		if (jurisdiction.includes('u') && Auth.getGroup(symbol).rank > Auth.getGroup(targetSymbol).rank) {
			return true;
		}
		return false;
	}
	static listJurisdiction(user: User, permission: GlobalPermission | RoomPermission) {
		const symbols = Object.keys(Config.groups) as GroupSymbol[];
		return symbols.filter(targetSymbol => Auth.hasPermission(user, permission, targetSymbol));
	}
	static isValidSymbol(symbol: string): symbol is GroupSymbol {
		if (symbol.length !== 1) return false;
		return !/[A-Za-z0-9|,]/.test(symbol);
	}
	static isAuthLevel(level: string): level is AuthLevel {
		if (Config.groupsranking.includes(level as EffectiveGroupSymbol)) return true;
		return ['‽', '!', 'unlocked', 'trusted', 'autoconfirmed', 'whitelist'].includes(level);
	}
	static ROOM_PERMISSIONS = ROOM_PERMISSIONS;
	static GLOBAL_PERMISSIONS = GLOBAL_PERMISSIONS;
}

export class RoomAuth extends Auth {
	room: BasicRoom;
	constructor(room: BasicRoom) {
		super();
		this.room = room;
	}
	get(userOrID: ID | User): GroupSymbol {
		const id = typeof userOrID === 'string' ? userOrID : userOrID.id;

		const parentAuth: Auth | null = this.room.parent ? this.room.parent.auth :
			this.room.settings.isPrivate !== true ? Users.globalAuth : null;
		const parentGroup = parentAuth ? parentAuth.get(userOrID) : Auth.defaultSymbol();

		if (this.has(id)) {
			// authority is whichever is higher between roomauth and global auth
			const roomGroup = this.getDirect(id);
			let group = Config.greatergroupscache[`${roomGroup}${parentGroup}`];
			if (!group) {
				// unrecognized groups always trump higher global rank
				const roomRank = Auth.getGroup(roomGroup, {rank: Infinity}).rank;
				const globalRank = Auth.getGroup(parentGroup).rank;
				if (roomGroup === Users.PLAYER_SYMBOL || roomGroup === Users.HOST_SYMBOL || roomGroup === '#') {
					// Player, Host, and Room Owner always trump higher global rank
					group = roomGroup;
				} else {
					group = (roomRank > globalRank ? roomGroup : parentGroup);
				}
				Config.greatergroupscache[`${roomGroup}${parentGroup}`] = group;
			}
			return group;
		}

		return parentGroup;
	}
	getEffectiveSymbol(user: User) {
		const symbol = super.getEffectiveSymbol(user);
		if (!this.room.persist && symbol === user.tempGroup) {
			const replaceGroup = Auth.getGroup(symbol).globalGroupInPersonalRoom;
			if (replaceGroup) return replaceGroup;
		}
		// this is a bit of a hardcode, yeah, but admins need to have admin commands in prooms w/o the symbol
		// and we want that to include sysops.
		// Plus, using user.can is cleaner than Users.globalAuth.get(user) === admin and it accounts for more things.
		// (and no this won't recurse or anything since user.can() with no room doesn't call this)
		if (this.room.settings.isPrivate === true && user.can('makeroom')) {
			// not hardcoding ~ here since globalAuth.get should return ~ in basically all cases
			// except sysops, and there's an override for them anyways so it doesn't matter
			return Users.globalAuth.get(user);
		}
		return symbol;
	}
	/** gets the room group without inheriting */
	getDirect(id: ID): GroupSymbol {
		return super.get(id);
	}
	save() {
		// construct auth object
		const auth = Object.create(null);
		for (const [userid, groupSymbol] of this) {
			auth[userid] = groupSymbol;
		}
		(this.room.settings as any).auth = auth;
		this.room.saveSettings();
	}
	load() {
		for (const userid in this.room.settings.auth) {
			super.set(userid as ID, this.room.settings.auth[userid]);
		}
	}
	set(id: ID, symbol: GroupSymbol) {
		if (symbol === 'whitelist' as GroupSymbol) {
			symbol = Auth.defaultSymbol();
		}
		super.set(id, symbol);
		this.room.settings.auth[id] = symbol;
		this.room.saveSettings();

		const user = Users.get(id);
		if (user) this.room.onUpdateIdentity(user);
		return this;
	}
	delete(id: ID) {
		if (!this.has(id)) return false;
		super.delete(id);
		delete this.room.settings.auth[id];
		this.room.saveSettings();
		return true;
	}
}

export class GlobalAuth extends Auth {
	usernames = new Map<ID, string>();
	sectionLeaders = new Map<ID, RoomSection>();
	constructor() {
		super();
		this.load();
	}
	save() {
		FS('config/usergroups.csv').writeUpdate(() => {
			let buffer = '';
			for (const [userid, groupSymbol] of this) {
				buffer += `${this.usernames.get(userid) || userid},${groupSymbol},${this.sectionLeaders.get(userid) || ''}\n`;
			}
			return buffer;
		});
	}
	load() {
		const data = FS('config/usergroups.csv').readIfExistsSync();
		for (const row of data.split("\n")) {
			if (!row) continue;
			const [name, symbol, sectionid] = row.split(",");
			const id = toID(name);
			if (!id) {
				Monitor.warn('Dropping malformed usergroups line (missing ID):');
				Monitor.warn(row);
				continue;
			}
			this.usernames.set(id, name);
			if (sectionid) this.sectionLeaders.set(id, sectionid as RoomSection);

			// handle glitched entries where a user has two entries in usergroups.csv due to bugs
			const newSymbol = symbol.charAt(0) as GroupSymbol;
			// Yes, we HAVE to ensure that it exists in the super. super.get here returns either the group symbol,
			// or the default symbol if it cannot find a symbol in the map.
			// the default symbol is truthy, and the symbol for trusted user is ` `
			// meaning that the preexisting && atLeast would return true, which would skip the row and nuke all trusted users
			// on a fresh load (aka, a restart).
			const preexistingSymbol = super.has(id) ? super.get(id) : null;
			// take a user's highest rank in usergroups.csv
			if (preexistingSymbol && Auth.atLeast(preexistingSymbol, newSymbol)) continue;
			super.set(id, newSymbol);
		}
	}
	set(id: ID, group: GroupSymbol, username?: string) {
		if (!username) username = id;
		const user = Users.get(id, true);
		if (user) {
			user.tempGroup = group;
			user.updateIdentity();
			username = user.name;
			Rooms.global.checkAutojoin(user);
		}
		this.usernames.set(id, username);
		super.set(id, group);
		void this.save();
		return this;
	}
	delete(id: ID) {
		if (!super.has(id)) return false;
		super.delete(id);
		const user = Users.get(id);
		if (user) {
			user.tempGroup = ' ';
		}
		this.usernames.delete(id);
		this.save();
		return true;
	}
	setSection(id: ID, sectionid: RoomSection, username?: string) {
		if (!username) username = id;
		const user = Users.get(id);
		if (user) {
			user.updateIdentity();
			username = user.name;
			Rooms.global.checkAutojoin(user);
		}
		if (!super.has(id)) this.set(id, ' ', username);
		this.sectionLeaders.set(id, sectionid);
		void this.save();
		return this;
	}
	deleteSection(id: ID) {
		if (!this.sectionLeaders.has(id)) return false;
		this.sectionLeaders.delete(id);
		if (super.get(id) === ' ') {
			return this.delete(id);
		}
		const user = Users.get(id);
		if (user) {
			user.updateIdentity();
			Rooms.global.checkAutojoin(user);
		}
		this.save();
		return true;
	}
}
