// SPDX-License-Identifier: AGPL-3.0-or-later

import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {ALL_PERMISSIONS, ChannelTypes, DEFAULT_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {DiscoveryApplicationStatus} from '@fluxer/constants/src/DiscoveryConstants';
import {
	ContentWarningLevel,
	GuildFeatures,
	GuildNSFWLevel,
	GuildSplashCardAlignment,
	GuildVerificationLevel,
	JoinSourceTypes,
	SystemChannelFlags,
} from '@fluxer/constants/src/GuildConstants';
import {
	MAX_GUILD_CHANNELS,
	MAX_GUILD_ROLES,
	VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
} from '@fluxer/constants/src/LimitConstants';
import {DEFAULT_GUILD_FOLDER_ICON} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {BotsCannotCreateGuildsError} from '@fluxer/errors/src/domains/guild/BotsCannotCreateGuildsError';
import {GuildTemplateInvalidError} from '@fluxer/errors/src/domains/guild/GuildTemplateInvalidError';
import {MaxGuildsError} from '@fluxer/errors/src/domains/guild/MaxGuildsError';
import {UnclaimedAccountCannotCreateGuildsError} from '@fluxer/errors/src/domains/guild/UnclaimedAccountCannotCreateGuildsError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults';
import type {GuildCreateRequest, GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import type {GuildPartialResponse, GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {
	TemplateChannel,
	TemplateRole,
	TemplateSerializedGuild,
} from '@fluxer/schema/src/domains/guild/GuildTemplateSchemas';
import {extractTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils';
import type {ChannelID, GuildID, RoleID, UserID} from '../../../BrandedTypes';
import {createChannelID, createGuildID, createRoleID, guildIdToRoleId} from '../../../BrandedTypes';
import type {IChannelRepository} from '../../../channel/IChannelRepository';
import type {ChannelService} from '../../../channel/services/ChannelService';
import {BatchBuilder} from '../../../database/CassandraQueryExecution';
import type {PermissionOverwrite} from '../../../database/types/ChannelTypes';
import type {GuildRow} from '../../../database/types/GuildTypes';
import {contentModerationService} from '../../../infrastructure/ContentModerationService';
import type {EntityAssetService, PreparedAssetUpload} from '../../../infrastructure/EntityAssetService';
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import type {ISnowflakeService} from '../../../infrastructure/ISnowflakeService';
import type {InviteRepository} from '../../../invite/InviteRepository';
import {Logger} from '../../../Logger';
import type {LimitConfigService} from '../../../limits/LimitConfigService';
import {resolveLimitSafe} from '../../../limits/LimitConfigUtils';
import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder';
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
import {Guild} from '../../../models/Guild';
import type {User} from '../../../models/User';
import {getGuildSearchService} from '../../../SearchFactory';
import type {GuildDiscoveryContext} from '../../../search/guild/GuildSearchSerializer';
import {deleteChannelMessageSearchDocuments} from '../../../search/MessageSearchIndexCleanup';
import {Channels, ChannelsByGuild, GuildMembers, GuildMembersByUserId, GuildRoles, Guilds} from '../../../Tables';
import type {IUserRepository} from '../../../user/IUserRepository';
import {mapUserSettingsToResponse} from '../../../user/UserMappers';
import {addGuildToUncategorizedFolder, removeGuildFromUserFolders} from '../../../user/utils/GuildFolderUtils';
import type {IWebhookRepository} from '../../../webhook/IWebhookRepository';
import {mapGuildToGuildResponse, mapGuildToPartialResponse} from '../../GuildModel';
import type {IGuildDiscoveryRepository} from '../../repositories/GuildDiscoveryRepository';
import type {IGuildRepositoryAggregate} from '../../repositories/IGuildRepositoryAggregate';
import type {GuildDataHelpers} from './GuildDataHelpers';

const DEFAULT_TEXT_CATEGORY_NAME = 'Text Channels';
const DEFAULT_VOICE_CATEGORY_NAME = 'Voice Channels';
const DEFAULT_TEXT_CHANNEL_NAME = 'general';
const DEFAULT_VOICE_CHANNEL_NAME = 'General';

interface PreparedGuildAssets {
	icon: PreparedAssetUpload | null;
	banner: PreparedAssetUpload | null;
	splash: PreparedAssetUpload | null;
	embed_splash: PreparedAssetUpload | null;
}

interface TemplateGuildSettings {
	verificationLevel: number;
	explicitContentFilter: number;
	defaultMessageNotifications: number;
	systemChannelFlags: number;
	afkTimeout: number;
}

interface MappedTemplateChannel {
	channel: TemplateChannel;
	channelId: ChannelID;
	fluxerType: number;
}

const BASE_GUILD_FEATURES: ReadonlyArray<string> = [
	GuildFeatures.ANIMATED_ICON,
	GuildFeatures.ANIMATED_BANNER,
	GuildFeatures.BANNER,
	GuildFeatures.INVITE_SPLASH,
];
const USER_TOGGLEABLE_GUILD_FEATURES: ReadonlySet<string> = new Set([
	GuildFeatures.INVITES_DISABLED,
	GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES,
	GuildFeatures.DETACHED_BANNER,
	GuildFeatures.CLONE_EMOJI_DISABLED,
	GuildFeatures.CLONE_STICKER_DISABLED,
	GuildFeatures.HIDE_OWNER_CROWN,
]);
const SUPPORTED_SYSTEM_CHANNEL_FLAGS = SystemChannelFlags.SUPPRESS_JOIN_NOTIFICATIONS;

function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
	if (a === b) return true;
	if (a.size !== b.size) return false;
	for (const value of a) {
		if (!b.has(value)) return false;
	}
	return true;
}

const TEMPLATE_AFK_TIMEOUT_MIN_SECONDS = 60;
const TEMPLATE_AFK_TIMEOUT_MAX_SECONDS = 3600;
const THE_OTHER_PLATFORM_GUILD_ANNOUNCEMENT_CHANNEL_TYPE = 5;
const THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE = 13;

export class GuildOperationsService {
	constructor(
		private readonly guildRepository: IGuildRepositoryAggregate,
		private readonly channelRepository: IChannelRepository,
		private readonly inviteRepository: InviteRepository,
		private readonly channelService: ChannelService,
		private readonly gatewayService: IGatewayService,
		private readonly entityAssetService: EntityAssetService,
		private readonly userRepository: IUserRepository,
		private readonly snowflakeService: ISnowflakeService,
		private readonly webhookRepository: IWebhookRepository,
		private readonly helpers: GuildDataHelpers,
		private readonly limitConfigService: LimitConfigService,
		private readonly discoveryRepository: IGuildDiscoveryRepository,
	) {}

	async getGuild({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildResponse> {
		try {
			const guild = await this.gatewayService.getGuildData({guildId, userId});
			if (!guild) throw new UnknownGuildError();
			return guild;
		} catch (error) {
			if (this.isGuildAccessError(error)) {
				if (await this.guildExists(guildId)) {
					throw new AccessDeniedError();
				}
				throw new UnknownGuildError();
			}
			throw error;
		}
	}

	async getUserGuilds(
		userId: UserID,
		options?: {
			before?: GuildID;
			after?: GuildID;
			limit?: number;
			withCounts?: boolean;
		},
	): Promise<Array<GuildResponse>> {
		let guilds = await this.guildRepository.listUserGuilds(userId);
		guilds.sort((a, b) => (a.id < b.id ? -1 : 1));
		if (options?.after) {
			const index = guilds.findIndex((g) => g.id === options.after);
			if (index !== -1) {
				guilds = guilds.slice(index + 1);
			}
		} else if (options?.before) {
			const index = guilds.findIndex((g) => g.id === options.before);
			if (index !== -1) {
				guilds = guilds.slice(0, index);
			}
		}
		const limit = options?.limit ?? 200;
		guilds = guilds.slice(0, limit);
		const guildIds = guilds.map((g) => g.id);
		let permissionsMap = new Map<GuildID, bigint>();
		try {
			permissionsMap = await this.gatewayService.getUserPermissionsBatch({guildIds, userId});
		} catch (error) {
			Logger.warn(
				{userId: userId.toString(), guildCount: guildIds.length, error},
				'[GuildOperationsService] Failed to fetch guild permissions batch for list_guilds; returning without permissions',
			);
		}
		const responses = guilds.map((guild) => {
			const permissions = permissionsMap.get(guild.id);
			if (permissions == null) {
				return mapGuildToGuildResponse(guild);
			}
			return mapGuildToGuildResponse(guild, {permissions});
		});
		if (!options?.withCounts) {
			return responses;
		}
		const guildsWithCounts: Array<GuildResponse> = [];
		const countBatchSize = 25;
		for (let index = 0; index < guilds.length; index += countBatchSize) {
			const guildChunk = guilds.slice(index, index + countBatchSize);
			const responseChunk = responses.slice(index, index + countBatchSize);
			const batchResults = await Promise.all(
				guildChunk.map(async (guild, chunkIndex) => {
					const baseResponse = responseChunk[chunkIndex] ?? mapGuildToGuildResponse(guild);
					try {
						const counts = await this.gatewayService.getGuildCounts(guild.id);
						return {
							...baseResponse,
							approximate_member_count: counts.memberCount,
							approximate_presence_count: counts.presenceCount,
						};
					} catch (error) {
						Logger.warn(
							{guildId: guild.id.toString(), userId: userId.toString(), error},
							'[GuildOperationsService] Failed to fetch guild counts for list_guilds; returning without counts for guild',
						);
						return baseResponse;
					}
				}),
			);
			guildsWithCounts.push(...batchResults);
		}
		return guildsWithCounts;
	}

	async getPublicGuildData(guildId: GuildID): Promise<GuildPartialResponse> {
		const guild = await this.guildRepository.findUnique(guildId);
		if (!guild) throw new UnknownGuildError();
		return mapGuildToPartialResponse(guild);
	}

	async getGuildSystem(guildId: GuildID): Promise<Guild> {
		const guild = await this.guildRepository.findUnique(guildId);
		if (!guild) throw new UnknownGuildError();
		return guild;
	}

	async createGuild(
		params: {
			user: User;
			data: GuildCreateRequest;
			locale?: string | null;
		},
		_auditLogReason?: string | null,
	): Promise<GuildResponse> {
		const {user, data} = params;
		if (user.isBot) {
			throw new BotsCannotCreateGuildsError();
		}
		if (user.isUnclaimedAccount()) {
			throw new UnclaimedAccountCannotCreateGuildsError();
		}
		const currentGuildCount = await this.guildRepository.countUserGuilds(user.id);
		const ctx = createLimitMatchContext({user});
		const maxGuilds = resolveLimitSafe(
			this.limitConfigService.getConfigSnapshot(),
			ctx,
			'max_guilds',
			DEFAULT_STOCK_LIMITS.max_guilds,
		);
		if (currentGuildCount >= maxGuilds) throw new MaxGuildsError(maxGuilds);
		const guildId = createGuildID(await this.snowflakeService.generate());
		contentModerationService.scanText(data.name, {
			userId: user.id,
			guildId,
			channelId: null,
			messageId: null,
			surface: 'profile_field',
		});
		let preparedIcon: PreparedAssetUpload | null = null;
		if (data.icon) {
			preparedIcon = await this.entityAssetService.prepareAssetUpload({
				assetType: 'icon',
				entityType: 'guild',
				entityId: guildId,
				previousHash: null,
				base64Image: data.icon,
				errorPath: 'icon',
			});
		}
		const iconKey = preparedIcon?.newHash ?? null;
		const shouldUseEmptyFeatures = data.empty_features ?? false;
		const featuresSet = shouldUseEmptyFeatures ? new Set<string>() : new Set(BASE_GUILD_FEATURES);
		const templateSettings = this.sanitiseTemplateGuildSettings(data.template);
		const batch = new BatchBuilder();
		let systemChannelId: ChannelID;
		if (data.template) {
			const templateBatch = new BatchBuilder();
			const templateResult = await this.buildTemplateEntities(guildId, data.template, templateBatch);
			systemChannelId = templateResult.systemChannelId;
			await templateBatch.executeChunked(50);
		} else {
			const defaultResult = await this.buildDefaultEntities(guildId, batch);
			systemChannelId = defaultResult.systemChannelId;
		}
		const guildData: GuildRow = {
			guild_id: guildId,
			owner_id: user.id,
			name: data.name,
			vanity_url_code: null,
			icon_hash: iconKey,
			banner_hash: null,
			banner_width: null,
			banner_height: null,
			splash_hash: null,
			splash_width: null,
			splash_height: null,
			splash_card_alignment: GuildSplashCardAlignment.CENTER,
			embed_splash_hash: null,
			embed_splash_width: null,
			embed_splash_height: null,
			features: featuresSet,
			verification_level: templateSettings.verificationLevel,
			mfa_level: 0,
			nsfw_level: 0,
			nsfw: false,
			content_warning_level: null,
			content_warning_text: null,
			explicit_content_filter: templateSettings.explicitContentFilter,
			default_message_notifications: templateSettings.defaultMessageNotifications,
			system_channel_id: systemChannelId,
			system_channel_flags: templateSettings.systemChannelFlags,
			rules_channel_id: null,
			afk_channel_id: null,
			afk_timeout: templateSettings.afkTimeout,
			disabled_operations: 0,
			member_count: 1,
			audit_logs_indexed_at: null,
			members_indexed_at: null,
			message_history_cutoff: null,
			version: 1,
		};
		batch.addPrepared(Guilds.insert(guildData));
		batch.addPrepared(
			GuildMembers.insert({
				guild_id: guildId,
				user_id: user.id,
				joined_at: new Date(),
				nick: null,
				avatar_hash: null,
				banner_hash: null,
				bio: null,
				pronouns: null,
				accent_color: null,
				join_source_type: JoinSourceTypes.CREATOR,
				source_invite_code: null,
				inviter_id: null,
				deaf: false,
				mute: false,
				communication_disabled_until: null,
				role_ids: null,
				is_premium_sanitized: null,
				temporary: false,
				profile_flags: null,
				mention_flags: null,
				version: 1,
			}),
		);
		batch.addPrepared(GuildMembersByUserId.insert({user_id: user.id, guild_id: guildId}));
		await batch.execute();
		const guild = new Guild(guildData);
		await this.gatewayService.startGuild(guildId);
		await this.gatewayService.joinGuild({userId: user.id, guildId});
		if (!user.isBot) {
			const userSettings = await this.userRepository.findSettings(user.id);
			if (userSettings) {
				const settingsRow = userSettings.toRow();
				const {folders: nextFolders, modified} = addGuildToUncategorizedFolder({
					folders: settingsRow.guild_folders ?? [],
					guildId,
					defaultIcon: DEFAULT_GUILD_FOLDER_ICON,
				});
				if (modified) {
					settingsRow.guild_folders = nextFolders;
					const updatedSettings = await this.userRepository.upsertSettings(settingsRow);
					await this.gatewayService.dispatchPresence({
						userId: user.id,
						event: 'USER_SETTINGS_UPDATE',
						data: mapUserSettingsToResponse({settings: updatedSettings}),
					});
				}
			}
		}
		const guildSearchService = getGuildSearchService();
		if (guildSearchService) {
			await guildSearchService.indexGuild(guild).catch((error) => {
				Logger.error({guildId: guild.id, error}, 'Failed to index guild in search');
			});
		}
		return mapGuildToGuildResponse(guild);
	}

	async updateGuild(
		params: {
			userId: UserID;
			guildId: GuildID;
			data: GuildUpdateRequest;
			requestCache: RequestCache;
		},
		auditLogReason?: string | null,
	): Promise<{
		guild: GuildResponse;
		previousFeatures: Set<string>;
		updatedFeatures: Set<string>;
	}> {
		const {userId, guildId, data} = params;
		const {checkPermission, guildData} = await this.helpers.getGuildAuthenticated({userId, guildId});
		await checkPermission(Permissions.MANAGE_GUILD);
		const currentGuild = await this.guildRepository.findUnique(guildId);
		if (!currentGuild) throw new UnknownGuildError();
		const previousSnapshot = this.helpers.serializeGuildForAudit(currentGuild);
		const previousFeatures = new Set(currentGuild.features);
		contentModerationService.scanText(data.name ?? null, {
			userId,
			guildId,
			channelId: null,
			messageId: null,
			surface: 'profile_field',
		});
		if (
			data.verification_level !== undefined &&
			data.verification_level < GuildVerificationLevel.LOW &&
			currentGuild.features.has(GuildFeatures.DISCOVERABLE)
		) {
			throw InputValidationError.fromCode(
				'verification_level',
				ValidationErrorCodes.DISCOVERABLE_GUILD_VERIFICATION_LEVEL_TOO_LOW,
			);
		}
		const isMfaLevelChange = data.mfa_level !== undefined && data.mfa_level !== currentGuild.mfaLevel;
		if (isMfaLevelChange) {
			const isOwner = guildData.owner_id === userId.toString();
			if (!isOwner) {
				throw new MissingPermissionsError();
			}
			const owner = await this.userRepository.findUniqueAssert(userId);
			if (owner.authenticatorTypes.size === 0) {
				throw InputValidationError.fromCode(
					'mfa_level',
					ValidationErrorCodes.MUST_ENABLE_2FA_BEFORE_REQUIRING_FOR_MODS,
				);
			}
		}
		const updatedFeatures = this.computeUpdatedFeatures(previousFeatures, data.features);
		const featuresChanged = !setsEqual(previousFeatures, updatedFeatures);
		const preparedAssets: PreparedGuildAssets = {icon: null, banner: null, splash: null, embed_splash: null};
		const patch: Partial<GuildRow> = {};
		if (data.name !== undefined) {
			patch.name = data.name;
		}
		if (data.icon !== undefined) {
			preparedAssets.icon = await this.entityAssetService.prepareAssetUpload({
				assetType: 'icon',
				entityType: 'guild',
				entityId: guildId,
				previousHash: currentGuild.iconHash,
				base64Image: data.icon,
				errorPath: 'icon',
			});
			patch.icon_hash = preparedAssets.icon.newHash;
		}
		if (data.banner !== undefined) {
			if (data.banner && !currentGuild.features.has(GuildFeatures.BANNER)) {
				await this.rollbackPreparedAssets(preparedAssets);
				throw InputValidationError.fromCode('banner', ValidationErrorCodes.GUILD_BANNER_REQUIRES_FEATURE);
			}
			if (data.banner === null) {
				patch.banner_hash = null;
				patch.banner_width = null;
				patch.banner_height = null;
			} else {
				try {
					preparedAssets.banner = await this.entityAssetService.prepareAssetUpload({
						assetType: 'banner',
						entityType: 'guild',
						entityId: guildId,
						previousHash: currentGuild.bannerHash,
						base64Image: data.banner,
						errorPath: 'banner',
					});
					if (preparedAssets.banner.isAnimated && !currentGuild.features.has(GuildFeatures.ANIMATED_BANNER)) {
						await this.rollbackPreparedAssets(preparedAssets);
						throw InputValidationError.fromCode('banner', ValidationErrorCodes.ANIMATED_GUILD_BANNER_REQUIRES_FEATURE);
					}
					patch.banner_hash = preparedAssets.banner.newHash;
					patch.banner_height =
						preparedAssets.banner.newHash === currentGuild.bannerHash && currentGuild.bannerHeight != null
							? currentGuild.bannerHeight
							: (preparedAssets.banner.height ?? null);
					patch.banner_width =
						preparedAssets.banner.newHash === currentGuild.bannerHash && currentGuild.bannerWidth != null
							? currentGuild.bannerWidth
							: (preparedAssets.banner.width ?? null);
				} catch (error) {
					await this.rollbackPreparedAssets(preparedAssets);
					throw error;
				}
			}
		}
		if (data.splash !== undefined) {
			if (data.splash && !currentGuild.features.has(GuildFeatures.INVITE_SPLASH)) {
				await this.rollbackPreparedAssets(preparedAssets);
				throw InputValidationError.fromCode('splash', ValidationErrorCodes.INVITE_SPLASH_REQUIRES_FEATURE);
			}
			if (data.splash === null) {
				patch.splash_hash = null;
				patch.splash_width = null;
				patch.splash_height = null;
			} else {
				try {
					preparedAssets.splash = await this.entityAssetService.prepareAssetUpload({
						assetType: 'splash',
						entityType: 'guild',
						entityId: guildId,
						previousHash: currentGuild.splashHash,
						base64Image: data.splash,
						errorPath: 'splash',
					});
					patch.splash_hash = preparedAssets.splash.newHash;
					patch.splash_height =
						preparedAssets.splash.newHash === currentGuild.splashHash && currentGuild.splashHeight != null
							? currentGuild.splashHeight
							: (preparedAssets.splash.height ?? null);
					patch.splash_width =
						preparedAssets.splash.newHash === currentGuild.splashHash && currentGuild.splashWidth != null
							? currentGuild.splashWidth
							: (preparedAssets.splash.width ?? null);
				} catch (error) {
					await this.rollbackPreparedAssets(preparedAssets);
					throw error;
				}
			}
		}
		if (data.embed_splash !== undefined) {
			if (data.embed_splash && !currentGuild.features.has(GuildFeatures.INVITE_SPLASH)) {
				await this.rollbackPreparedAssets(preparedAssets);
				throw InputValidationError.fromCode('embed_splash', ValidationErrorCodes.EMBED_SPLASH_REQUIRES_FEATURE);
			}
			if (data.embed_splash === null) {
				patch.embed_splash_hash = null;
				patch.embed_splash_width = null;
				patch.embed_splash_height = null;
			} else {
				try {
					preparedAssets.embed_splash = await this.entityAssetService.prepareAssetUpload({
						assetType: 'embed_splash',
						entityType: 'guild',
						entityId: guildId,
						previousHash: currentGuild.embedSplashHash,
						base64Image: data.embed_splash,
						errorPath: 'embed_splash',
					});
					patch.embed_splash_hash = preparedAssets.embed_splash.newHash;
					patch.embed_splash_height =
						preparedAssets.embed_splash.newHash === currentGuild.embedSplashHash &&
						currentGuild.embedSplashHeight != null
							? currentGuild.embedSplashHeight
							: (preparedAssets.embed_splash.height ?? null);
					patch.embed_splash_width =
						preparedAssets.embed_splash.newHash === currentGuild.embedSplashHash &&
						currentGuild.embedSplashWidth != null
							? currentGuild.embedSplashWidth
							: (preparedAssets.embed_splash.width ?? null);
				} catch (error) {
					await this.rollbackPreparedAssets(preparedAssets);
					throw error;
				}
			}
		}
		if (data.splash_card_alignment !== undefined) {
			patch.splash_card_alignment = data.splash_card_alignment;
		}
		if (data.afk_channel_id !== undefined) {
			if (data.afk_channel_id) {
				const afkChannelId = createChannelID(data.afk_channel_id);
				const afkChannel = await this.channelRepository.findUnique(afkChannelId);
				if (!afkChannel || afkChannel.guildId !== guildId) {
					throw InputValidationError.fromCode('afk_channel_id', ValidationErrorCodes.AFK_CHANNEL_MUST_BE_IN_GUILD);
				}
				if (afkChannel.type !== ChannelTypes.GUILD_VOICE) {
					throw InputValidationError.fromCode('afk_channel_id', ValidationErrorCodes.AFK_CHANNEL_MUST_BE_VOICE);
				}
				patch.afk_channel_id = afkChannelId;
			} else {
				patch.afk_channel_id = null;
			}
		}
		if (data.afk_timeout !== undefined) {
			patch.afk_timeout = data.afk_timeout;
		}
		if (data.system_channel_id !== undefined) {
			if (data.system_channel_id) {
				const systemChannelId = createChannelID(data.system_channel_id);
				const systemChannel = await this.channelRepository.findUnique(systemChannelId);
				if (!systemChannel || systemChannel.guildId !== guildId) {
					throw InputValidationError.fromCode(
						'system_channel_id',
						ValidationErrorCodes.SYSTEM_CHANNEL_MUST_BE_IN_GUILD,
					);
				}
				if (systemChannel.type !== ChannelTypes.GUILD_TEXT) {
					throw InputValidationError.fromCode('system_channel_id', ValidationErrorCodes.SYSTEM_CHANNEL_MUST_BE_TEXT);
				}
				patch.system_channel_id = systemChannelId;
			} else {
				patch.system_channel_id = null;
			}
		}
		if (data.system_channel_flags !== undefined) {
			patch.system_channel_flags = data.system_channel_flags & SUPPORTED_SYSTEM_CHANNEL_FLAGS;
		}
		if (data.default_message_notifications !== undefined) {
			patch.default_message_notifications = data.default_message_notifications;
		}
		if (data.verification_level !== undefined) {
			patch.verification_level = data.verification_level;
		}
		if (data.mfa_level !== undefined) {
			patch.mfa_level = data.mfa_level;
		}
		let nextNsfw: boolean | undefined;
		if (data.nsfw !== undefined) {
			nextNsfw = data.nsfw;
		} else if (data.nsfw_level !== undefined) {
			nextNsfw = data.nsfw_level === GuildNSFWLevel.AGE_RESTRICTED;
			if (
				nextNsfw === true &&
				data.content_warning_level === undefined &&
				(currentGuild.contentWarningLevel ?? 0) === ContentWarningLevel.INHERIT
			) {
				patch.content_warning_level = ContentWarningLevel.CONTENT_WARNING;
			}
		}
		if (nextNsfw !== undefined) {
			patch.nsfw = nextNsfw;
			patch.nsfw_level = nextNsfw ? GuildNSFWLevel.AGE_RESTRICTED : GuildNSFWLevel.SAFE;
		}
		if (data.content_warning_level !== undefined) {
			patch.content_warning_level =
				data.content_warning_level === ContentWarningLevel.CONTENT_WARNING
					? ContentWarningLevel.CONTENT_WARNING
					: ContentWarningLevel.INHERIT;
		}
		if (data.content_warning_text !== undefined) {
			const trimmed = data.content_warning_text == null ? null : data.content_warning_text.trim();
			patch.content_warning_text = trimmed && trimmed.length > 0 ? trimmed : null;
		}
		if (data.explicit_content_filter !== undefined) {
			patch.explicit_content_filter = data.explicit_content_filter;
		}
		if (data.message_history_cutoff !== undefined) {
			if (data.message_history_cutoff === null) {
				patch.message_history_cutoff = null;
			} else {
				const cutoffDate = new Date(data.message_history_cutoff);
				const guildCreationTimestamp = extractTimestamp(guildId.toString());
				if (cutoffDate.getTime() < guildCreationTimestamp) {
					await this.rollbackPreparedAssets(preparedAssets);
					throw InputValidationError.fromCode(
						'message_history_cutoff',
						ValidationErrorCodes.MESSAGE_HISTORY_CUTOFF_BEFORE_GUILD_CREATION,
					);
				}
				if (cutoffDate.getTime() > Date.now()) {
					await this.rollbackPreparedAssets(preparedAssets);
					throw InputValidationError.fromCode(
						'message_history_cutoff',
						ValidationErrorCodes.MESSAGE_HISTORY_CUTOFF_IN_FUTURE,
					);
				}
				patch.message_history_cutoff = cutoffDate;
			}
		}
		if (featuresChanged) {
			patch.features = updatedFeatures;
		}
		let updatedGuild: Guild;
		if (Object.keys(patch).length === 0) {
			updatedGuild = currentGuild;
		} else {
			try {
				updatedGuild = await this.guildRepository.upsertPartial(guildId, patch, currentGuild.toRow());
			} catch (error) {
				await this.rollbackPreparedAssets(preparedAssets);
				Logger.error({error, guildId}, 'Guild update failed, rolled back asset uploads');
				throw error;
			}
		}
		try {
			await this.commitPreparedAssets(preparedAssets);
		} catch (error) {
			Logger.error({error, guildId}, 'Failed to commit asset changes after successful guild update');
		}
		await this.helpers.dispatchGuildUpdate(updatedGuild);
		const guildSearchService = getGuildSearchService();
		if (guildSearchService) {
			let discoveryContext: GuildDiscoveryContext | undefined;
			if (updatedGuild.features.has(GuildFeatures.DISCOVERABLE)) {
				const discoveryRow = await this.discoveryRepository.findByGuildId(updatedGuild.id).catch(() => null);
				if (discoveryRow?.status === DiscoveryApplicationStatus.APPROVED) {
					discoveryContext = {
						description: discoveryRow.description,
						categoryId: discoveryRow.category_type,
						primaryLanguage: discoveryRow.primary_language ?? null,
						tags: discoveryRow.custom_tags ?? [],
					};
				}
			}
			await guildSearchService.updateGuild(updatedGuild, discoveryContext).catch((error) => {
				Logger.error({guildId: updatedGuild.id, error}, 'Failed to update guild in search');
			});
		}
		const auditLogChanges = this.helpers.computeGuildChanges(previousSnapshot, updatedGuild);
		if (auditLogChanges.length > 0) {
			await this.helpers.recordAuditLog({
				guildId,
				userId,
				action: AuditLogActionType.GUILD_UPDATE,
				targetId: guildId,
				auditLogReason: auditLogReason ?? null,
				metadata: {name: updatedGuild.name},
				changes: auditLogChanges,
			});
		}
		if (data.name !== undefined && currentGuild.name !== updatedGuild.name) {
		}
		if (data.icon !== undefined && currentGuild.iconHash !== updatedGuild.iconHash) {
		}
		if (data.banner !== undefined && currentGuild.bannerHash !== updatedGuild.bannerHash) {
		}
		return {
			guild: mapGuildToGuildResponse(updatedGuild),
			previousFeatures,
			updatedFeatures: new Set(updatedGuild.features),
		};
	}

	private computeUpdatedFeatures(
		previousFeatures: ReadonlySet<string>,
		desiredFeatures: ReadonlyArray<string> | undefined,
	): Set<string> {
		if (!desiredFeatures) {
			return new Set(previousFeatures);
		}
		const desired = new Set(desiredFeatures);
		const next = new Set(previousFeatures);
		for (const feature of desired) {
			if (previousFeatures.has(feature)) continue;
			if (!USER_TOGGLEABLE_GUILD_FEATURES.has(feature)) {
				throw InputValidationError.fromCode('features', ValidationErrorCodes.GUILD_FEATURE_NOT_TOGGLEABLE, {
					feature,
				});
			}
			next.add(feature);
		}
		for (const feature of previousFeatures) {
			if (desired.has(feature)) continue;
			if (!USER_TOGGLEABLE_GUILD_FEATURES.has(feature)) continue;
			next.delete(feature);
		}
		return next;
	}

	private async rollbackPreparedAssets(assets: PreparedGuildAssets): Promise<void> {
		const rollbackPromises: Array<Promise<void>> = [];
		if (assets.icon) {
			rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(assets.icon));
		}
		if (assets.banner) {
			rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(assets.banner));
		}
		if (assets.splash) {
			rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(assets.splash));
		}
		if (assets.embed_splash) {
			rollbackPromises.push(this.entityAssetService.rollbackAssetUpload(assets.embed_splash));
		}
		await Promise.all(rollbackPromises);
	}

	private async commitPreparedAssets(assets: PreparedGuildAssets): Promise<void> {
		const commitPromises: Array<Promise<void>> = [];
		if (assets.icon) {
			commitPromises.push(this.entityAssetService.commitAssetChange({prepared: assets.icon, deferDeletion: true}));
		}
		if (assets.banner) {
			commitPromises.push(this.entityAssetService.commitAssetChange({prepared: assets.banner, deferDeletion: true}));
		}
		if (assets.splash) {
			commitPromises.push(this.entityAssetService.commitAssetChange({prepared: assets.splash, deferDeletion: true}));
		}
		if (assets.embed_splash) {
			commitPromises.push(
				this.entityAssetService.commitAssetChange({prepared: assets.embed_splash, deferDeletion: true}),
			);
		}
		await Promise.all(commitPromises);
	}

	async deleteGuild(
		params: {
			user: User;
			guildId: GuildID;
		},
		_auditLogReason?: string | null,
	): Promise<void> {
		const {user, guildId} = params;
		const {guildData} = await this.helpers.getGuildAuthenticated({userId: user.id, guildId});
		if (!guildData || guildData.owner_id !== user.id.toString()) {
			throw new MissingPermissionsError();
		}
		await this.performGuildDeletion(guildId);
	}

	async deleteGuildById(guildId: GuildID): Promise<void> {
		await this.performGuildDeletion(guildId);
	}

	private async performGuildDeletion(guildId: GuildID): Promise<void> {
		const guild = await this.guildRepository.findUnique(guildId);
		if (!guild) {
			throw new UnknownGuildError();
		}
		const members = await this.guildRepository.listMembers(guildId);
		await this.gatewayService.dispatchGuild({
			guildId,
			event: 'GUILD_DELETE',
			data: {id: guildId.toString()},
		});
		await Promise.all(
			members.map(async (member) => {
				await this.gatewayService.leaveGuild({userId: member.userId, guildId});
			}),
		);
		await Promise.all(members.map((member) => this.userRepository.deleteGuildSettings(member.userId, guildId)));
		await Promise.all(
			members.map(async (member) => {
				const user = await this.userRepository.findUnique(member.userId);
				if (user && !user.isBot) {
					await removeGuildFromUserFolders({
						userId: member.userId,
						guildId,
						userRepository: this.userRepository,
						gatewayService: this.gatewayService,
					});
				}
			}),
		);
		const invites = await this.inviteRepository.listGuildInvites(guildId);
		await Promise.all(invites.map((invite) => this.inviteRepository.delete(invite.code)));
		const webhooks = await this.webhookRepository.listByGuild(guildId);
		await Promise.all(webhooks.map((webhook) => this.webhookRepository.delete(webhook.id)));
		const channels = await this.channelRepository.listGuildChannels(guildId);
		await Promise.all(channels.map((channel) => this.channelRepository.deleteAllChannelMessages(channel.id)));
		await Promise.all(
			channels.map((channel) => deleteChannelMessageSearchDocuments(channel.id, {context: {source: 'guild_delete'}})),
		);
		await Promise.all(channels.map((channel) => this.channelService.attachments.purgeChannelAttachments(channel)));
		const discoveryRow = await this.discoveryRepository.findByGuildId(guildId);
		if (discoveryRow) {
			await this.discoveryRepository.deleteByGuildId(guildId, discoveryRow.status, discoveryRow.applied_at);
		}
		await this.guildRepository.delete(guildId, guild.ownerId);
		await this.gatewayService.stopGuild(guildId);
		const guildSearchService = getGuildSearchService();
		if (guildSearchService) {
			await guildSearchService.deleteGuild(guildId).catch((error) => {
				Logger.error({guildId, error}, 'Failed to delete guild from search');
			});
		}
	}

	private async buildDefaultEntities(
		guildId: GuildID,
		batch: BatchBuilder,
	): Promise<{
		systemChannelId: ChannelID;
	}> {
		const textCategoryId = createChannelID(await this.snowflakeService.generate());
		const voiceCategoryId = createChannelID(await this.snowflakeService.generate());
		const generalChannelId = createChannelID(await this.snowflakeService.generate());
		const generalVoiceId = createChannelID(await this.snowflakeService.generate());
		const addChannel = (
			channelId: ChannelID,
			type: number,
			name: string,
			parentId: ChannelID | null,
			position: number,
			bitrate: number | null = null,
		) => {
			batch.addPrepared(
				Channels.insert({
					channel_id: channelId,
					guild_id: guildId,
					type,
					name,
					topic: null,
					icon_hash: null,
					url: null,
					parent_id: parentId,
					position,
					owner_id: null,
					recipient_ids: null,
					nsfw: false,
					content_warning_level: null,
					content_warning_text: null,
					rate_limit_per_user: 0,
					bitrate,
					user_limit: bitrate !== null ? 0 : null,
					voice_connection_limit: bitrate !== null ? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT : null,
					rtc_region: null,
					last_message_id: null,
					last_pin_timestamp: null,
					permission_overwrites: null,
					nicks: null,
					soft_deleted: false,
					indexed_at: null,
					version: 1,
				}),
			);
			batch.addPrepared(
				ChannelsByGuild.upsertAll({
					guild_id: guildId,
					channel_id: channelId,
				}),
			);
		};
		addChannel(textCategoryId, ChannelTypes.GUILD_CATEGORY, DEFAULT_TEXT_CATEGORY_NAME, null, 0);
		addChannel(voiceCategoryId, ChannelTypes.GUILD_CATEGORY, DEFAULT_VOICE_CATEGORY_NAME, null, 1);
		addChannel(generalChannelId, ChannelTypes.GUILD_TEXT, DEFAULT_TEXT_CHANNEL_NAME, textCategoryId, 0);
		addChannel(generalVoiceId, ChannelTypes.GUILD_VOICE, DEFAULT_VOICE_CHANNEL_NAME, voiceCategoryId, 0, 64000);
		batch.addPrepared(
			GuildRoles.insert({
				guild_id: guildId,
				role_id: guildIdToRoleId(guildId),
				name: '@everyone',
				permissions: DEFAULT_PERMISSIONS,
				position: 0,
				hoist_position: null,
				color: 0,
				icon_hash: null,
				unicode_emoji: null,
				hoist: false,
				mentionable: false,
				version: 1,
			}),
		);
		return {systemChannelId: generalChannelId};
	}

	private async buildTemplateEntities(
		guildId: GuildID,
		template: TemplateSerializedGuild,
		batch: BatchBuilder,
	): Promise<{
		systemChannelId: ChannelID;
	}> {
		if (template.channels.length > MAX_GUILD_CHANNELS) {
			throw new GuildTemplateInvalidError();
		}
		if (template.roles.length > MAX_GUILD_ROLES) {
			throw new GuildTemplateInvalidError();
		}
		const roleIdMap = new Map<string, RoleID>();
		const channelIdMap = new Map<string, ChannelID>();
		const channelTypeMap = new Map<string, number>();
		const everyoneRole = this.findTemplateEveryoneRole(template.roles);
		const everyoneRoleKey = everyoneRole ? this.getTemplateEntityKey(everyoneRole.id) : null;
		const everyoneRoleId = guildIdToRoleId(guildId);
		roleIdMap.set('0', everyoneRoleId);
		if (everyoneRoleKey) {
			roleIdMap.set(everyoneRoleKey, everyoneRoleId);
		}
		const seenRoleKeys = new Set<string>();
		for (const role of template.roles) {
			const roleKey = this.getTemplateEntityKey(role.id);
			if (seenRoleKeys.has(roleKey)) {
				throw new GuildTemplateInvalidError();
			}
			seenRoleKeys.add(roleKey);
			if (roleKey === '0' || roleKey === everyoneRoleKey) continue;
			roleIdMap.set(roleKey, createRoleID(await this.snowflakeService.generate()));
		}
		const seenChannelKeys = new Set<string>();
		const mappedChannels: Array<MappedTemplateChannel> = [];
		const skippedOtherPlatformChannelTypes = new Set<number>();
		for (const channel of template.channels) {
			const channelKey = this.getTemplateEntityKey(channel.id);
			if (seenChannelKeys.has(channelKey)) {
				throw new GuildTemplateInvalidError();
			}
			seenChannelKeys.add(channelKey);
			const fluxerType = this.mapOtherPlatformTemplateChannelTypeToFluxer(channel.type);
			if (fluxerType == null) {
				skippedOtherPlatformChannelTypes.add(channel.type);
				continue;
			}
			const channelId = createChannelID(await this.snowflakeService.generate());
			channelIdMap.set(channelKey, channelId);
			channelTypeMap.set(channelKey, fluxerType);
			mappedChannels.push({channel, channelId, fluxerType});
		}
		if (skippedOtherPlatformChannelTypes.size > 0) {
			Logger.warn(
				{
					guildId: guildId.toString(),
					unsupportedOtherPlatformChannelTypes: Array.from(skippedOtherPlatformChannelTypes),
				},
				'[GuildOperationsService] Template import skipped unsupported channel types',
			);
		}
		const everyonePermissions =
			this.parseTemplatePermissionBitfield(everyoneRole?.permissions_new ?? everyoneRole?.permissions) ||
			DEFAULT_PERMISSIONS;
		batch.addPrepared(
			GuildRoles.insert({
				guild_id: guildId,
				role_id: everyoneRoleId,
				name: '@everyone',
				permissions: everyonePermissions,
				position: 0,
				hoist_position: null,
				color: 0,
				icon_hash: null,
				unicode_emoji: null,
				hoist: false,
				mentionable: false,
				version: 1,
			}),
		);
		let nextRolePosition = 1;
		for (const role of template.roles) {
			const roleKey = this.getTemplateEntityKey(role.id);
			if (roleKey === '0' || roleKey === everyoneRoleKey) continue;
			const roleId = roleIdMap.get(roleKey);
			if (!roleId) continue;
			batch.addPrepared(
				GuildRoles.insert({
					guild_id: guildId,
					role_id: roleId,
					name: role.name,
					permissions: this.parseTemplatePermissionBitfield(role.permissions_new ?? role.permissions),
					position: nextRolePosition,
					hoist_position: null,
					color: role.color ?? 0,
					icon_hash: null,
					unicode_emoji: role.unicode_emoji ?? null,
					hoist: role.hoist ?? false,
					mentionable: role.mentionable ?? false,
					version: 1,
				}),
			);
			nextRolePosition += 1;
		}
		let firstTextChannelId: ChannelID | null = null;
		for (const mappedChannel of mappedChannels) {
			const {channel, channelId, fluxerType} = mappedChannel;
			const parentKey = channel.parent_id != null ? this.getTemplateEntityKey(channel.parent_id) : null;
			const parentType = parentKey != null ? channelTypeMap.get(parentKey) : null;
			const parentId =
				parentKey != null && parentType === ChannelTypes.GUILD_CATEGORY ? (channelIdMap.get(parentKey) ?? null) : null;
			const isVoice = fluxerType === ChannelTypes.GUILD_VOICE;
			let permissionOverwrites: Map<RoleID | UserID, PermissionOverwrite> | null = null;
			if (channel.permission_overwrites && channel.permission_overwrites.length > 0) {
				permissionOverwrites = new Map();
				for (const overwrite of channel.permission_overwrites) {
					if (overwrite.type !== 0) continue;
					const mappedRoleId = roleIdMap.get(this.getTemplateEntityKey(overwrite.id));
					if (!mappedRoleId) continue;
					const allow = this.parseTemplatePermissionBitfield(overwrite.allow);
					const deny = this.parseTemplatePermissionBitfield(overwrite.deny);
					permissionOverwrites.set(mappedRoleId, {
						type: overwrite.type,
						allow_: allow,
						deny_: deny,
					});
				}
				if (permissionOverwrites.size === 0) {
					permissionOverwrites = null;
				}
			}
			if (fluxerType === ChannelTypes.GUILD_TEXT && !firstTextChannelId) {
				firstTextChannelId = channelId;
			}
			batch.addPrepared(
				Channels.insert({
					channel_id: channelId,
					guild_id: guildId,
					type: fluxerType,
					name: channel.name,
					topic: channel.topic ?? null,
					icon_hash: null,
					url: null,
					parent_id: parentId,
					position: channel.position,
					owner_id: null,
					recipient_ids: null,
					nsfw: channel.nsfw ?? false,
					content_warning_level: null,
					content_warning_text: null,
					rate_limit_per_user: channel.rate_limit_per_user ?? 0,
					bitrate: isVoice ? (channel.bitrate ?? 64000) : null,
					user_limit: isVoice ? (channel.user_limit ?? 0) : null,
					voice_connection_limit: isVoice
						? (channel.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT)
						: null,
					rtc_region: null,
					last_message_id: null,
					last_pin_timestamp: null,
					permission_overwrites: permissionOverwrites,
					nicks: null,
					soft_deleted: false,
					indexed_at: null,
					version: 1,
				}),
			);
			batch.addPrepared(
				ChannelsByGuild.upsertAll({
					guild_id: guildId,
					channel_id: channelId,
				}),
			);
		}
		let systemChannelId: ChannelID | null = null;
		if (template.system_channel_id != null) {
			const templateSystemChannelKey = this.getTemplateEntityKey(template.system_channel_id);
			const templateSystemChannelType = channelTypeMap.get(templateSystemChannelKey);
			if (templateSystemChannelType === ChannelTypes.GUILD_TEXT) {
				systemChannelId = channelIdMap.get(templateSystemChannelKey) ?? null;
			}
		}
		if (!systemChannelId && firstTextChannelId) {
			systemChannelId = firstTextChannelId;
		}
		if (!systemChannelId) {
			systemChannelId = createChannelID(await this.snowflakeService.generate());
			batch.addPrepared(
				Channels.insert({
					channel_id: systemChannelId,
					guild_id: guildId,
					type: ChannelTypes.GUILD_TEXT,
					name: DEFAULT_TEXT_CHANNEL_NAME,
					topic: null,
					icon_hash: null,
					url: null,
					parent_id: null,
					position: 0,
					owner_id: null,
					recipient_ids: null,
					nsfw: false,
					content_warning_level: null,
					content_warning_text: null,
					rate_limit_per_user: 0,
					bitrate: null,
					user_limit: null,
					voice_connection_limit: null,
					rtc_region: null,
					last_message_id: null,
					last_pin_timestamp: null,
					permission_overwrites: null,
					nicks: null,
					soft_deleted: false,
					indexed_at: null,
					version: 1,
				}),
			);
			batch.addPrepared(
				ChannelsByGuild.upsertAll({
					guild_id: guildId,
					channel_id: systemChannelId,
				}),
			);
		}
		return {systemChannelId};
	}

	private parseTemplatePermissionBitfield(value: string | number | undefined): bigint {
		const valueToParse = value ?? '0';
		try {
			return BigInt(valueToParse) & ALL_PERMISSIONS;
		} catch {
			throw new GuildTemplateInvalidError();
		}
	}

	private getTemplateEntityKey(id: number | string): string {
		return String(id);
	}

	private findTemplateEveryoneRole(roles: Array<TemplateRole>): TemplateRole | null {
		const namedEveryoneRole = roles.find((role) => role.name === '@everyone');
		if (namedEveryoneRole) {
			return namedEveryoneRole;
		}
		return roles.find((role) => this.getTemplateEntityKey(role.id) === '0') ?? null;
	}

	private mapOtherPlatformTemplateChannelTypeToFluxer(channelType: number): number | null {
		if (
			channelType === ChannelTypes.GUILD_TEXT ||
			channelType === ChannelTypes.GUILD_VOICE ||
			channelType === ChannelTypes.GUILD_CATEGORY
		) {
			return channelType;
		}
		if (channelType === THE_OTHER_PLATFORM_GUILD_ANNOUNCEMENT_CHANNEL_TYPE) {
			return ChannelTypes.GUILD_TEXT;
		}
		if (channelType === THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE) {
			return ChannelTypes.GUILD_VOICE;
		}
		return null;
	}

	private sanitiseTemplateGuildSettings(template?: TemplateSerializedGuild): TemplateGuildSettings {
		return {
			verificationLevel: this.clampTemplateSetting(template?.verification_level, 0, 4, 0),
			explicitContentFilter: this.clampTemplateSetting(template?.explicit_content_filter, 0, 2, 0),
			defaultMessageNotifications: this.clampTemplateSetting(template?.default_message_notifications, 0, 1, 0),
			systemChannelFlags: (template?.system_channel_flags ?? 0) & SUPPORTED_SYSTEM_CHANNEL_FLAGS,
			afkTimeout: this.clampTemplateSetting(
				template?.afk_timeout,
				TEMPLATE_AFK_TIMEOUT_MIN_SECONDS,
				TEMPLATE_AFK_TIMEOUT_MAX_SECONDS,
				300,
			),
		};
	}

	private clampTemplateSetting(value: number | undefined, min: number, max: number, fallback: number): number {
		if (value == null || Number.isNaN(value)) {
			return fallback;
		}
		const integerValue = Math.trunc(value);
		if (integerValue < min) return min;
		if (integerValue > max) return max;
		return integerValue;
	}

	private isGuildAccessError(error: unknown): boolean {
		return error instanceof UnknownGuildError;
	}

	private async guildExists(guildId: GuildID): Promise<boolean> {
		const guild = await this.guildRepository.findUnique(guildId);
		return guild !== null;
	}
}
