<?php

declare(strict_types=1);
/**
 * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */

namespace OCA\Talk\Controller;

use GuzzleHttp\Exception\ClientException;
use OCA\DAV\CalDAV\TimezoneService;
use OCA\Talk\Authenticator;
use OCA\Talk\Capabilities;
use OCA\Talk\Config;
use OCA\Talk\Events\AAttendeeRemovedEvent;
use OCA\Talk\Events\BeforeRoomsFetchEvent;
use OCA\Talk\Events\RoomExtendedEvent;
use OCA\Talk\Exceptions\CannotReachRemoteException;
use OCA\Talk\Exceptions\FederationRestrictionException;
use OCA\Talk\Exceptions\ForbiddenException;
use OCA\Talk\Exceptions\GuestImportException;
use OCA\Talk\Exceptions\InvalidPasswordException;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Exceptions\ParticipantProperty\PermissionsException;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Exceptions\RoomProperty\CreationException;
use OCA\Talk\Exceptions\RoomProperty\DefaultPermissionsException;
use OCA\Talk\Exceptions\RoomProperty\DescriptionException;
use OCA\Talk\Exceptions\RoomProperty\ListableException;
use OCA\Talk\Exceptions\RoomProperty\LobbyException;
use OCA\Talk\Exceptions\RoomProperty\MentionPermissionsException;
use OCA\Talk\Exceptions\RoomProperty\MessageExpirationException;
use OCA\Talk\Exceptions\RoomProperty\NameException;
use OCA\Talk\Exceptions\RoomProperty\PasswordException;
use OCA\Talk\Exceptions\RoomProperty\ReadOnlyException;
use OCA\Talk\Exceptions\RoomProperty\RecordingConsentException;
use OCA\Talk\Exceptions\RoomProperty\SipConfigurationException;
use OCA\Talk\Exceptions\RoomProperty\TypeException;
use OCA\Talk\Exceptions\UnauthorizedException;
use OCA\Talk\Federation\FederationManager;
use OCA\Talk\Federation\Proxy\TalkV1\ProxyRequest;
use OCA\Talk\GuestManager;
use OCA\Talk\Manager;
use OCA\Talk\MatterbridgeManager;
use OCA\Talk\Middleware\Attribute\FederationSupported;
use OCA\Talk\Middleware\Attribute\RequireLoggedInModeratorParticipant;
use OCA\Talk\Middleware\Attribute\RequireLoggedInParticipant;
use OCA\Talk\Middleware\Attribute\RequireModeratorOrNoLobby;
use OCA\Talk\Middleware\Attribute\RequireModeratorParticipant;
use OCA\Talk\Middleware\Attribute\RequireParticipant;
use OCA\Talk\Middleware\Attribute\RequireRoom;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\BreakoutRoom;
use OCA\Talk\Model\Session;
use OCA\Talk\Model\Thread;
use OCA\Talk\Participant;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Room;
use OCA\Talk\RoomAttributes;
use OCA\Talk\RoomPresets\Forced;
use OCA\Talk\RoomPresets\Parameter;
use OCA\Talk\RoomPresets\VoiceRoom;
use OCA\Talk\Service\BanService;
use OCA\Talk\Service\BreakoutRoomService;
use OCA\Talk\Service\ChecksumVerificationService;
use OCA\Talk\Service\ConversationTagService;
use OCA\Talk\Service\InvitationService;
use OCA\Talk\Service\NoteToSelfService;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\PhoneService;
use OCA\Talk\Service\RecordingService;
use OCA\Talk\Service\RoomFormatter;
use OCA\Talk\Service\RoomService;
use OCA\Talk\Service\SessionService;
use OCA\Talk\Service\ThreadService;
use OCA\Talk\Settings\UserPreference;
use OCA\Talk\Share\Helper\Preloader;
use OCA\Talk\TalkSession;
use OCA\Talk\Webinary;
use OCP\App\IAppManager;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\RequestHeader;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Services\IAppConfig;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Calendar\CalendarEventStatus;
use OCP\Calendar\Exceptions\CalendarException;
use OCP\Calendar\ICreateFromString;
use OCP\Calendar\IManager as ICalendarManager;
use OCP\Comments\IComment;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Federation\ICloudIdManager;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IL10N;
use OCP\IPhoneNumberUtil;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Log\Audit\CriticalActionPerformedEvent;
use OCP\Security\Bruteforce\IThrottler;
use OCP\Server;
use OCP\User\Events\UserLiveStatusEvent;
use OCP\UserStatus\IManager as IUserStatusManager;
use OCP\UserStatus\IUserStatus;
use Psr\Log\LoggerInterface;

/**
 * @psalm-import-type TalkCapabilities from ResponseDefinitions
 * @psalm-import-type TalkParticipant from ResponseDefinitions
 * @psalm-import-type TalkInvitationList from ResponseDefinitions
 * @psalm-import-type TalkRoom from ResponseDefinitions
 * @psalm-import-type TalkRoomWithInvalidInvitations from ResponseDefinitions
 */
class RoomController extends AEnvironmentAwareOCSController {
	protected array $commonReadMessages = [];

	public function __construct(
		string $appName,
		IRequest $request,
		private readonly IAppManager $appManager,
		private readonly TalkSession $session,
		private readonly IUserManager $userManager,
		private readonly IGroupManager $groupManager,
		private readonly Manager $manager,
		private readonly RoomService $roomService,
		private readonly BreakoutRoomService $breakoutRoomService,
		private readonly NoteToSelfService $noteToSelfService,
		private readonly InvitationService $invitationService,
		private readonly ParticipantService $participantService,
		private readonly SessionService $sessionService,
		private readonly GuestManager $guestManager,
		private readonly IUserStatusManager $statusManager,
		private readonly ICalendarManager $calendarManager,
		private readonly IEventDispatcher $dispatcher,
		private readonly ITimeFactory $timeFactory,
		private readonly ChecksumVerificationService $checksumVerificationService,
		private readonly RoomFormatter $roomFormatter,
		private readonly Preloader $sharePreloader,
		private readonly IConfig $config,
		private readonly IAppConfig $appConfig,
		private readonly Config $talkConfig,
		private readonly ICloudIdManager $cloudIdManager,
		private readonly IPhoneNumberUtil $phoneNumberUtil,
		private readonly PhoneService $phoneService,
		private readonly IThrottler $throttler,
		private readonly LoggerInterface $logger,
		private readonly Authenticator $federationAuthenticator,
		private readonly Capabilities $capabilities,
		private readonly FederationManager $federationManager,
		private readonly BanService $banService,
		private readonly IURLGenerator $url,
		private readonly IL10N $l,
		private readonly ThreadService $threadService,
		private readonly ConversationTagService $conversationTagService,
		private readonly Forced $forcedParameters,
		private readonly ?string $userId,
	) {
		parent::__construct($appName, $request);
	}

	/**
	 * @return array{X-Nextcloud-Talk-Hash: string}
	 */
	protected function getTalkHashHeader(): array {
		$values = [
			$this->config->getSystemValueString('version'),
			$this->config->getAppValue('spreed', 'installed_version'),
			$this->config->getAppValue('spreed', 'stun_servers'),
			$this->config->getAppValue('spreed', 'turn_servers'),
			$this->config->getAppValue('spreed', 'signaling_servers'),
			$this->config->getAppValue('spreed', 'signaling_mode'),
			$this->config->getAppValue('spreed', 'signaling_ticket_secret'),
			$this->config->getAppValue('spreed', 'signaling_token_alg', 'ES256'),
			$this->config->getAppValue('spreed', 'signaling_token_privkey_' . $this->config->getAppValue('spreed', 'signaling_token_alg', 'ES256')),
			$this->config->getAppValue('spreed', 'signaling_token_pubkey_' . $this->config->getAppValue('spreed', 'signaling_token_alg', 'ES256')),
			$this->config->getAppValue('spreed', 'call_recording'),
			$this->config->getAppValue('spreed', 'recording_servers'),
			$this->config->getAppValue('spreed', 'allowed_groups'),
			$this->config->getAppValue('spreed', 'start_calls'),
			$this->config->getAppValue('spreed', 'start_conversations'),
			$this->config->getAppValue('spreed', 'default_permissions'),
			$this->config->getAppValue('spreed', 'breakout_rooms'),
			$this->config->getAppValue('spreed', 'federation_enabled'),
			$this->config->getAppValue('spreed', 'enable_matterbridge'),
			$this->config->getAppValue('spreed', 'sip_bridge_groups', '[]'),
			$this->config->getAppValue('spreed', 'sip_bridge_dialin_info'),
			$this->config->getAppValue('spreed', 'sip_bridge_shared_secret'),
			$this->config->getAppValue('spreed', 'recording_consent'),
			$this->config->getAppValue('spreed', 'call_recording_transcription'),
			$this->config->getAppValue('spreed', 'call_recording_summary'),
			$this->config->getAppValue('theming', 'cachebuster', '1'),
			$this->config->getUserValue($this->userId, 'theming', 'userCacheBuster', '0'),
			$this->config->getAppValue('spreed', 'federation_incoming_enabled'),
			$this->config->getAppValue('spreed', 'federation_outgoing_enabled'),
			$this->config->getAppValue('spreed', 'federation_only_trusted_servers'),
			$this->config->getAppValue('spreed', 'federation_allowed_groups', '[]'),
			$this->appConfig->getAppValueInt('feature_hints_hidden'),
		];

		if ($this->userId !== null) {
			$values[] = $this->appConfig->getAppValueInt('experiments_users');
			$values[] = $this->config->getUserValue($this->userId, 'spreed', UserPreference::ATTACHMENT_FOLDER);
		} else {
			$values[] = $this->appConfig->getAppValueInt('experiments_guests');
		}

		return [
			'X-Nextcloud-Talk-Hash' => sha1(implode('#', $values)),
		];
	}

	/**
	 * Get all currently existent rooms which the user has joined
	 *
	 * @param 0|1 $noStatusUpdate When the user status should not be automatically set to online set to 1 (default 0)
	 * @param bool $includeStatus Include the user status
	 * @param int $modifiedSince Filter rooms modified after a timestamp
	 * @param bool $includeLastMessage Include the last message, clients should opt-out when only rendering a compact list
	 * @psalm-param non-negative-int $modifiedSince
	 * @return DataResponse<Http::STATUS_OK, list<TalkRoom>, array{X-Nextcloud-Talk-Hash: string, X-Nextcloud-Talk-Modified-Before: numeric-string, X-Nextcloud-Talk-Federation-Invites?: numeric-string}>
	 *
	 * 200: Return list of rooms
	 */
	#[NoAdminRequired]
	#[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/room', requirements: [
		'apiVersion' => '(v4)',
	])]
	public function getRooms(int $noStatusUpdate = 0, bool $includeStatus = false, int $modifiedSince = 0, bool $includeLastMessage = true): DataResponse {
		$nextModifiedSince = $this->timeFactory->getTime();

		$event = new BeforeRoomsFetchEvent($this->userId);
		$this->dispatcher->dispatchTyped($event);
		$user = $this->userManager->get($this->userId);

		if ($noStatusUpdate === 0) {
			$isMobileApp = $this->request->isUserAgent([
				IRequest::USER_AGENT_TALK_ANDROID,
				IRequest::USER_AGENT_TALK_IOS,
			]);

			if ($isMobileApp) {
				// Bump the user status again
				$event = new UserLiveStatusEvent(
					$user,
					IUserStatus::ONLINE,
					$this->timeFactory->getTime()
				);
				$this->dispatcher->dispatchTyped($event);
			}
		}

		$sessionIds = $this->session->getAllActiveSessions();
		$rooms = $this->manager->getRoomsForUser($this->userId, $sessionIds, $includeLastMessage);

		if ($modifiedSince !== 0) {
			$rooms = array_filter($rooms, function (Room $room) use ($includeStatus, $modifiedSince): bool {
				if ($includeStatus && $room->getType() === Room::TYPE_ONE_TO_ONE) {
					// Always include 1-1s to update the user status
					return true;
				}
				if ($room->getCallFlag() !== Participant::FLAG_DISCONNECTED) {
					// Always include active calls
					return true;
				}
				if ($room->getLastActivity() && $room->getLastActivity()->getTimestamp() >= $modifiedSince) {
					// Include rooms which had activity
					return true;
				}

				// Include rooms where only attendee level things changed,
				// e.g. favorite, read-marker update, notification setting
				$participant = $this->participantService->getParticipant($room, $this->userId);
				return $participant->getAttendee()->getLastAttendeeActivity() >= $modifiedSince;
			});
		}

		$readPrivacy = $this->talkConfig->getUserReadPrivacy($this->userId);
		if ($readPrivacy === Participant::PRIVACY_PUBLIC) {
			$roomIds = array_map(static fn (Room $room) => $room->getId(), $rooms);
			$this->commonReadMessages = $this->participantService->getLastCommonReadChatMessageForMultipleRooms($roomIds);
		}

		$statuses = $threads = [];
		if ($includeStatus
			&& $this->appManager->isEnabledForUser('user_status')) {
			$userIds = array_filter(array_map(function (Room $room) {
				if ($room->getType() === Room::TYPE_ONE_TO_ONE) {
					$participants = json_decode($room->getName(), true);
					foreach ($participants as $participant) {
						if ($participant !== $this->userId) {
							return $participant;
						}
					}
				}
				return null;
			}, $rooms));

			$statuses = $this->statusManager->getUserStatuses($userIds);
		}

		if ($includeLastMessage) {
			$sharesInLastMessages = array_filter(array_map(static fn (Room $room): ?IComment => $room->getLastMessage()?->getVerb() === 'object_shared' ? $room->getLastMessage() : null, $rooms));
			$this->sharePreloader->preloadShares($sharesInLastMessages);

			$lastLocalMessages = array_filter(array_map(static fn (Room $room): ?IComment => !$room->isFederatedConversation() ? $room->getLastMessage() : null, $rooms));
			$potentialThreads = array_map(static fn (IComment $lastMessage): int => (int)$lastMessage->getTopmostParentId() ?: (int)$lastMessage->getId(), $lastLocalMessages);

			$threads = $this->threadService->preloadThreadsForConversationList($potentialThreads);
		}

		$return = [];
		foreach ($rooms as $room) {
			try {
				$return[] = $this->formatRoom(
					$room,
					$this->participantService->getParticipant($room, $this->userId),
					$statuses,
					skipLastMessage: !$includeLastMessage,
					thread: $threads[$room->getId()] ?? null,
					isThreadInfoComplete: true,
				);
			} catch (ParticipantNotFoundException) {
				// for example in case the room was deleted concurrently,
				// the user is not a participant anymore
			}
		}

		/** @var array{X-Nextcloud-Talk-Modified-Before: numeric-string, X-Nextcloud-Talk-Federation-Invites?: numeric-string} $headers */
		$headers = ['X-Nextcloud-Talk-Modified-Before' => (string)$nextModifiedSince];
		if ($this->talkConfig->isFederationEnabledForUserId($user)) {
			$numInvites = $this->federationManager->getNumberOfPendingInvitationsForUser($user);
			if ($numInvites !== 0) {
				$headers['X-Nextcloud-Talk-Federation-Invites'] = (string)$numInvites;
			}
		}

		/** @var array{X-Nextcloud-Talk-Hash: string, X-Nextcloud-Talk-Modified-Before: numeric-string, X-Nextcloud-Talk-Federation-Invites?: numeric-string} $headers */
		$headers = array_merge($this->getTalkHashHeader(), $headers);

		return new DataResponse($return, Http::STATUS_OK, $headers);
	}

	/**
	 * Get listed rooms with optional search term
	 *
	 * @param string $searchTerm search term
	 * @return DataResponse<Http::STATUS_OK, list<TalkRoom>, array{}>
	 *
	 * 200: Return list of matching rooms
	 */
	#[NoAdminRequired]
	#[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/listed-room', requirements: [
		'apiVersion' => '(v4)',
	])]
	public function getListedRooms(string $searchTerm = ''): DataResponse {
		$rooms = $this->manager->getListedRoomsForUser($this->userId, $searchTerm);

		$return = [];
		foreach ($rooms as $room) {
			$return[] = $this->formatRoom($room, null, skipLastMessage: true);
		}

		return new DataResponse($return, Http::STATUS_OK);
	}

	/**
	 * Get breakout rooms
	 *
	 * All for moderators and in case of "free selection", or the assigned breakout room for other participants
	 *
	 * @return DataResponse<Http::STATUS_OK, list<TalkRoom>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
	 *
	 * 200: Breakout rooms returned
	 * 400: Getting breakout rooms is not possible
	 */
	#[NoAdminRequired]
	#[BruteForceProtection(action: 'talkRoomToken')]
	#[RequireLoggedInParticipant]
	#[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/room/{token}/breakout-rooms', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function getBreakoutRooms(): DataResponse {
		try {
			$rooms = $this->breakoutRoomService->getBreakoutRooms($this->room, $this->participant);
		} catch (\InvalidArgumentException $e) {
			return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
		}

		$return = [];
		foreach ($rooms as $room) {
			try {
				$participant = $this->participantService->getParticipant($room, $this->userId);
			} catch (ParticipantNotFoundException) {
				$participant = null;
			}

			$return[] = $this->formatRoom($room, $participant, null, false, true, true);
		}

		return new DataResponse($return);
	}

	/**
	 * Get a room
	 *
	 * @param string $token Token of the room
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{X-Nextcloud-Talk-Hash: string}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND, null, array{}>
	 *
	 * 200: Room returned
	 * 401: SIP request invalid
	 * 404: Room not found
	 */
	#[PublicPage]
	#[BruteForceProtection(action: 'talkFederationAccess')]
	#[BruteForceProtection(action: 'talkRoomToken')]
	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
	#[OpenAPI]
	#[OpenAPI(scope: 'backend-sipbridge')]
	#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
	#[RequestHeader(name: 'talk-sipbridge-random', description: 'Random seed (at least 32 bytes) used together with the room token to generate the SHA256-HMAC request checksum', indirect: true)]
	#[RequestHeader(name: 'talk-sipbridge-checksum', description: 'SHA256-HMAC checksum over the concatenation of the random seed and the room token, signed with the shared SIP bridge secret, to verify authenticity from the SIP bridge', indirect: true)]
	#[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/room/{token}', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function getSingleRoom(string $token): DataResponse {
		try {
			$isSIPBridgeRequest = $this->validateSIPBridgeRequest($token);
		} catch (UnauthorizedException) {
			/**
			 * A hack to fix type collision
			 * @var DataResponse<Http::STATUS_UNAUTHORIZED, null, array{}> $response
			 */
			$response = new DataResponse([], Http::STATUS_UNAUTHORIZED);
			$response->throttle(['action' => 'talkSipBridgeSecret']);
			return $response;
		}

		// The SIP bridge only needs room details (public, sip enabled, lobby state, etc)
		$includeLastMessage = !$isSIPBridgeRequest;

		try {
			$action = 'talkRoomToken';
			$participant = null;

			$isTalkFederation = $this->request->getHeader('x-nextcloud-federation');

			if (!$isTalkFederation) {
				if ($this->userId === null && $this->federationAuthenticator->isAuthenticatedEmailGuest()) {
					// Authenticated email guest: the user/public-room check in
					// getRoomForUserByToken() would reject non-public rooms, so
					// trust the session-stored actor id and require the email
					// attendee to still exist.
					$room = $this->manager->getRoomByToken($token);
					try {
						$participant = $this->participantService->getParticipantByActor(
							$room,
							Attendee::ACTOR_EMAILS,
							$this->federationAuthenticator->getActorId(),
						);
					} catch (ParticipantNotFoundException) {
						throw new RoomNotFoundException();
					}
				} else {
					$sessionId = $this->session->getSessionForRoom($token);
					$room = $this->manager->getRoomForUserByToken($token, $this->userId, $sessionId, $includeLastMessage, $isSIPBridgeRequest);

					try {
						$participant = $this->participantService->getParticipant($room, $this->userId, $sessionId);
					} catch (ParticipantNotFoundException) {
						try {
							$participant = $this->participantService->getParticipantBySession($room, $sessionId);
						} catch (ParticipantNotFoundException) {
						}
					}
				}
			} else {
				$action = 'talkFederationAccess';
				try {
					$room = $this->federationAuthenticator->getRoom();
				} catch (RoomNotFoundException) {
					$room = $this->manager->getRoomByRemoteAccess(
						$token,
						Attendee::ACTOR_FEDERATED_USERS,
						$this->federationAuthenticator->getCloudId(),
						$this->federationAuthenticator->getAccessToken(),
					);
				}
				try {
					$participant = $this->federationAuthenticator->getParticipant();
				} catch (ParticipantNotFoundException) {
					$participant = $this->participantService->getParticipantByActor(
						$room,
						Attendee::ACTOR_FEDERATED_USERS,
						$this->federationAuthenticator->getCloudId(),
					);
					$this->federationAuthenticator->authenticated($room, $participant);
				}
			}

			$statuses = [];
			if ($this->userId !== null
				&& $this->appManager->isEnabledForUser('user_status')) {
				$userIds = array_filter(array_map(function (Room $room) {
					if ($room->getType() === Room::TYPE_ONE_TO_ONE) {
						$participants = json_decode($room->getName(), true);
						foreach ($participants as $participant) {
							if ($participant !== $this->userId) {
								return $participant;
							}
						}
					}
					return null;
				}, [$room]));

				$statuses = $this->statusManager->getUserStatuses($userIds);
			}
			return new DataResponse($this->formatRoom($room, $participant, $statuses, $isSIPBridgeRequest), Http::STATUS_OK, $this->getTalkHashHeader());
		} catch (RoomNotFoundException) {
			/**
			 * A hack to fix type collision
			 * @var DataResponse<Http::STATUS_NOT_FOUND, null, array{}> $response
			 */
			$response = new DataResponse([], Http::STATUS_NOT_FOUND);
			$response->throttle(['token' => $token, 'action' => $action]);
			return $response;
		}
	}

	/**
	 * Get the "Note to self" conversation for the user
	 *
	 * It will be automatically created when it is currently missing
	 *
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{X-Nextcloud-Talk-Hash: string}>
	 *
	 * 200: Room returned successfully
	 */
	#[NoAdminRequired]
	#[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/room/note-to-self', requirements: [
		'apiVersion' => '(v4)',
	])]
	public function getNoteToSelfConversation(): DataResponse {
		$room = $this->noteToSelfService->ensureNoteToSelfExistsForUser($this->userId);
		$participant = $this->participantService->getParticipant($room, $this->userId, false);
		return new DataResponse($this->formatRoom($room, $participant), Http::STATUS_OK, $this->getTalkHashHeader());
	}

	/**
	 * Check if the current request is coming from an allowed backend.
	 *
	 * The SIP bridge is sending the custom header "Talk-SIPBridge-Random"
	 * containing at least 32 bytes random data, and the header
	 * "Talk-SIPBridge-Checksum", which is the SHA256-HMAC of the random data
	 * and the body of the request, calculated with the shared secret from the
	 * configuration.
	 *
	 * @param string $token
	 * @return bool True if the request is from the SIP bridge and valid, false if not from SIP bridge
	 * @throws UnauthorizedException when the request tried to sign as SIP bridge but is not valid
	 */
	private function validateSIPBridgeRequest(string $token): bool {
		$random = $this->request->getHeader('talk-sipbridge-random');
		$checksum = $this->request->getHeader('talk-sipbridge-checksum');
		$secret = $this->talkConfig->getSIPSharedSecret();
		return $this->checksumVerificationService->validateRequest($random, $checksum, $secret, $token);
	}

	/**
	 * Check if the current request is coming from the configured external call service
	 *
	 * @param string $owner User ID the external call service wants to act as
	 * @return bool True if the request is from the external call service and valid
	 */
	private function validateExternalCallServiceRequest(string $owner, string $random, string $checksum): bool {
		if ($owner === '' || !$this->talkConfig->isExternalCallServiceConfigured()) {
			return false;
		}

		$secret = $this->talkConfig->getExternalCallServiceSharedSecret();
		try {
			return $this->checksumVerificationService->validateRequest($random, $checksum, $secret, $owner);
		} catch (UnauthorizedException) {
			return false;
		}
	}

	/**
	 * @return TalkRoom
	 */
	protected function formatRoom(
		Room $room,
		?Participant $currentParticipant,
		?array $statuses = null,
		bool $isSIPBridgeRequest = false,
		bool $isListingBreakoutRooms = false,
		bool $skipLastMessage = false,
		?Thread $thread = null,
		bool $isThreadInfoComplete = false,
	): array {
		return $this->roomFormatter->formatRoom(
			$this->getResponseFormat(),
			$this->commonReadMessages,
			$room,
			$currentParticipant,
			$statuses,
			$isSIPBridgeRequest,
			$isListingBreakoutRooms,
			$skipLastMessage,
			$thread,
			$isThreadInfoComplete,
		);
	}

	/**
	 * Create a room with a user, a group or a circle
	 *
	 * With the `conversation-creation-all` capability a lot of new options where
	 * introduced.
	 * Before that only `$roomType`, `$roomName`, `$objectType` and `$objectId`
	 * were supported all the time, and `$password` with the
	 * `conversation-creation-password` capability
	 * In case the `$roomType` is {@see Room::TYPE_ONE_TO_ONE} only the `$invite`
	 * or `$participants` parameter is supported.
	 *
	 * The endpoint can also be used unauthenticated by an external call service
	 * by signing the request with the configured shared secret via the
	 * `x-nextcloud-talk-external-service-random` and
	 * `x-nextcloud-talk-external-service-checksum` headers (SHA256-HMAC of the
	 * random seed and the `$owner` user ID).
	 *
	 * @param int $roomType Type of the room
	 * @psalm-param Room::TYPE_* $roomType
	 * @param string $invite User, group, … ID to invite @deprecated Use the `$participants` array instead
	 * @param string $roomName Name of the room, unless the legacy mode providing `$invite` and `$source` is used, the name must no longer be empty with the `conversation-creation-all` capability (Ignored if `$roomType` is {@see Room::TYPE_ONE_TO_ONE})
	 * @param 'groups'|'circles'|'' $source Source of the invite ID ('circles' to create a room with a circle, etc.) @deprecated Use the `$participants` array instead
	 * @param string $objectType Type of the object (Ignored if `$roomType` is {@see Room::TYPE_ONE_TO_ONE})
	 * @param string $objectId ID of the object (Ignored if `$roomType` is {@see Room::TYPE_ONE_TO_ONE})
	 * @param string $password The room password (only available with `conversation-creation-password` capability) (Ignored if `$roomType` is not {@see Room::TYPE_PUBLIC})
	 * @param 0|1 $readOnly Read only state of the conversation (Default writable) (only available with `conversation-creation-all` capability)
	 * @psalm-param Room::READ_* $readOnly
	 * @param 0|1|2 $listable Scope where the conversation is listable (Default not listable for anyone) (only available with `conversation-creation-all` capability)
	 * @psalm-param Room::LISTABLE_* $listable
	 * @param int $messageExpiration Seconds after which messages will disappear, 0 disables expiration (Default 0) (only available with `conversation-creation-all` capability)
	 * @psalm-param non-negative-int $messageExpiration
	 * @param 0|1 $lobbyState Lobby state of the conversation (Default lobby is disabled) (only available with `conversation-creation-all` capability)
	 * @psalm-param Webinary::LOBBY_* $lobbyState
	 * @param int|null $lobbyTimer Timer when the lobby will be removed (Default null, will not be disabled automatically) (only available with `conversation-creation-all` capability)
	 * @psalm-param non-negative-int|null $lobbyTimer
	 * @param 0|1|2 $sipEnabled Whether SIP dial-in shall be enabled (only available with `conversation-creation-all` capability)
	 * @psalm-param Webinary::SIP_* $sipEnabled
	 * @param int<0, 511> $permissions Default permissions for participants (only available with `conversation-creation-all` capability)
	 * @psalm-param int-mask-of<Attendee::PERMISSIONS_*> $permissions
	 * @param 0|1 $recordingConsent Whether participants need to agree to a recording before joining a call (only available with `conversation-creation-all` capability)
	 * @psalm-param RecordingService::CONSENT_REQUIRED_NO|RecordingService::CONSENT_REQUIRED_YES $recordingConsent
	 * @param 0|1 $mentionPermissions Who can mention at-all in the chat (only available with `conversation-creation-all` capability)
	 * @psalm-param Room::MENTION_PERMISSIONS_* $mentionPermissions
	 * @param string $description Description for the conversation (limited to 2.000 characters) (only available with `conversation-creation-all` capability)
	 * @param ?non-empty-string $emoji Emoji for the avatar of the conversation (only available with `conversation-creation-all` capability)
	 * @param ?non-empty-string $avatarColor Background color of the avatar (Only considered when an emoji was provided) (only available with `conversation-creation-all` capability)
	 * @param array<string, list<string>> $participants List of participants to add grouped by type (only available with `conversation-creation-all` capability)
	 * @psalm-param TalkInvitationList $participants
	 * @param string $owner User ID that will be used as actor and made owner of the conversation. Required when the request is authenticated via the `x-nextcloud-talk-external-service-random` and `x-nextcloud-talk-external-service-checksum` headers, otherwise ignored.
	 * @param ?string $preset Identifier of the preset that was used (only available with `conversation-preset` capability)
	 * @return DataResponse<Http::STATUS_OK|Http::STATUS_CREATED, TalkRoom, array{}>|DataResponse<Http::STATUS_ACCEPTED, TalkRoomWithInvalidInvitations, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array{error: 'avatar'|'description'|'invite'|'listable'|'lobby'|'lobby-timer'|'mention-permissions'|'message-expiration'|'name'|'object'|'object-id'|'object-type'|'owner'|'password'|'permissions'|'preset'|'read-only'|'recording-consent'|'sip-enabled'|'type', message?: string}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, array{error: 'auth'}, array{}>
	 *
	 * 200: Room already existed
	 * 201: Room created successfully
	 * 202: Room created successfully but not all participants could be added
	 * 400: Room type invalid or missing or invalid password
	 * 401: Request not authenticated (missing user session or invalid external call service secret)
	 * 403: Missing permissions to create room
	 * 404: User, group or other target to invite was not found
	 */
	#[PublicPage]
	#[BruteForceProtection(action: 'talkExternalCallServiceSecret')]
	#[RequestHeader(name: 'x-nextcloud-talk-external-service-random', description: 'Random seed (at least 32 bytes) used together with the owner user ID to generate the SHA256-HMAC request checksum', indirect: true)]
	#[RequestHeader(name: 'x-nextcloud-talk-external-service-checksum', description: 'SHA256-HMAC checksum over the concatenation of the random seed and the owner user ID, signed with the shared external call service secret, to verify authenticity from the external call service', indirect: true)]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room', requirements: [
		'apiVersion' => '(v4)',
	])]
	public function createRoom(
		int $roomType = Room::TYPE_GROUP,
		string $invite = '', /* @deprecated */
		string $roomName = '',
		string $source = '', /* @deprecated */
		string $objectType = '',
		string $objectId = '',
		string $password = '',
		int $readOnly = Room::READ_WRITE,
		int $listable = Room::LISTABLE_NONE,
		int $messageExpiration = 0,
		int $lobbyState = Webinary::LOBBY_NONE,
		?int $lobbyTimer = null,
		int $sipEnabled = Webinary::SIP_DISABLED,
		int $permissions = Attendee::PERMISSIONS_DEFAULT,
		int $recordingConsent = RecordingService::CONSENT_REQUIRED_NO,
		int $mentionPermissions = Room::MENTION_PERMISSIONS_EVERYONE,
		string $description = '',
		?string $emoji = null,
		?string $avatarColor = null,
		array $participants = [],
		string $owner = '',
		?string $preset = null,
	): DataResponse {
		$externalServiceRandom = $this->request->getHeader('x-nextcloud-talk-external-service-random');
		$externalServiceChecksum = $this->request->getHeader('x-nextcloud-talk-external-service-checksum');
		if ($externalServiceRandom !== '' || $externalServiceChecksum !== '') {
			if ($owner === '') {
				return new DataResponse(['error' => 'owner'], Http::STATUS_BAD_REQUEST);
			}

			if (!$this->validateExternalCallServiceRequest($owner, $externalServiceRandom, $externalServiceChecksum)) {
				$response = new DataResponse(['error' => 'auth'], Http::STATUS_UNAUTHORIZED);
				$response->throttle(['action' => 'talkExternalCallServiceSecret']);
				return $response;
			}

			$actorUserId = $owner;

			if (!in_array($roomType, [
				Room::TYPE_GROUP,
				Room::TYPE_PUBLIC,
			], true)) {
				return new DataResponse(['error' => CreationException::REASON_OBJECT_TYPE], Http::STATUS_BAD_REQUEST);
			}
			$allowInternalTypes = true;
		} else {
			if ($this->userId === null) {
				return new DataResponse(['error' => 'auth'], Http::STATUS_UNAUTHORIZED);
			}

			$actorUserId = $this->userId;
			$allowInternalTypes = false;
		}

		$user = $this->userManager->get($actorUserId);
		if (!$user instanceof IUser) {
			return new DataResponse(['error' => 'owner'], Http::STATUS_NOT_FOUND);
		}

		if ($roomType === Room::TYPE_ONE_TO_ONE) {
			if ($invite === ''
				&& isset($participants['users'][0])
				&& is_string($participants['users'][0])) {
				$invite = $participants['users'][0];
			}

			return $this->createOneToOneRoom($invite);
		}

		/** @var ?Room $oldRoom */
		$oldRoom = null;
		if ($objectType === Room::OBJECT_TYPE_EXTENDED_CONVERSATION && $objectId !== '') {
			try {
				$oldRoom = $this->manager->getRoomForUserByToken($objectId, $actorUserId);
				// Don't allow to extend unjoined public conversations
				$this->participantService->getParticipant($oldRoom, $actorUserId, false);
			} catch (RoomNotFoundException|ParticipantNotFoundException) {
				return new DataResponse(['error' => CreationException::REASON_OBJECT], Http::STATUS_BAD_REQUEST);
			}

			if ($oldRoom->getType() !== Room::TYPE_ONE_TO_ONE) {
				// If we ever allow more types, only moderators should be able to perform the action
				return new DataResponse(['error' => CreationException::REASON_OBJECT], Http::STATUS_BAD_REQUEST);
			}
		}

		if ($this->talkConfig->isNotAllowedToCreateConversations($user)) {
			return new DataResponse(['error' => 'permissions'], Http::STATUS_FORBIDDEN);
		}

		if ($invite !== '') {
			// Legacy fallback for creating a conversation directly with a group or team
			if ($source === 'circles') {
				$sourceV2 = 'teams';
			} else {
				$sourceV2 = 'groups';
			}
			if (!isset($participants[$sourceV2])) {
				$participants[$sourceV2] = [];
			}
			$participants[$sourceV2][] = $invite;
			$participants[$sourceV2] = array_values(array_unique($participants[$sourceV2]));
		}

		if ($roomName === '') {
			// Legacy fallback for creating a conversation without a name
			$roomName = $this->roomService->prepareConversationName($invite ?: '---');
			if ($source === 'circles') {
				try {
					$circle = $this->participantService->getCircle($invite, $actorUserId);
					$roomName = $this->roomService->prepareConversationName($circle->getName());
				} catch (\Exception) {
				}
			} else {
				$targetGroup = $this->groupManager->get($invite);
				if ($targetGroup instanceof IGroup) {
					$roomName = $this->roomService->prepareConversationName($targetGroup->getDisplayName());
				}
			}
		}

		if ($roomType !== Room::TYPE_PUBLIC) {
			// Force empty password for non-public conversations
			$password = '';
		}

		$invitationList = $this->invitationService->validateInvitations($participants, $user);
		if ($invitationList->hasInvalidInvitations() && !$invitationList->hasValidInvitations()) {
			// FIXME add the list of failed invitations?
			return new DataResponse(['error' => 'invite'], Http::STATUS_NOT_FOUND);
		}

		if (in_array($objectType, [
			Room::OBJECT_TYPE_PHONE_PERSIST,
			Room::OBJECT_TYPE_PHONE_TEMPORARY,
			Room::OBJECT_TYPE_PHONE_LEGACY,
		], true)) {
			$objectId = Room::OBJECT_ID_PHONE_OUTGOING;
		}

		$attributes = RoomAttributes::NONE->value;
		if ($preset === VoiceRoom::getIdentifier()) {
			if ($this->appConfig->getAppValueInt('start_calls', Room::START_CALL_EVERYONE) === Room::START_CALL_NOONE) {
				return new DataResponse(['error' => 'preset'], Http::STATUS_NOT_FOUND);
			}
			$attributes |= RoomAttributes::VOICE_ROOM->value;
		}

		try {
			$room = $this->roomService->createConversation(
				$roomType,
				$roomName,
				$user,
				$objectType,
				$objectId,
				$password,
				$readOnly,
				$this->forcedParameters->forceParameter(Parameter::LISTABLE, $listable),
				$this->forcedParameters->forceParameter(Parameter::MESSAGE_EXPIRATION, $messageExpiration),
				$this->forcedParameters->forceParameter(Parameter::LOBBY_STATE, $lobbyState),
				$lobbyTimer,
				$this->forcedParameters->forceParameter(Parameter::SIP_ENABLED, $sipEnabled),
				$this->forcedParameters->forceParameter(Parameter::PERMISSIONS, $permissions),
				$recordingConsent,
				$this->forcedParameters->forceParameter(Parameter::MENTION_PERMISSIONS, $mentionPermissions),
				$description,
				$emoji,
				$avatarColor,
				attributes: $attributes,
				allowInternalTypes: $allowInternalTypes,
			);
		} catch (CreationException $e) {
			$room = $e->getRoom();
			if ($room instanceof Room) {
				return new DataResponse($this->formatRoom($room, $this->participantService->getParticipant($room, $actorUserId, false)), Http::STATUS_OK);
			}
			return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
		} catch (PasswordException $e) {
			return new DataResponse(['error' => 'password', 'message' => $e->getHint()], Http::STATUS_BAD_REQUEST);
		}

		if ($invitationList->hasValidInvitations()) {
			$this->participantService->addInvitationList($room, $invitationList, $user);
		}

		if ($objectType === Room::OBJECT_TYPE_EXTENDED_CONVERSATION) {
			$event = new RoomExtendedEvent($oldRoom, $room);
			$this->dispatcher->dispatchTyped($event);
		}

		if (!$invitationList->hasInvalidInvitations()) {
			return new DataResponse($this->formatRoom($room, $this->participantService->getParticipant($room, $actorUserId, false)), Http::STATUS_CREATED);
		}

		$data = $this->formatRoom($room, $this->participantService->getParticipant($room, $actorUserId, false));
		$data['invalidParticipants'] = $invitationList->getInvalidList();

		return new DataResponse($data, Http::STATUS_ACCEPTED);
	}

	/**
	 * Initiates a one-to-one video call from the current user to the recipient
	 *
	 * @param string $targetUserId ID of the user
	 * @return DataResponse<Http::STATUS_OK|Http::STATUS_CREATED, TalkRoom, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array{error: 'invite'}, array{}>
	 */
	#[NoAdminRequired]
	protected function createOneToOneRoom(string $targetUserId): DataResponse {
		$currentUser = $this->userManager->get($this->userId);
		if (!$currentUser instanceof IUser) {
			// Should never happen, basically an internal server error so we reuse another error
			return new DataResponse(['error' => 'invite'], Http::STATUS_NOT_FOUND);
		}

		if ($targetUserId === MatterbridgeManager::BRIDGE_BOT_USERID) {
			return new DataResponse(['error' => 'invite'], Http::STATUS_NOT_FOUND);
		}

		$targetUser = $this->userManager->get($targetUserId);
		if (!$targetUser instanceof IUser) {
			return new DataResponse(['error' => 'invite'], Http::STATUS_NOT_FOUND);
		}

		try {
			// We are only doing this manually here to be able to return different status codes
			// Actually createOneToOneConversation also checks it.
			$room = $this->manager->getOne2OneRoom($currentUser->getUID(), $targetUser->getUID());
			$this->participantService->ensureOneToOneRoomIsFilled($room, $currentUser->getUID());
			return new DataResponse(
				$this->formatRoom($room, $this->participantService->getParticipant($room, $currentUser->getUID(), false)),
				Http::STATUS_OK
			);
		} catch (RoomNotFoundException) {
		}

		try {
			$room = $this->roomService->createOneToOneConversation($currentUser, $targetUser);
			return new DataResponse(
				$this->formatRoom($room, $this->participantService->getParticipant($room, $currentUser->getUID(), false)),
				Http::STATUS_CREATED
			);
		} catch (\InvalidArgumentException) {
			// Same current and target user
			return new DataResponse(['error' => 'invite'], Http::STATUS_FORBIDDEN);
		} catch (RoomNotFoundException) {
			return new DataResponse(['error' => 'invite'], Http::STATUS_FORBIDDEN);
		}
	}

	/**
	 * Add a room to the favorites
	 *
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>
	 *
	 * 200: Successfully added room to favorites
	 */
	#[FederationSupported]
	#[NoAdminRequired]
	#[RequireLoggedInParticipant]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/favorite', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function addToFavorites(): DataResponse {
		$this->participantService->updateFavoriteStatus($this->participant, true);
		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Remove a room from the favorites
	 *
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>
	 *
	 * 200: Successfully removed room from favorites
	 */
	#[FederationSupported]
	#[NoAdminRequired]
	#[RequireLoggedInParticipant]
	#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/room/{token}/favorite', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function removeFromFavorites(): DataResponse {
		$this->participantService->updateFavoriteStatus($this->participant, false);
		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Update the notification level for a room
	 *
	 * @param int $level New level
	 * @psalm-param Participant::NOTIFY_* $level
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'level'}, array{}>
	 *
	 * 200: Notification level updated successfully
	 * 400: Updating notification level is not possible
	 */
	#[FederationSupported]
	#[NoAdminRequired]
	#[RequireLoggedInParticipant]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/notify', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function setNotificationLevel(int $level): DataResponse {
		try {
			$this->participantService->updateNotificationLevel($this->participant, $level);
		} catch (\InvalidArgumentException) {
			return new DataResponse(['error' => 'level'], Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Update call notifications
	 *
	 * @param int $level New level
	 * @psalm-param Participant::NOTIFY_CALLS_* $level
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'level'}, array{}>
	 *
	 * 200: Call notification level updated successfully
	 * 400: Updating call notification level is not possible
	 */
	#[FederationSupported]
	#[NoAdminRequired]
	#[RequireLoggedInParticipant]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/notify-calls', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function setNotificationCalls(int $level): DataResponse {
		try {
			$this->participantService->updateNotificationCalls($this->participant, $level);
		} catch (\InvalidArgumentException) {
			return new DataResponse(['error' => 'level'], Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Rename a room
	 *
	 * @param string $roomName New name
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'event'|'type'|'value'}, array{}>
	 *
	 * 200: Room renamed successfully
	 * 400: Renaming room is not possible
	 */
	#[PublicPage]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/room/{token}', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function renameRoom(string $roomName): DataResponse {
		if ($this->room->getObjectType() === Room::OBJECT_TYPE_EVENT) {
			return new DataResponse(['error' => Room::OBJECT_TYPE_EVENT], Http::STATUS_BAD_REQUEST);
		}

		try {
			$this->roomService->setName($this->room, $roomName, validateType: true);
		} catch (NameException $e) {
			return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
		}
		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Update the description of a room
	 *
	 * @param string $description New description for the conversation (limited to 2.000 characters, was 500 before Talk 21)
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'event'|'type'|'value'}, array{}>
	 *
	 * 200: Description updated successfully
	 * 400: Updating description is not possible
	 */
	#[PublicPage]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/room/{token}/description', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function setDescription(string $description): DataResponse {
		if ($this->room->getObjectType() === Room::OBJECT_TYPE_EVENT) {
			return new DataResponse(['error' => Room::OBJECT_TYPE_EVENT], Http::STATUS_BAD_REQUEST);
		}

		try {
			$this->roomService->setDescription($this->room, $description);
		} catch (DescriptionException $e) {
			return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Delete a room
	 *
	 * @return DataResponse<Http::STATUS_OK|Http::STATUS_BAD_REQUEST, null, array{}>
	 *
	 * 200: Room successfully deleted
	 * 400: Deleting room is not possible
	 */
	#[PublicPage]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/room/{token}', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function deleteRoom(): DataResponse {
		if (!$this->appConfig->getAppValueBool('delete_one_to_one_conversations')
			&& in_array($this->room->getType(), [Room::TYPE_ONE_TO_ONE, Room::TYPE_ONE_TO_ONE_FORMER], true)) {
			return new DataResponse(null, Http::STATUS_BAD_REQUEST);
		}

		$this->roomService->deleteRoom($this->room);

		return new DataResponse(null);
	}

	/**
	 * Unbind a room from its object to prevent automatic retention
	 *
	 * Required capability: `unbind-conversation`
	 *
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'object-type'}, array{}>
	 *
	 * 200: Room successfully unbound
	 * 400: Unbinding room is not possible
	 */
	#[PublicPage]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/room/{token}/object', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function unbindRoomFromObject(): DataResponse {
		if ($this->room->getObjectType() === Room::OBJECT_TYPE_EVENT || $this->room->getObjectType() === Room::OBJECT_TYPE_INSTANT_MEETING) {
			$this->roomService->resetObject($this->room);
		} elseif ($this->room->getObjectType() === Room::OBJECT_TYPE_PHONE_TEMPORARY) {
			$this->roomService->setObject($this->room, Room::OBJECT_TYPE_PHONE_PERSIST, $this->room->getObjectId());
		} else {
			return new DataResponse(['error' => 'object-type'], Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 *
	 * Get a list of participants for a room
	 *
	 * @param bool $includeStatus Include the user statuses
	 * @return DataResponse<Http::STATUS_OK, list<TalkParticipant>, array{X-Nextcloud-Has-User-Statuses?: bool}>|DataResponse<Http::STATUS_FORBIDDEN, null, array{}>
	 *
	 * 200: Participants returned
	 * 403: Missing permissions for getting participants
	 */
	#[FederationSupported]
	#[PublicPage]
	#[RequireModeratorOrNoLobby]
	#[RequireParticipant]
	#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
	#[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/room/{token}/participants', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function getParticipants(bool $includeStatus = false): DataResponse {
		if ($this->room->isFederatedConversation()) {
			/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\RoomController $proxy */
			$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\RoomController::class);
			$response = $proxy->getParticipants($this->room, $this->participant);
			$data = $response->getData();

			if ($this->userId !== null
				&& $includeStatus
				&& count($data) < Config::USER_STATUS_INTEGRATION_LIMIT
				&& $this->appManager->isEnabledForUser('user_status')) {
				$userIds = array_filter(array_map(static function (array $parsedParticipant): ?string {
					if ($parsedParticipant['actorType'] === Attendee::ACTOR_USERS) {
						return $parsedParticipant['actorId'];
					}
					return null;
				}, $data));

				$statuses = $this->statusManager->getUserStatuses($userIds);
				$data = array_map(static function (array $parsedParticipant) use ($statuses): array {
					if ($parsedParticipant['actorType'] === Attendee::ACTOR_USERS
						&& isset($statuses[$parsedParticipant['actorId']])) {
						$userId = $parsedParticipant['actorId'];
						if (isset($statuses[$userId])) {
							$parsedParticipant['status'] = $statuses[$userId]->getStatus();
							$parsedParticipant['statusIcon'] = $statuses[$userId]->getIcon();
							$parsedParticipant['statusMessage'] = $statuses[$userId]->getMessage();
							$parsedParticipant['statusClearAt'] = $statuses[$userId]->getClearAt()?->getTimestamp();
						} else {
							$parsedParticipant['status'] = IUserStatus::OFFLINE;
							$parsedParticipant['statusIcon'] = null;
							$parsedParticipant['statusMessage'] = null;
							$parsedParticipant['statusClearAt'] = null;
						}
					}
					return $parsedParticipant;
				}, $data);
			}

			$response->setData($data);
			return $response;
		}

		if ($this->participant->getAttendee()->getParticipantType() === Participant::GUEST) {
			return new DataResponse(null, Http::STATUS_FORBIDDEN);
		}

		$participants = $this->participantService->getSessionsAndParticipantsForRoom($this->room);

		return $this->formatParticipantList($participants, $includeStatus);
	}

	/**
	 * Get the breakout room participants for a room
	 *
	 * @param bool $includeStatus Include the user statuses
	 * @return DataResponse<Http::STATUS_OK, list<TalkParticipant>, array{X-Nextcloud-Has-User-Statuses?: bool}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, null, array{}>
	 *
	 * 200: Breakout room participants returned
	 * 400: Getting breakout room participants is not possible
	 * 403: Missing permissions to get breakout room participants
	 */
	#[PublicPage]
	#[RequireModeratorOrNoLobby]
	#[RequireParticipant]
	#[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/room/{token}/breakout-rooms/participants', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function getBreakoutRoomParticipants(bool $includeStatus = false): DataResponse {
		if ($this->participant->getAttendee()->getParticipantType() === Participant::GUEST) {
			return new DataResponse(null, Http::STATUS_FORBIDDEN);
		}

		try {
			$breakoutRooms = $this->breakoutRoomService->getBreakoutRooms($this->room, $this->participant);
		} catch (\InvalidArgumentException $e) {
			return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
		}

		$breakoutRooms[] = $this->room;
		$participants = $this->participantService->getSessionsAndParticipantsForRooms($breakoutRooms);

		return $this->formatParticipantList($participants, $includeStatus);
	}

	/**
	 * @param Participant[] $participants
	 * @param bool $includeStatus
	 * @return DataResponse<Http::STATUS_OK, list<TalkParticipant>, array{X-Nextcloud-Has-User-Statuses?: true}>
	 */
	protected function formatParticipantList(array $participants, bool $includeStatus): DataResponse {
		$results = $headers = $statuses = [];
		$maxPingAge = $this->timeFactory->getTime() - Session::SESSION_TIMEOUT_KILL;

		if ($this->userId !== null
			&& $includeStatus
			&& count($participants) < Config::USER_STATUS_INTEGRATION_LIMIT
			&& $this->appManager->isEnabledForUser('user_status')) {
			$userIds = array_filter(array_map(static function (Participant $participant): ?string {
				if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
					return $participant->getAttendee()->getActorId();
				}
				return null;
			}, $participants));

			$statuses = $this->statusManager->getUserStatuses($userIds);

			$headers['X-Nextcloud-Has-User-Statuses'] = true;
		}

		$currentUser = null;
		if ($this->userId !== null) {
			$currentUser = $this->userManager->get($this->userId);
		}

		$cleanGuests = false;
		foreach ($participants as $participant) {
			$attendeeId = $participant->getAttendee()->getId();
			if (isset($results[$attendeeId])) {
				$session = $participant->getSession();
				if (!$session instanceof Session) {
					// If the user has an entry already and this has no session we don't need it anymore.
					continue;
				}

				if ($session->getLastPing() <= $maxPingAge) {
					if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_GUESTS) {
						$cleanGuests = true;
					} elseif ($participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS
						|| $participant->getAttendee()->getActorType() === Attendee::ACTOR_EMAILS
						|| $participant->getAttendee()->getActorType() === Attendee::ACTOR_FEDERATED_USERS) {
						$this->participantService->leaveRoomAsSession($this->room, $participant);
					}
					// Session expired, ignore
					continue;
				}

				// Combine the session values: All inCall bit flags, newest lastPing and any sessionId (for online checking)
				$results[$attendeeId]['inCall'] |= $session->getInCall();
				$results[$attendeeId]['lastPing'] = max($results[$attendeeId]['lastPing'], $session->getLastPing());
				$results[$attendeeId]['sessionIds'][] = $session->getSessionId();
				continue;
			}

			$result = [
				'roomToken' => $participant->getRoom()->getToken(),
				'inCall' => Participant::FLAG_DISCONNECTED,
				'lastPing' => 0,
				'sessionIds' => [],
				'participantType' => $participant->getAttendee()->getParticipantType(),
				'attendeeId' => $attendeeId,
				'actorId' => $participant->getAttendee()->getActorId(),
				'actorType' => $participant->getAttendee()->getActorType(),
				'displayName' => $participant->getAttendee()->getActorId(),
				'permissions' => $participant->getPermissions(),
				'attendeePermissions' => $participant->getAttendee()->getPermissions(),
				'attendeePin' => '',
				'phoneNumber' => '',
				'callId' => '',
			];
			if ($this->talkConfig->isSIPConfigured()
				&& $this->room->getSIPEnabled() !== Webinary::SIP_DISABLED
				&& ($this->participant->hasModeratorPermissions(false)
					|| $this->participant->getAttendee()->getId() === $participant->getAttendee()->getId())) {
				// Generate a PIN if the attendee is a user and doesn't have one.
				$this->participantService->generatePinForParticipant($this->room, $participant);

				$result['attendeePin'] = (string)$participant->getAttendee()->getPin();
			}

			if ($participant->getSession() instanceof Session) {
				$result['inCall'] = $participant->getSession()->getInCall();
				$result['lastPing'] = $participant->getSession()->getLastPing();
				$result['sessionIds'] = [$participant->getSession()->getSessionId()];
			}

			if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
				$userId = $participant->getAttendee()->getActorId();
				if ($participant->getSession() instanceof Session && $participant->getSession()->getLastPing() <= $maxPingAge) {
					$this->participantService->leaveRoomAsSession($this->room, $participant);
				}

				$result['displayName'] = $participant->getAttendee()->getDisplayName();
				if (!$result['displayName']) {
					$userDisplayName = $this->userManager->getDisplayName($userId);
					if ($userDisplayName === null) {
						continue;
					}
					$result['displayName'] = $userDisplayName;
				}

				if (isset($statuses[$userId])) {
					$result['status'] = $statuses[$userId]->getStatus();
					$result['statusIcon'] = $statuses[$userId]->getIcon();
					$result['statusMessage'] = $statuses[$userId]->getMessage();
					$result['statusClearAt'] = $statuses[$userId]->getClearAt()?->getTimestamp();
				} elseif (isset($headers['X-Nextcloud-Has-User-Statuses'])) {
					$result['status'] = IUserStatus::OFFLINE;
					$result['statusIcon'] = null;
					$result['statusMessage'] = null;
					$result['statusClearAt'] = null;
				}
			} elseif ($participant->getAttendee()->getActorType() === Attendee::ACTOR_GUESTS) {
				if ($participant->getAttendee()->getParticipantType() === Participant::GUEST
					&& ($participant->getAttendee()->getPermissions() === Attendee::PERMISSIONS_DEFAULT
						|| $participant->getAttendee()->getPermissions() === Attendee::PERMISSIONS_CUSTOM)) {
					// Guests without an up-to-date session are filtered out. We
					// only keep there attendees in the database, so that the
					// comments show the display name. Only when they have
					// non-default permissions we show them, so permissions can
					// be reset or removed
					if ($result['lastPing'] <= $maxPingAge) {
						$cleanGuests = true;
						continue;
					}
				}

				$result['displayName'] = $participant->getAttendee()->getDisplayName();
			} elseif ($participant->getAttendee()->getActorType() === Attendee::ACTOR_GROUPS) {
				$result['displayName'] = $participant->getAttendee()->getDisplayName();
			} elseif ($participant->getAttendee()->getActorType() === Attendee::ACTOR_CIRCLES) {
				$result['displayName'] = $participant->getAttendee()->getDisplayName();
			} elseif ($participant->getAttendee()->getActorType() === Attendee::ACTOR_EMAILS) {
				if ($participant->getSession() instanceof Session && $participant->getSession()->getLastPing() <= $maxPingAge) {
					$this->participantService->leaveRoomAsSession($this->room, $participant);
				}
				$result['displayName'] = $participant->getAttendee()->getDisplayName();
				if ($this->participant->hasModeratorPermissions() || $this->participant->getAttendee()->getId() === $participant->getAttendee()->getId()) {
					$result['invitedActorId'] = $participant->getAttendee()->getInvitedCloudId();
				}
			} elseif ($participant->getAttendee()->getActorType() === Attendee::ACTOR_FEDERATED_USERS) {
				if ($participant->getSession() instanceof Session && $participant->getSession()->getLastPing() <= $maxPingAge) {
					$this->participantService->leaveRoomAsSession($this->room, $participant);
				}
				$result['displayName'] = $participant->getAttendee()->getDisplayName();
			} elseif ($participant->getAttendee()->getActorType() === Attendee::ACTOR_PHONES) {
				$result['displayName'] = $participant->getAttendee()->getDisplayName();
				if ($this->talkConfig->isSIPConfigured()
					&& $this->participant->hasModeratorPermissions(false)) {
					$result['phoneNumber'] = $participant->getAttendee()->getPhoneNumber();

					if ($currentUser instanceof IUser && $this->talkConfig->canUserDialOutSIP($currentUser)) {
						$result['callId'] = $participant->getAttendee()->getCallId();
					}
				}
			}

			$results[$attendeeId] = $result;
		}

		if ($cleanGuests) {
			$this->participantService->cleanGuestParticipants($this->room);
		}

		return new DataResponse(array_values($results), Http::STATUS_OK, $headers);
	}

	/**
	 * Add a participant to a room
	 *
	 * @param string $newParticipant New participant
	 * @param 'users'|'groups'|'circles'|'emails'|'federated_users'|'phones'|'teams' $source Source of the participant
	 * @return DataResponse<Http::STATUS_OK, array{type?: int}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_NOT_IMPLEMENTED, array{error: 'ban'|'cloud-id'|'federation'|'moderator'|'new-participant'|'outgoing'|'reach-remote'|'room-type'|'sip'|'source'|'trusted-servers'}, array{}>
	 *
	 * 200: Participant successfully added
	 * 400: Adding participant is not possible, e.g. when the user is banned (check error attribute of response for detail key)
	 * 404: User, group or other target to invite was not found
	 * 501: SIP dial-out is not configured
	 */
	#[NoAdminRequired]
	#[RequireLoggedInModeratorParticipant]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/participants', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function addParticipantToRoom(string $newParticipant, string $source = 'users'): DataResponse {
		if ($this->room->getType() === Room::TYPE_ONE_TO_ONE
			|| $this->room->getType() === Room::TYPE_ONE_TO_ONE_FORMER
			|| $this->room->getType() === Room::TYPE_NOTE_TO_SELF
			|| $this->room->getObjectType() === Room::OBJECT_TYPE_VIDEO_VERIFICATION) {
			return new DataResponse(['error' => 'room-type'], Http::STATUS_BAD_REQUEST);
		}

		if ($source !== 'users' && $this->room->getObjectType() === BreakoutRoom::PARENT_OBJECT_TYPE) {
			// Can only add users to breakout rooms
			return new DataResponse(['error' => 'source'], Http::STATUS_BAD_REQUEST);
		}

		$participants = $this->participantService->getParticipantsForRoom($this->room);
		$participantsByUserId = [];
		$remoteParticipantsByFederatedId = [];
		foreach ($participants as $participant) {
			if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
				$participantsByUserId[$participant->getAttendee()->getActorId()] = $participant;
			} elseif ($participant->getAttendee()->getActorType() === Attendee::ACTOR_FEDERATED_USERS) {
				$remoteParticipantsByFederatedId[$participant->getAttendee()->getActorId()] = $participant;
			}
		}

		/** @var IUser $addedBy */
		$addedBy = $this->userManager->get($this->userId);

		// list of participants to attempt adding,
		// existing ones will be filtered later below
		$participantsToAdd = [];

		if ($source === 'users') {
			if ($newParticipant === MatterbridgeManager::BRIDGE_BOT_USERID) {
				return new DataResponse(['error' => 'new-participant'], Http::STATUS_NOT_FOUND);
			}

			$newUser = $this->userManager->get($newParticipant);
			if (!$newUser instanceof IUser) {
				return new DataResponse(['error' => 'new-participant'], Http::STATUS_NOT_FOUND);
			}

			//Check if the user is banned
			if ($this->banService->isActorBanned($this->room, Attendee::ACTOR_USERS, $newUser->getUID())) {
				return new DataResponse(['error' => 'ban'], Http::STATUS_BAD_REQUEST);
			}

			$participantsToAdd[] = [
				'actorType' => Attendee::ACTOR_USERS,
				'actorId' => $newUser->getUID(),
				'displayName' => $newUser->getDisplayName(),
			];
		} elseif ($source === 'groups') {
			$group = $this->groupManager->get($newParticipant);
			if (!$group instanceof IGroup) {
				return new DataResponse(['error' => 'new-participant'], Http::STATUS_NOT_FOUND);
			}

			$this->participantService->addGroup($this->room, $group, $participants);
		} elseif ($source === 'circles' || $source === 'teams') {
			if (!$this->appManager->isEnabledForUser('circles')) {
				return new DataResponse(['error' => 'new-participant'], Http::STATUS_BAD_REQUEST);
			}

			try {
				$circle = $this->participantService->getCircle($newParticipant, $this->userId);
			} catch (\Exception) {
				return new DataResponse(['error' => 'new-participant'], Http::STATUS_NOT_FOUND);
			}

			$this->participantService->addCircle($this->room, $circle, $participants);
		} elseif ($source === 'emails') {
			$email = strtolower($newParticipant);
			$actorId = hash('sha256', $email);
			try {
				$this->participantService->getParticipantByActor($this->room, Attendee::ACTOR_EMAILS, $actorId);
			} catch (ParticipantNotFoundException) {
				$participant = $this->participantService->inviteEmailAddress($this->room, $actorId, $email);
				$this->guestManager->sendEmailInvitation($this->room, $participant);
			}

			return new DataResponse([]);
		} elseif ($source === 'federated_users') {
			if (!$this->talkConfig->isFederationEnabled()) {
				return new DataResponse(['error' => 'federation'], Http::STATUS_NOT_IMPLEMENTED);
			}
			try {
				$newUser = $this->cloudIdManager->resolveCloudId($newParticipant);
			} catch (\InvalidArgumentException $e) {
				$this->logger->error($e->getMessage(), [
					'exception' => $e,
				]);
				return new DataResponse(['error' => 'cloud-id'], Http::STATUS_BAD_REQUEST);
			}
			try {
				$this->federationManager->isAllowedToInvite($addedBy, $newUser);
			} catch (FederationRestrictionException $e) {
				return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
			}

			$participantsToAdd[] = [
				'actorType' => Attendee::ACTOR_FEDERATED_USERS,
				'actorId' => $newUser->getId(),
				'displayName' => $newUser->getDisplayId(),
			];
		} elseif ($source === 'phones') {
			if (
				!$addedBy instanceof IUser
				|| !$this->talkConfig->isSIPConfigured()
				|| !$this->talkConfig->canUserDialOutSIP($addedBy)
				|| preg_match(Room::SIP_INCOMPATIBLE_REGEX, $this->room->getToken())
				|| ($this->room->getType() !== Room::TYPE_GROUP && $this->room->getType() !== Room::TYPE_PUBLIC)) {
				return new DataResponse(['error' => 'sip'], Http::STATUS_NOT_IMPLEMENTED);
			}

			$phoneRegion = $this->config->getSystemValueString('default_phone_region');
			if ($phoneRegion === '') {
				$phoneRegion = null;
			}

			$formattedNumber = $this->phoneNumberUtil->convertToStandardFormat($newParticipant, $phoneRegion);
			if ($formattedNumber === null) {
				return new DataResponse([], Http::STATUS_BAD_REQUEST);
			}

			$participantsToAdd[] = [
				'actorType' => Attendee::ACTOR_PHONES,
				'actorId' => sha1($formattedNumber . '#' . $this->timeFactory->getTime()),
				'displayName' => substr($formattedNumber, 0, -4) . '…', // FIXME Allow the UI to hand in a name (when selected from contacts?)
				'phoneNumber' => $formattedNumber,
			];
		} else {
			$this->logger->error('Trying to add participant from unsupported source ' . $source);
			return new DataResponse(['error' => 'source'], Http::STATUS_BAD_REQUEST);
		}

		// attempt adding the listed users to the room
		// existing users with USER_SELF_JOINED will get converted to regular USER participants
		foreach ($participantsToAdd as $index => $participantToAdd) {
			$existingParticipant = $participantsByUserId[$participantToAdd['actorId']] ?? null;
			if ($participantToAdd['actorType'] === Attendee::ACTOR_FEDERATED_USERS) {
				$existingParticipant = $remoteParticipantsByFederatedId[$participantToAdd['actorId']] ?? null;
			}

			if ($existingParticipant !== null) {
				unset($participantsToAdd[$index]);
				if ($existingParticipant->getAttendee()->getParticipantType() !== Participant::USER_SELF_JOINED) {
					// user is already a regular participant, skip
					continue;
				}
				$this->participantService->updateParticipantType($this->room, $existingParticipant, Participant::USER);
			}
		}

		if ($source === 'users' && $this->room->getObjectType() === BreakoutRoom::PARENT_OBJECT_TYPE) {
			$parentRoom = $this->manager->getRoomByToken($this->room->getObjectId());

			// Also add to parent room in case the user is missing
			try {
				$this->participantService->getParticipantByActor(
					$parentRoom,
					Attendee::ACTOR_USERS,
					$newParticipant
				);
			} catch (ParticipantNotFoundException $e) {
				$this->participantService->addUsers($parentRoom, $participantsToAdd, $addedBy);
			}

			// Remove from previous breakout room in case the user is moved
			try {
				$this->breakoutRoomService->removeAttendeeFromBreakoutRoom($parentRoom, Attendee::ACTOR_USERS, $newParticipant);
			} catch (\InvalidArgumentException $e) {
				/** @var 'moderator' $error */
				$error = $e->getMessage();
				return new DataResponse(['error' => $error], Http::STATUS_BAD_REQUEST);
			}
		}

		// add the remaining users in batch
		try {
			$this->participantService->addUsers($this->room, $participantsToAdd, $addedBy);
		} catch (CannotReachRemoteException) {
			return new DataResponse(['error' => 'reach-remote'], Http::STATUS_NOT_FOUND);
		}

		return new DataResponse([]);
	}

	/**
	 * Remove the current user from a room
	 *
	 * @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{error: 'last-moderator'|'participant'}, array{}>
	 *
	 * 200: Participant removed successfully
	 * 400: Removing participant is not possible
	 * 404: Participant not found
	 */
	#[FederationSupported]
	#[NoAdminRequired]
	#[RequireLoggedInParticipant]
	#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
	#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/room/{token}/participants/self', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function removeSelfFromRoom(): DataResponse {
		return $this->removeSelfFromRoomLogic($this->room, $this->participant);
	}

	/**
	 * @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{error: 'last-moderator'|'participant'}, array{}>
	 */
	protected function removeSelfFromRoomLogic(Room $room, Participant $participant): DataResponse {
		if ($room->isFederatedConversation()) {
			$this->federationManager->rejectByRemoveSelf($room, $this->userId);
		}

		if ($room->getType() !== Room::TYPE_ONE_TO_ONE && $room->getType() !== Room::TYPE_ONE_TO_ONE_FORMER) {
			if ($participant->hasModeratorPermissions(false)
				&& $this->participantService->getNumberOfUsers($room) > 1
				&& $this->participantService->getNumberOfModerators($room) === 1) {
				return new DataResponse(['error' => 'last-moderator'], Http::STATUS_BAD_REQUEST);
			}
		}

		if ($room->getType() !== Room::TYPE_CHANGELOG
			&& $room->getObjectType() !== Room::OBJECT_TYPE_FILE
			&& $this->participantService->getNumberOfUsers($room) === 1
			&& \in_array($participant->getAttendee()->getParticipantType(), [
				Participant::USER,
				Participant::MODERATOR,
				Participant::OWNER,
			], true)) {
			$this->roomService->deleteRoom($room);
			return new DataResponse(null);
		}

		if ($this->appConfig->getAppValueBool('delete_one_to_one_conversations')
			&& in_array($this->room->getType(), [Room::TYPE_ONE_TO_ONE, Room::TYPE_ONE_TO_ONE_FORMER], true)) {
			$this->roomService->deleteRoom($room);
			return new DataResponse(null);
		}

		$currentUser = $this->userManager->get($this->userId);
		if (!$currentUser instanceof IUser) {
			return new DataResponse(['error' => 'participant'], Http::STATUS_NOT_FOUND);
		}

		$this->participantService->removeUser($room, $currentUser, AAttendeeRemovedEvent::REASON_LEFT);

		return new DataResponse(null);
	}

	/**
	 * Remove an attendee from a room
	 *
	 * @param int $attendeeId ID of the attendee
	 * @psalm-param non-negative-int $attendeeId
	 * @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array{error: 'last-moderator'|'owner'|'participant'|'room-type'}, array{}>
	 *
	 * 200: Attendee removed successfully
	 * 400: Removing attendee is not possible
	 * 403: Removing attendee is not allowed
	 * 404: Attendee not found
	 */
	#[PublicPage]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/room/{token}/attendees', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function removeAttendeeFromRoom(int $attendeeId): DataResponse {
		try {
			$targetParticipant = $this->participantService->getParticipantByAttendeeId($this->room, $attendeeId);
		} catch (ParticipantNotFoundException) {
			return new DataResponse(['error' => 'participant'], Http::STATUS_NOT_FOUND);
		}

		if ($targetParticipant->getAttendee()->getActorType() === Attendee::ACTOR_USERS
			&& $targetParticipant->getAttendee()->getActorId() === MatterbridgeManager::BRIDGE_BOT_USERID) {
			return new DataResponse(['error' => 'participant'], Http::STATUS_NOT_FOUND);
		}

		if ($this->room->getType() === Room::TYPE_ONE_TO_ONE || $this->room->getType() === Room::TYPE_ONE_TO_ONE_FORMER) {
			return new DataResponse(['error' => 'room-type'], Http::STATUS_BAD_REQUEST);
		}

		if ($this->participant->getAttendee()->getId() === $targetParticipant->getAttendee()->getId()) {
			return $this->removeSelfFromRoomLogic($this->room, $targetParticipant);
		}

		if ($targetParticipant->getAttendee()->getParticipantType() === Participant::OWNER) {
			return new DataResponse(['error' => 'owner'], Http::STATUS_FORBIDDEN);
		}

		$this->participantService->removeAttendee($this->room, $targetParticipant, AAttendeeRemovedEvent::REASON_REMOVED);
		return new DataResponse(null);
	}

	/**
	 * Allowed guests to join conversation
	 *
	 * Required capability: `conversation-creation-password` for `string $password` parameter
	 *
	 * @param string $password New password (only available with `conversation-creation-password` capability)
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'breakout-room'|'type'|'value'|'password', message?: string}, array{}>
	 *
	 * 200: Allowed guests successfully
	 * 400: Allowing guests is not possible
	 */
	#[NoAdminRequired]
	#[RequireLoggedInModeratorParticipant]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/public', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function makePublic(string $password = ''): DataResponse {
		if ($this->talkConfig->isPasswordEnforced() && $password === '') {
			return new DataResponse(['error' => 'password', 'message' => $this->l->t('Password needs to be set')], Http::STATUS_BAD_REQUEST);
		}

		try {
			if ($password !== '') {
				$this->roomService->makePublicWithPassword($this->room, $password);
			} else {
				$this->roomService->setType($this->room, Room::TYPE_PUBLIC);
			}
		} catch (PasswordException $e) {
			return new DataResponse(['error' => 'password', 'message' => $e->getHint()], Http::STATUS_BAD_REQUEST);
		} catch (TypeException $e) {
			return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Disallowed guests to join conversation
	 *
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'breakout-room'|'type'|'value'}, array{}>
	 *
	 * 200: Room unpublished Disallowing guests successfully
	 * 400: Disallowing guests is not possible
	 */
	#[NoAdminRequired]
	#[RequireLoggedInModeratorParticipant]
	#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/room/{token}/public', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function makePrivate(): DataResponse {
		try {
			$this->roomService->setType($this->room, Room::TYPE_GROUP);
		} catch (TypeException $e) {
			return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Set read-only state of a room
	 *
	 * @param 0|1 $state New read-only state
	 * @psalm-param Room::READ_* $state
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'type'|'value'}, array{}>
	 *
	 * 200: Read-only state updated successfully
	 * 400: Updating read-only state is not possible
	 */
	#[NoAdminRequired]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/room/{token}/read-only', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function setReadOnly(int $state): DataResponse {
		try {
			$this->roomService->setReadOnly($this->room, $state);
		} catch (ReadOnlyException $e) {
			return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
		}

		if ($state === Room::READ_ONLY) {
			$participants = $this->participantService->getParticipantsInCall($this->room);

			// kick out all participants out of the call
			foreach ($participants as $participant) {
				$this->participantService->changeInCall($this->room, $participant, Participant::FLAG_DISCONNECTED);
			}
		}

		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Make a room listable
	 *
	 * @param 0|1|2 $scope Scope where the room is listable
	 * @psalm-param Room::LISTABLE_* $scope
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'breakout-room'|'type'|'value'}|array{error: 'forced', forced?: 0|1|2}, array{}>
	 *
	 * 200: Made room listable successfully
	 * 400: Making room listable is not possible
	 */
	#[NoAdminRequired]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/room/{token}/listable', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function setListable(int $scope): DataResponse {
		/** @var Room::LISTABLE_* $forced */
		$forced = $this->forcedParameters->getForcedParameter(Parameter::LISTABLE);
		if ($forced !== null && $forced !== $scope) {
			return new DataResponse(['error' => 'forced', 'forced' => $forced], Http::STATUS_BAD_REQUEST);
		}

		try {
			$this->roomService->setListable($this->room, $scope);
		} catch (ListableException $e) {
			return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Update the mention permissions for a room
	 *
	 * @param 0|1 $mentionPermissions New mention permissions
	 * @psalm-param Room::MENTION_PERMISSIONS_* $mentionPermissions
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'breakout-room'|'type'|'value'}|array{error: 'forced', forced?: 0|1}, array{}>
	 *
	 * 200: Permissions updated successfully
	 * 400: Updating permissions is not possible
	 */
	#[NoAdminRequired]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/room/{token}/mention-permissions', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function setMentionPermissions(int $mentionPermissions): DataResponse {
		/** @var Room::MENTION_PERMISSIONS_* $forced */
		$forced = $this->forcedParameters->getForcedParameter(Parameter::MENTION_PERMISSIONS);
		if ($forced !== null && $forced !== $mentionPermissions) {
			return new DataResponse(['error' => 'forced', 'forced' => $forced], Http::STATUS_BAD_REQUEST);
		}

		try {
			$this->roomService->setMentionPermissions($this->room, $mentionPermissions);
		} catch (MentionPermissionsException $e) {
			return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Set a password for a room
	 *
	 * @param string $password New password
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'breakout-room'|'type'|'value', message?: string}, array{}>
	 *
	 * 200: Password set successfully
	 * 400: Setting password is not possible
	 */
	#[PublicPage]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/room/{token}/password', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function setPassword(string $password): DataResponse {
		try {
			$this->roomService->setPassword($this->room, $password);
		} catch (PasswordException $e) {
			$data = ['error' => $e->getReason()];
			if ($e->getHint() !== '') {
				$data['message'] = $e->getHint();
			}
			return new DataResponse($data, Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Archive a conversation
	 *
	 * Required capability: `archived-conversations-v2`
	 *
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>
	 *
	 * 200: Conversation was archived
	 */
	#[NoAdminRequired]
	#[FederationSupported]
	#[RequireLoggedInParticipant]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/archive', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function archiveConversation(): DataResponse {
		$this->participantService->archiveConversation($this->participant);
		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Unarchive a conversation
	 *
	 * Required capability: `archived-conversations-v2`
	 *
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>
	 *
	 * 200: Conversation was unarchived
	 */
	#[NoAdminRequired]
	#[FederationSupported]
	#[RequireLoggedInParticipant]
	#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/room/{token}/archive', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function unarchiveConversation(): DataResponse {
		$this->participantService->unarchiveConversation($this->participant);
		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Assign conversation tags
	 *
	 * Required capability: `conversation-tags`
	 *
	 * @param list<string> $tagIds IDs of tags to assign (empty array to unassign all)
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>
	 *
	 * 200: Conversation tags updated
	 */
	#[NoAdminRequired]
	#[FederationSupported]
	#[RequireLoggedInParticipant]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/tags', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function assignTags(array $tagIds = []): DataResponse {
		$tagIds = $this->conversationTagService->validateTagIdsForUser($this->participant->getAttendee()->getActorId(), $tagIds);
		$this->participantService->assignConversationToTags($this->participant, $tagIds);
		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Mark a conversation as important (still sending notifications while on DND)
	 *
	 * Required capability: `important-conversations`
	 *
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>
	 *
	 * 200: Conversation was marked as important
	 */
	#[NoAdminRequired]
	#[FederationSupported]
	#[RequireLoggedInParticipant]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/important', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function markConversationAsImportant(): DataResponse {
		$this->participantService->markConversationAsImportant($this->participant);
		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Mark a conversation as unimportant (no longer sending notifications while on DND)
	 *
	 * Required capability: `important-conversations`
	 *
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>
	 *
	 * 200: Conversation was marked as unimportant
	 */
	#[NoAdminRequired]
	#[FederationSupported]
	#[RequireLoggedInParticipant]
	#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/room/{token}/important', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function markConversationAsUnimportant(): DataResponse {
		$this->participantService->markConversationAsUnimportant($this->participant);
		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Mark a conversation as sensitive (no last message is visible / no push preview is shown)
	 *
	 * Required capability: `sensitive-conversations`
	 *
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>
	 *
	 * 200: Conversation was marked as sensitive
	 */
	#[NoAdminRequired]
	#[FederationSupported]
	#[RequireLoggedInParticipant]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/sensitive', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function markConversationAsSensitive(): DataResponse {
		$this->participantService->markConversationAsSensitive($this->participant);
		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Mark a conversation as insensitive (last message is visible / push preview is shown)
	 *
	 * Required capability: `sensitive-conversations`
	 *
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>
	 *
	 * 200: Conversation was marked as insensitive
	 */
	#[NoAdminRequired]
	#[FederationSupported]
	#[RequireLoggedInParticipant]
	#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/room/{token}/sensitive', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function markConversationAsInsensitive(): DataResponse {
		$this->participantService->markConversationAsInsensitive($this->participant);
		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Join a room
	 *
	 * @param string $token Token of the room
	 * @param string $password Password of the room
	 * @param bool $force Create a new session if necessary
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{X-Nextcloud-Talk-Proxy-Hash?: string}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'field'|'response'}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, array{error: 'ban'|'password'}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}>|DataResponse<Http::STATUS_CONFLICT, array{sessionId: string, inCall: int, lastPing: int}, array{}>
	 *
	 * 200: Room joined successfully
	 * 400: Bad request when the external call access check failed
	 * 403: Joining room is not allowed
	 * 404: Room not found
	 * 409: Session already exists
	 */
	#[PublicPage]
	#[BruteForceProtection(action: 'talkRoomPassword')]
	#[BruteForceProtection(action: 'talkRoomToken')]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/participants/active', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function joinRoom(string $token, string $password = '', bool $force = true): DataResponse {
		$sessionId = $this->session->getSessionForRoom($token);
		$authenticatedEmailGuest = null;
		if ($this->userId === null && $this->federationAuthenticator->isAuthenticatedEmailGuest()) {
			$authenticatedEmailGuest = $this->federationAuthenticator->getActorId();
		}

		try {
			// The participant is just joining, so enforce to not load any session.
			// Authenticated email guests bypass the user/public-room access check;
			// the email attendee lookup below is the trust anchor instead.
			if ($authenticatedEmailGuest !== null) {
				$room = $this->manager->getRoomByToken($token);
			} else {
				$room = $this->manager->getRoomForUserByToken($token, $this->userId, null);
			}
		} catch (RoomNotFoundException $e) {
			$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
			$response->throttle(['token' => $token, 'action' => 'talkRoomToken']);
			return $response;
		}

		try {
			$this->banService->throwIfActorIsBanned($room, $this->userId);
		} catch (ForbiddenException $e) {
			$this->logger->info('Participant ' . ($this->userId ?? 'guest') . ' is banned from room ' . $token . ' by ' . $e->getMessage());
			return new DataResponse([
				'error' => 'ban',
			], Http::STATUS_FORBIDDEN);
		}

		if ($this->talkConfig->isExternalCallServiceConfigured()
			&& $room->getObjectType() === Room::OBJECT_TYPE_EXTERNAL_CALL) {
			$response = $this->requestExternalCallUrlFromService($room, $this->userId);
			if ($response['status'] === Http::STATUS_FORBIDDEN) {
				if ($this->userId !== null) {
					$this->participantService->removeUser($room, $this->userManager->get($this->userId), AAttendeeRemovedEvent::REASON_REMOVED);

					$this->dispatcher->dispatchTyped(new CriticalActionPerformedEvent(
						'Removed former user "%s" from conversation "%s" [%s] due to external call policy',
						[$this->userId, $room->getToken(), $room->getObjectId()],
					));
				}
				return new DataResponse(['error' => 'ban'], Http::STATUS_FORBIDDEN);
			}
			if ($response['status'] === Http::STATUS_BAD_REQUEST) {
				return new DataResponse(['error' => $response['error']], Http::STATUS_BAD_REQUEST);
			}
		}

		/** @var Participant|null $previousSession */
		$previousParticipant = null;
		/** @var Session|null $previousSession */
		$previousSession = null;

		if ($sessionId !== null) {
			try {
				if ($this->userId !== null) {
					$previousParticipant = $this->participantService->getParticipant($room, $this->userId, $sessionId);
				} else {
					$previousParticipant = $this->participantService->getParticipantBySession($room, $sessionId);
				}
				$previousSession = $previousParticipant->getSession();
			} catch (ParticipantNotFoundException) {
			}

			if ($previousSession instanceof Session && $previousSession->getSessionId() === $sessionId) {
				if ($force === false && $previousSession->getInCall() !== Participant::FLAG_DISCONNECTED) {
					// Previous session is/was active in the call, show a warning
					return new DataResponse([
						'sessionId' => $previousSession->getSessionId(),
						'inCall' => $previousSession->getInCall(),
						'lastPing' => $previousSession->getLastPing(),
					], Http::STATUS_CONFLICT);
				}

				if ($previousSession->getInCall() !== Participant::FLAG_DISCONNECTED) {
					$this->participantService->changeInCall($room, $previousParticipant, Participant::FLAG_DISCONNECTED);
				}

				$this->participantService->leaveRoomAsSession($room, $previousParticipant, true);
			}
		}

		$headers = [];
		if ($authenticatedEmailGuest !== null || $room->isFederatedConversation()
			|| ($previousParticipant instanceof Participant && $previousParticipant->isGuest())) {
			// Skip password checking
			$result = [
				'result' => true,
			];
		} else {
			$result = $this->roomService->verifyPassword($room, (string)$this->session->getPasswordForRoom($token));
		}

		$user = $this->userManager->get($this->userId);
		try {
			if ($user instanceof IUser) {
				$participant = $this->participantService->joinRoom($this->roomService, $room, $user, $password, $result['result']);
				$this->participantService->generatePinForParticipant($room, $participant);
			} else {
				if ($authenticatedEmailGuest !== null && $previousParticipant === null) {
					try {
						$previousParticipant = $this->participantService->getParticipantByActor($room, Attendee::ACTOR_EMAILS, $authenticatedEmailGuest);
					} catch (ParticipantNotFoundException) {
						// Stale spreed-authed-email session: the email attendee was
						// removed (e.g. by a moderator) since the invite link was opened.
						$this->session->removeAuthedEmailActorIdForRoom($token);
						$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
						$response->throttle(['token' => $token, 'action' => 'talkRoomToken']);
						return $response;
					}
				}
				$participant = $this->participantService->joinRoomAsNewGuest($this->roomService, $room, $password, $result['result'], $previousParticipant);
				$this->session->setGuestActorIdForRoom($room->getToken(), $participant->getAttendee()->getActorId());
			}
			$this->throttler->resetDelay($this->request->getRemoteAddress(), 'talkRoomPassword', ['token' => $token, 'action' => 'talkRoomPassword']);
			$this->throttler->resetDelay($this->request->getRemoteAddress(), 'talkRoomToken', ['token' => $token, 'action' => 'talkRoomToken']);
		} catch (InvalidPasswordException $e) {
			$response = new DataResponse([
				'error' => 'password',
			], Http::STATUS_FORBIDDEN);
			$response->throttle(['token' => $token, 'action' => 'talkRoomPassword']);
			return $response;
		} catch (UnauthorizedException $e) {
			$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
			$response->throttle(['token' => $token, 'action' => 'talkRoomToken']);
			return $response;
		}

		$this->session->removePasswordForRoom($token);
		$session = $participant->getSession();
		if ($session instanceof Session) {
			$this->session->setSessionForRoom($token, $session->getSessionId());
		}

		if ($room->isFederatedConversation()) {
			/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\RoomController $proxy */
			$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\RoomController::class);

			try {
				$response = $proxy->joinFederatedRoom($room, $participant);
			} catch (CannotReachRemoteException $e) {
				$this->participantService->leaveRoomAsSession($room, $participant);

				throw $e;
			}

			if ($response->getStatus() === Http::STATUS_NOT_FOUND) {
				$this->participantService->removeAttendee($room, $participant, AAttendeeRemovedEvent::REASON_REMOVED);
				return new DataResponse(null, Http::STATUS_NOT_FOUND);
			}

			/** @var TalkRoom $data */
			$data = $response->getData();
			$this->roomService->syncPropertiesFromHostRoom($room, $data);

			$proxyHeaders = $response->getHeaders();
			if (isset($proxyHeaders['X-Nextcloud-Talk-Proxy-Hash'])) {
				$headers['X-Nextcloud-Talk-Proxy-Hash'] = $proxyHeaders['X-Nextcloud-Talk-Proxy-Hash'];
			}
		}

		$userStatuses = null;
		if ($this->userId !== null
			&& $this->appManager->isEnabledForUser('user_status')
			&& $room->getType() === Room::TYPE_ONE_TO_ONE) {
			$actorIds = json_decode($room->getName(), true) ?? [];
			if (is_array($actorIds)) {
				$actorIds = array_filter($actorIds, fn ($actorId) => $actorId !== $this->userId);
				if (!empty($actorIds)) {
					$userStatuses = $this->statusManager->getUserStatuses($actorIds);
				}
			}
		}

		return new DataResponse($this->formatRoom($room, $participant, $userStatuses), Http::STATUS_OK, $headers);
	}

	/**
	 * Join room on the host server using the session id of the federated user
	 *
	 * The session id can be null only for requests from Talk < 20.
	 *
	 * @param string $token Token of the room
	 * @param ?string $sessionId Federated session id to join with
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{X-Nextcloud-Talk-Hash: string}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}>
	 *
	 * 200: Federated user joined the room
	 * 404: Room not found
	 */
	#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
	#[PublicPage]
	#[BruteForceProtection(action: 'talkRoomToken')]
	#[BruteForceProtection(action: 'talkFederationAccess')]
	#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/federation/active', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function joinFederatedRoom(string $token, ?string $sessionId): DataResponse {
		if (!$this->federationAuthenticator->isFederationRequest()) {
			$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
			$response->throttle(['token' => $token, 'action' => 'talkRoomToken']);
			return $response;
		}

		try {
			try {
				$room = $this->federationAuthenticator->getRoom();
			} catch (RoomNotFoundException) {
				$room = $this->manager->getRoomByRemoteAccess(
					$token,
					Attendee::ACTOR_FEDERATED_USERS,
					$this->federationAuthenticator->getCloudId(),
					$this->federationAuthenticator->getAccessToken(),
				);
			}

			if ($sessionId !== null) {
				$participant = $this->participantService->joinRoomAsFederatedUser($room, Attendee::ACTOR_FEDERATED_USERS, $this->federationAuthenticator->getCloudId(), $sessionId);
			} else {
				$participant = $this->participantService->getParticipantByActor($room, Attendee::ACTOR_FEDERATED_USERS, $this->federationAuthenticator->getCloudId());
			}

			// Let the clients know if they need to reload capabilities
			$capabilities = $this->capabilities->getCapabilities();
			return new DataResponse($this->formatRoom($room, $participant), Http::STATUS_OK, [
				'X-Nextcloud-Talk-Hash' => sha1(json_encode($capabilities)),
			]);
		} catch (RoomNotFoundException|ParticipantNotFoundException|UnauthorizedException) {
			$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
			$response->throttle(['token' => $token, 'action' => 'talkFederationAccess']);
			return $response;
		}
	}

	/**
	 * Verify a dial-in PIN (SIP bridge)
	 *
	 * @param numeric-string $pin PIN the participant used to dial-in
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
	 *
	 * 200: Participant returned
	 * 401: SIP request invalid
	 * 404: Participant not found
	 * 501: SIP dial-in is not configured
	 */
	#[PublicPage]
	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
	#[OpenAPI(scope: 'backend-sipbridge')]
	#[RequireRoom]
	#[RequestHeader(name: 'talk-sipbridge-random', description: 'Random seed (at least 32 bytes) used together with the room token to generate the SHA256-HMAC request checksum', indirect: true)]
	#[RequestHeader(name: 'talk-sipbridge-checksum', description: 'SHA256-HMAC checksum over the concatenation of the random seed and the room token, signed with the shared SIP bridge secret, to verify authenticity from the SIP bridge', indirect: true)]
	#[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/room/{token}/pin/{pin}', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
		'pin' => '\d{7,32}',
	], postfix: 'deprecated')]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/verify-dialin', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function verifyDialInPin(string $pin): DataResponse {
		if (!$this->talkConfig->isSIPConfigured()) {
			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
		}

		try {
			if (!$this->validateSIPBridgeRequest($this->room->getToken())) {
				$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
				$response->throttle(['action' => 'talkSipBridgeSecret']);
				return $response;
			}
		} catch (UnauthorizedException) {
			$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
			$response->throttle(['action' => 'talkSipBridgeSecret']);
			return $response;
		}

		try {
			$participant = $this->participantService->getParticipantByPin($this->room, $pin);
		} catch (ParticipantNotFoundException) {
			return new DataResponse(null, Http::STATUS_NOT_FOUND);
		}

		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
	}

	/**
	 * Direct dial-in (SIP bridge)
	 *
	 * Required capability: `sip-direct-dialin`
	 *
	 * @param string $phoneNumber Phone number that is called
	 * @param string $caller Phone number of the person calling in
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
	 *
	 * 200: Call conversation created
	 * 401: SIP request invalid
	 * 404: Number is not assigned to any user
	 * 500: Error occurred while creating conversation
	 * 501: SIP dial-in is not configured
	 */
	#[PublicPage]
	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
	#[OpenAPI(scope: 'backend-sipbridge')]
	#[RequestHeader(name: 'talk-sipbridge-random', description: 'Random seed (at least 32 bytes) used together with the phone number to generate the SHA256-HMAC request checksum', indirect: true)]
	#[RequestHeader(name: 'talk-sipbridge-checksum', description: 'SHA256-HMAC checksum over the concatenation of the random seed and the phone number, signed with the shared SIP bridge secret, to verify authenticity from the SIP bridge', indirect: true)]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/direct-dial-in', requirements: [
		'apiVersion' => '(v4)',
	])]
	public function directDialIn(string $phoneNumber, string $caller): DataResponse {
		if (!$this->talkConfig->isSIPConfigured()) {
			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
		}

		try {
			if (!$this->validateSIPBridgeRequest($phoneNumber)) {
				$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
				$response->throttle(['action' => 'talkSipBridgeSecret']);
				return $response;
			}
		} catch (UnauthorizedException) {
			$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
			$response->throttle(['action' => 'talkSipBridgeSecret']);
			return $response;
		}

		try {
			$entity = $this->phoneService->getAccountToCallForPhoneNumber($phoneNumber);
		} catch (DoesNotExistException) {
			$this->logger->info('No account found for direct dial-in with number: ' . $phoneNumber);
			return new DataResponse(null, Http::STATUS_NOT_FOUND);
		}

		$caller = trim($caller);
		// TODO: Use later and get name from addressbook? $cleanedCaller = $this->phoneNumberUtil->convertToStandardFormat($caller);
		$user = $this->userManager->get($entity->getActorId());
		try {
			$room = $this->roomService->createConversation(
				Room::TYPE_GROUP,
				$caller,
				$user,
				Room::OBJECT_TYPE_PHONE_TEMPORARY,
				Room::OBJECT_ID_PHONE_INCOMING,
				sipEnabled: Webinary::SIP_ENABLED_NO_PIN,
			);
		} catch (CreationException|PasswordException $e) {
			$this->logger->error('Error occurred while creating SIP direct dial-in conversation', ['exception' => $e]);
			return new DataResponse(null, Http::STATUS_INTERNAL_SERVER_ERROR);
		}

		$participant = $this->participantService->joinRoomAsNewGuest($this->roomService, $room, '', true, displayName: $caller);
		return new DataResponse($this->formatRoom($room, $participant));
	}

	/**
	 * Verify a dial-out number (SIP bridge)
	 *
	 * @param string $number E164 formatted phone number
	 * @param array{actorId?: string, actorType?: string, attendeeId?: int} $options Additional details to verify the validity of the request
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
	 *
	 * 200: Participant created successfully
	 * 400: Phone number and details could not be confirmed
	 * 401: SIP request invalid
	 * 404: Phone number is not invited as a participant
	 * 501: SIP dial-out is not configured
	 */
	#[PublicPage]
	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
	#[OpenAPI(scope: 'backend-sipbridge')]
	#[RequireRoom]
	#[RequestHeader(name: 'talk-sipbridge-random', description: 'Random seed (at least 32 bytes) used together with the room token to generate the SHA256-HMAC request checksum', indirect: true)]
	#[RequestHeader(name: 'talk-sipbridge-checksum', description: 'SHA256-HMAC checksum over the concatenation of the random seed and the room token, signed with the shared SIP bridge secret, to verify authenticity from the SIP bridge', indirect: true)]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/verify-dialout', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function verifyDialOutNumber(string $number, array $options = []): DataResponse {
		if (!$this->talkConfig->isSIPConfigured() || !$this->talkConfig->isSIPDialOutEnabled()) {
			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
		}

		try {
			if (!$this->validateSIPBridgeRequest($this->room->getToken())) {
				$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
				$response->throttle(['action' => 'talkSipBridgeSecret']);
				return $response;
			}
		} catch (UnauthorizedException) {
			$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
			$response->throttle(['action' => 'talkSipBridgeSecret']);
			return $response;
		}

		if (!isset($options['actorId'], $options['actorType']) || $options['actorType'] !== Attendee::ACTOR_PHONES) {
			return new DataResponse(null, Http::STATUS_BAD_REQUEST);
		}

		try {
			$participant = $this->participantService->getParticipantByActor($this->room, Attendee::ACTOR_PHONES, $options['actorId']);
		} catch (ParticipantNotFoundException) {
			return new DataResponse(null, Http::STATUS_NOT_FOUND);
		}

		if ($participant->getAttendee()->getPhoneNumber() !== $number) {
			return new DataResponse(null, Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
	}

	/**
	 * Create a guest by their dial-in
	 *
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED, null, array{}>
	 *
	 * 200: Participant created successfully
	 * 400: SIP not enabled
	 * 401: SIP request invalid
	 */
	#[PublicPage]
	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
	#[OpenAPI(scope: 'backend-sipbridge')]
	#[RequireRoom]
	#[RequestHeader(name: 'talk-sipbridge-random', description: 'Random seed (at least 32 bytes) used together with the room token to generate the SHA256-HMAC request checksum', indirect: true)]
	#[RequestHeader(name: 'talk-sipbridge-checksum', description: 'SHA256-HMAC checksum over the concatenation of the random seed and the room token, signed with the shared SIP bridge secret, to verify authenticity from the SIP bridge', indirect: true)]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/open-dial-in', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function createGuestByDialIn(): DataResponse {
		try {
			if (!$this->validateSIPBridgeRequest($this->room->getToken())) {
				$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
				$response->throttle(['action' => 'talkSipBridgeSecret']);
				return $response;
			}
		} catch (UnauthorizedException) {
			$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
			$response->throttle(['action' => 'talkSipBridgeSecret']);
			return $response;
		}

		if ($this->room->getSIPEnabled() !== Webinary::SIP_ENABLED_NO_PIN) {
			return new DataResponse(null, Http::STATUS_BAD_REQUEST);
		}

		$participant = $this->participantService->joinRoomAsNewGuest($this->roomService, $this->room, '', true);

		return new DataResponse($this->formatRoom($this->room, $participant, skipLastMessage: true));
	}

	/**
	 * Reset call ID of a dial-out participant when the SIP gateway rejected it
	 *
	 * @param string $callId The call ID provided by the SIP bridge earlier to uniquely identify the call to terminate
	 * @param array{actorId?: string, actorType?: string, attendeeId?: int} $options Additional details to verify the validity of the request
	 * @return DataResponse<Http::STATUS_OK|Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND|Http::STATUS_NOT_IMPLEMENTED, null, array{}>
	 *
	 * 200: Call ID reset
	 * 400: Call ID mismatch or attendeeId not found in $options
	 * 401: SIP request invalid
	 * 404: Participant was not found
	 * 501: SIP dial-out is not configured
	 */
	#[PublicPage]
	#[BruteForceProtection(action: 'talkSipBridgeSecret')]
	#[OpenAPI(scope: 'backend-sipbridge')]
	#[RequireRoom]
	#[RequestHeader(name: 'talk-sipbridge-random', description: 'Random seed (at least 32 bytes) used together with the room token to generate the SHA256-HMAC request checksum', indirect: true)]
	#[RequestHeader(name: 'talk-sipbridge-checksum', description: 'SHA256-HMAC checksum over the concatenation of the random seed and the room token, signed with the shared SIP bridge secret, to verify authenticity from the SIP bridge', indirect: true)]
	#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/room/{token}/rejected-dialout', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function rejectedDialOutRequest(string $callId, array $options = []): DataResponse {
		if (!$this->talkConfig->isSIPConfigured() || !$this->talkConfig->isSIPDialOutEnabled()) {
			return new DataResponse(null, Http::STATUS_NOT_IMPLEMENTED);
		}

		try {
			if (!$this->validateSIPBridgeRequest($this->room->getToken())) {
				$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
				$response->throttle(['action' => 'talkSipBridgeSecret']);
				return $response;
			}
		} catch (UnauthorizedException) {
			$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
			$response->throttle(['action' => 'talkSipBridgeSecret']);
			return $response;
		}

		if (empty($options['attendeeId'])) {
			return new DataResponse(null, Http::STATUS_BAD_REQUEST);
		}

		try {
			$this->participantService->resetDialOutRequest($this->room, $options['attendeeId'], $callId);
		} catch (ParticipantNotFoundException) {
			return new DataResponse(null, Http::STATUS_NOT_FOUND);
		} catch (\InvalidArgumentException) {
			return new DataResponse(null, Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse(null);
	}

	/**
	 * Set active state for a session
	 *
	 * @param 0|1 $state of the room
	 * @psalm-param Session::STATE_* $state
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, null, array{}>
	 *
	 * 200: Session state set successfully
	 * 400: The provided new state was invalid
	 * 404: The participant did not have a session
	 */
	#[FederationSupported]
	#[PublicPage]
	#[RequireParticipant]
	#[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/room/{token}/participants/state', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function setSessionState(int $state): DataResponse {
		if (!$this->participant->getSession() instanceof Session) {
			return new DataResponse(null, Http::STATUS_NOT_FOUND);
		}

		try {
			$this->sessionService->updateSessionState($this->participant->getSession(), $state);
		} catch (\InvalidArgumentException) {
			return new DataResponse(null, Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Leave a room
	 *
	 * @param string $token Token of the room
	 * @return DataResponse<Http::STATUS_OK, null, array{}>
	 *
	 * 200: Successfully left the room
	 */
	#[PublicPage]
	#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/room/{token}/participants/active', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function leaveRoom(string $token): DataResponse {
		$sessionId = $this->session->getSessionForRoom($token);
		$this->session->removeSessionForRoom($token);

		try {
			if ($this->userId === null && $this->federationAuthenticator->isAuthenticatedEmailGuest()) {
				$room = $this->manager->getRoomByToken($token);
			} else {
				$room = $this->manager->getRoomForUserByToken($token, $this->userId, $sessionId);
			}
			$participant = $this->participantService->getParticipantBySession($room, $sessionId);

			if ($room->isFederatedConversation()) {
				/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\RoomController $proxy */
				$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\RoomController::class);
				$response = $proxy->leaveFederatedRoom($room, $participant);
			}

			$this->participantService->leaveRoomAsSession($room, $participant);
		} catch (RoomNotFoundException|ParticipantNotFoundException) {
		}

		return new DataResponse(null);
	}

	/**
	 * Leave room on the host server using the session id of the federated user
	 *
	 * @param string $token Token of the room
	 * @param string $sessionId Federated session id to leave with
	 * @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, null, array{}>
	 *
	 * 200: Successfully left the room
	 * 404: Room not found (non-federation request)
	 */
	#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
	#[PublicPage]
	#[BruteForceProtection(action: 'talkRoomToken')]
	#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
	#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/room/{token}/federation/active', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function leaveFederatedRoom(string $token, string $sessionId): DataResponse {
		if (!$this->federationAuthenticator->isFederationRequest()) {
			$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
			$response->throttle(['token' => $token, 'action' => 'talkRoomToken']);
			return $response;
		}

		try {
			try {
				$room = $this->federationAuthenticator->getRoom();
			} catch (RoomNotFoundException) {
				$room = $this->manager->getRoomByRemoteAccess(
					$token,
					Attendee::ACTOR_FEDERATED_USERS,
					$this->federationAuthenticator->getCloudId(),
					$this->federationAuthenticator->getAccessToken(),
				);
			}

			try {
				$participant = $this->federationAuthenticator->getParticipant();
			} catch (ParticipantNotFoundException) {
				$participant = $this->participantService->getParticipantBySession(
					$room,
					$sessionId,
				);

				if (
					$participant->getAttendee()->getActorType() !== Attendee::ACTOR_FEDERATED_USERS
					|| $participant->getAttendee()->getActorId() !== $this->federationAuthenticator->getCloudId()
					|| !hash_equals($participant->getAttendee()->getAccessToken(), $this->federationAuthenticator->getAccessToken())
				) {
					$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
					$response->throttle(['token' => $token, 'action' => 'talkRoomToken']);
					return $response;
				}
				$this->federationAuthenticator->authenticated($room, $participant);
			}

			$this->participantService->leaveRoomAsSession($room, $participant);
		} catch (RoomNotFoundException|ParticipantNotFoundException) {
		}

		return new DataResponse(null);
	}

	/**
	 * Promote an attendee to moderator
	 *
	 * @param int $attendeeId ID of the attendee
	 * @psalm-param non-negative-int $attendeeId
	 * @return DataResponse<Http::STATUS_OK|Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, null, array{}>
	 *
	 * 200: Attendee promoted to moderator successfully
	 * 400: Promoting attendee to moderator is not possible
	 * 403: Promoting attendee to moderator is not allowed
	 * 404: Attendee not found
	 */
	#[PublicPage]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/moderators', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function promoteModerator(int $attendeeId): DataResponse {
		return $this->changeParticipantType($attendeeId, true);
	}

	/**
	 * Demote an attendee from moderator
	 *
	 * @param int $attendeeId ID of the attendee
	 * @psalm-param non-negative-int $attendeeId
	 * @return DataResponse<Http::STATUS_OK|Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, null, array{}>
	 *
	 * 200: Attendee demoted from moderator successfully
	 * 400: Demoting attendee from moderator is not possible
	 * 403: Demoting attendee from moderator is not allowed
	 * 404: Attendee not found
	 */
	#[PublicPage]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/room/{token}/moderators', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function demoteModerator(int $attendeeId): DataResponse {
		return $this->changeParticipantType($attendeeId, false);
	}

	/**
	 * Toggle a user/guest to moderator/guest-moderator or vice-versa based on
	 * attendeeId
	 *
	 * @param int $attendeeId
	 * @psalm-param non-negative-int $attendeeId
	 * @param bool $promote Shall the attendee be promoted or demoted
	 * @return DataResponse<Http::STATUS_OK|Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, null, array{}>
	 */
	protected function changeParticipantType(int $attendeeId, bool $promote): DataResponse {
		try {
			$targetParticipant = $this->participantService->getParticipantByAttendeeId($this->room, $attendeeId);
		} catch (ParticipantNotFoundException) {
			return new DataResponse(null, Http::STATUS_NOT_FOUND);
		}

		$attendee = $targetParticipant->getAttendee();

		if ($attendee->getActorType() === Attendee::ACTOR_USERS
			&& $attendee->getActorId() === MatterbridgeManager::BRIDGE_BOT_USERID) {
			return new DataResponse(null, Http::STATUS_NOT_FOUND);
		}

		// Prevent users/moderators modifying themselves
		if ($attendee->getActorType() === $this->participant->getAttendee()->getActorType()) {
			if ($attendee->getActorId() === $this->participant->getAttendee()->getActorId()) {
				return new DataResponse(null, Http::STATUS_FORBIDDEN);
			}
		} elseif ($attendee->getActorType() === Attendee::ACTOR_GROUPS) {
			// Can not promote/demote groups
			return new DataResponse(null, Http::STATUS_BAD_REQUEST);
		}

		if ($promote === $targetParticipant->hasModeratorPermissions()) {
			// Prevent concurrent changes
			return new DataResponse(null, Http::STATUS_BAD_REQUEST);
		}

		if ($attendee->getParticipantType() === Participant::USER
			|| $attendee->getParticipantType() === Participant::USER_SELF_JOINED) {
			$newType = Participant::MODERATOR;
		} elseif ($attendee->getParticipantType() === Participant::GUEST) {
			$newType = Participant::GUEST_MODERATOR;
		} elseif ($attendee->getParticipantType() === Participant::MODERATOR) {
			$newType = Participant::USER;
		} elseif ($attendee->getParticipantType() === Participant::GUEST_MODERATOR) {
			$newType = Participant::GUEST;
		} else {
			return new DataResponse(null, Http::STATUS_BAD_REQUEST);
		}

		$this->participantService->updateParticipantType($this->room, $targetParticipant, $newType);

		return new DataResponse(null);
	}

	/**
	 * Update the permissions of a room
	 *
	 * @param 'call'|'default' $mode Level of the permissions ('call' (removed in Talk 20), 'default')
	 * @param int<0, 511> $permissions New permissions
	 * @psalm-param int-mask-of<Attendee::PERMISSIONS_*> $permissions
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'breakout-room'|'mode'|'type'|'value'}|array{error: 'forced', forced?: int<0, 511>}, array{}>
	 *
	 * 200: Permissions updated successfully
	 * 400: Updating permissions is not possible
	 */
	#[PublicPage]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/room/{token}/permissions/{mode}', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
		'mode' => '(call|default)',
	])]
	public function setPermissions(string $mode, int $permissions): DataResponse {
		if ($mode !== 'default') {
			return new DataResponse(['error' => 'mode'], Http::STATUS_BAD_REQUEST);
		}

		/** @var int<0, 511> $forced */
		$forced = $this->forcedParameters->getForcedParameter(Parameter::PERMISSIONS);
		if ($forced !== null && $forced !== $permissions) {
			return new DataResponse(['error' => 'forced', 'forced' => $forced], Http::STATUS_BAD_REQUEST);
		}

		try {
			$this->roomService->setDefaultPermissions($this->room, $permissions);
		} catch (DefaultPermissionsException $e) {
			return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Update the permissions of an attendee
	 *
	 * @param int $attendeeId ID of the attendee
	 * @psalm-param non-negative-int $attendeeId
	 * @param 'set'|'remove'|'add' $method Method of updating permissions ('set', 'remove', 'add')
	 * @param int<0, 511> $permissions New permissions
	 * @psalm-param int-mask-of<Attendee::PERMISSIONS_*> $permissions
	 * @return DataResponse<Http::STATUS_OK, list<TalkParticipant>, array{X-Nextcloud-Has-User-Statuses?: true}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array{error: 'participant'|'method'|'moderator'|'room-type'|'type'|'value'}|array{error: 'forced', forced?: int<0, 511>}, array{}>
	 *
	 * 200: Permissions updated successfully
	 * 400: Updating permissions is not possible
	 * 403: Missing permissions to update permissions
	 * 404: Attendee not found
	 */
	#[PublicPage]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/room/{token}/attendees/permissions', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function setAttendeePermissions(int $attendeeId, string $method, int $permissions): DataResponse {
		try {
			$targetParticipant = $this->participantService->getParticipantByAttendeeId($this->room, $attendeeId);
		} catch (ParticipantNotFoundException $e) {
			return new DataResponse(['error' => 'participant'], Http::STATUS_NOT_FOUND);
		}

		/** @var int<0, 511> $forced */
		$forced = $this->forcedParameters->getForcedParameter(Parameter::PERMISSIONS);
		if ($forced !== null && $permissions !== Attendee::PERMISSIONS_DEFAULT && ($forced !== $permissions || $method !== 'set')) {
			return new DataResponse(['error' => 'forced', 'forced' => $forced], Http::STATUS_BAD_REQUEST);
		}

		try {
			$this->participantService->updatePermissions($this->room, $targetParticipant, $method, $permissions);
		} catch (PermissionsException $e) {
			if ($e->getReason() === PermissionsException::REASON_MODERATOR) {
				return new DataResponse(['error' => $e->getReason()], Http::STATUS_FORBIDDEN);
			}
			return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
		}

		return $this->formatParticipantList([$targetParticipant], true);
	}

	/**
	 * Update the permissions of all attendees
	 *
	 * @param 'set'|'remove'|'add' $method Method of updating permissions ('set', 'remove', 'add')
	 * @psalm-param Attendee::PERMISSIONS_MODIFY_* $method
	 * @param int<0, 511> $permissions New permissions
	 * @psalm-param int-mask-of<Attendee::PERMISSIONS_*> $permissions
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, null, array{}>
	 * @deprecated Call permissions have been removed
	 *
	 * 200: Permissions updated successfully
	 * 400: Updating permissions is not possible
	 */
	#[PublicPage]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/room/{token}/attendees/permissions/all', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function setAllAttendeesPermissions(string $method, int $permissions): DataResponse {
		return new DataResponse(null, Http::STATUS_BAD_REQUEST);
	}

	/**
	 * Update the lobby state for a room
	 *
	 * @param 0|1 $state New state
	 * @psalm-param Webinary::LOBBY_* $state
	 * @param int|null $timer Timer when the lobby will be removed
	 * @psalm-param non-negative-int|null $timer
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'breakout-room'|'object'|'type'|'value'}, array{}>
	 *
	 * 200: Lobby state updated successfully
	 * 400: Updating lobby state is not possible
	 */
	#[NoAdminRequired]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/room/{token}/webinar/lobby', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function setLobby(int $state, ?int $timer = null): DataResponse {
		$timerDateTime = null;
		if ($timer !== null && $timer > 0) {
			try {
				$timerDateTime = $this->timeFactory->getDateTime('@' . $timer);
				$timerDateTime->setTimezone(new \DateTimeZone('UTC'));
			} catch (\Exception) {
				return new DataResponse(['error' => LobbyException::REASON_VALUE], Http::STATUS_BAD_REQUEST);
			}
		}

		if ($this->room->getObjectType() === BreakoutRoom::PARENT_OBJECT_TYPE) {
			// Do not allow manual changing the lobby in breakout rooms
			return new DataResponse(['error' => LobbyException::REASON_BREAKOUT_ROOM], Http::STATUS_BAD_REQUEST);
		}

		try {
			$this->roomService->setLobby($this->room, $state, $timerDateTime);
		} catch (LobbyException $e) {
			return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
		}

		if ($state === Webinary::LOBBY_NON_MODERATORS) {
			$participants = $this->participantService->getParticipantsInCall($this->room);
			foreach ($participants as $participant) {
				if ($participant->hasModeratorPermissions()) {
					continue;
				}

				$this->participantService->changeInCall($this->room, $participant, Participant::FLAG_DISCONNECTED);
			}
		}

		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Update SIP enabled state
	 *
	 * @param 0|1|2 $state New state
	 * @psalm-param Webinary::SIP_* $state
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_FORBIDDEN|Http::STATUS_PRECONDITION_FAILED, array{error: 'config'}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'breakout-room'|'token'|'type'|'value'}|array{error: 'forced', forced?: 0|1|2}, array{}>
	 *
	 * 200: SIP enabled state updated successfully
	 * 400: Updating SIP enabled state is not possible
	 * 401: User not found
	 * 403: Missing permissions to update SIP enabled state
	 * 412: SIP not configured
	 */
	#[NoAdminRequired]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/room/{token}/webinar/sip', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function setSIPEnabled(int $state): DataResponse {
		$user = $this->userManager->get($this->userId);
		if (!$user instanceof IUser) {
			return new DataResponse(['error' => 'config'], Http::STATUS_UNAUTHORIZED);
		}

		if (!$this->talkConfig->canUserEnableSIP($user)) {
			return new DataResponse(['error' => 'config'], Http::STATUS_FORBIDDEN);
		}

		if (!$this->talkConfig->isSIPConfigured()) {
			return new DataResponse(['error' => 'config'], Http::STATUS_PRECONDITION_FAILED);
		}

		/** @var Webinary::SIP_* $forced */
		$forced = $this->forcedParameters->getForcedParameter(Parameter::SIP_ENABLED);
		if ($forced !== null && $forced !== $state) {
			return new DataResponse(['error' => 'forced', 'forced' => $forced], Http::STATUS_BAD_REQUEST);
		}

		try {
			$this->roomService->setSIPEnabled($this->room, $state);
		} catch (SipConfigurationException $e) {
			return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Set recording consent requirement for this conversation
	 *
	 * @param int $recordingConsent New consent setting for the conversation
	 *                              (Only {@see RecordingService::CONSENT_REQUIRED_NO} and {@see RecordingService::CONSENT_REQUIRED_YES} are allowed here.)
	 * @psalm-param RecordingService::CONSENT_REQUIRED_NO|RecordingService::CONSENT_REQUIRED_YES $recordingConsent
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'breakout-room'|'call'|'value'}, array{}>|DataResponse<Http::STATUS_PRECONDITION_FAILED, array{error: 'config'}, array{}>
	 *
	 * 200: Recording consent requirement set successfully
	 * 400: Setting recording consent requirement is not possible
	 * 412: No recording server is configured
	 */
	#[NoAdminRequired]
	#[RequireLoggedInModeratorParticipant]
	#[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/room/{token}/recording-consent', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function setRecordingConsent(int $recordingConsent): DataResponse {
		if (!$this->talkConfig->isRecordingEnabled()) {
			return new DataResponse(['error' => 'config'], Http::STATUS_PRECONDITION_FAILED);
		}

		try {
			$this->roomService->setRecordingConsent($this->room, $recordingConsent);
		} catch (RecordingConsentException $e) {
			return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Resend invitations
	 *
	 * @param int|null $attendeeId ID of the attendee
	 * @psalm-param non-negative-int|null $attendeeId
	 * @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, null, array{}>
	 *
	 * 200: Invitation resent successfully
	 * 404: Attendee not found
	 */
	#[NoAdminRequired]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/participants/resend-invitations', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function resendInvitations(?int $attendeeId): DataResponse {
		/** @var Participant[] $participants */
		$participants = [];

		// targeting specific participant
		if ($attendeeId !== null) {
			try {
				$participants[] = $this->participantService->getParticipantByAttendeeId($this->room, $attendeeId);
			} catch (ParticipantNotFoundException) {
				return new DataResponse(null, Http::STATUS_NOT_FOUND);
			}
		} else {
			$participants = $this->participantService->getParticipantsByActorType($this->room, Attendee::ACTOR_EMAILS);
		}

		foreach ($participants as $participant) {
			if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_EMAILS) {
				// generate PIN if applicable
				$this->participantService->generatePinForParticipant($this->room, $participant);
				$this->guestManager->sendEmailInvitation($this->room, $participant);
			}
		}
		return new DataResponse(null);
	}

	/**
	 * Update message expiration time
	 *
	 * @param int $seconds New time
	 * @psalm-param non-negative-int $seconds
	 * @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'breakout-room'|'type'|'value', forced?: int<0, max>}|array{error: 'forced', forced?: int<0, max>}, array{}>
	 *
	 * 200: Message expiration time updated successfully
	 * 400: Updating message expiration time is not possible
	 */
	#[PublicPage]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/message-expiration', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function setMessageExpiration(int $seconds): DataResponse {
		/** @var int<0, max> $forced */
		$forced = $this->forcedParameters->getForcedParameter(Parameter::MESSAGE_EXPIRATION);
		if ($forced !== null && $forced !== $seconds) {
			return new DataResponse(['error' => 'forced', 'forced' => $forced], Http::STATUS_BAD_REQUEST);
		}

		try {
			$this->roomService->setMessageExpiration($this->room, $seconds);
		} catch (MessageExpirationException $e) {
			return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse($this->formatRoom($this->room, $this->participant));
	}

	/**
	 * Import a list of email attendees
	 *
	 * Content format is comma separated values:
	 * - Header line is required and must match `"email","name"` or `"email"`
	 * - One entry per line (e.g. `"John Doe","john@example.tld"`)
	 *
	 * Required capability: `email-csv-import`
	 *
	 * @param bool $testRun When set to true, the file is validated and no email is actually sent nor any participant added to the conversation
	 * @return DataResponse<Http::STATUS_OK, array{invites: non-negative-int, duplicates: non-negative-int, invalid?: non-negative-int, invalidLines?: list<non-negative-int>, type?: int<-1, 6>}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'room'|'file'|'header-email'|'header-name'|'rows', message?: string, invites?: non-negative-int, duplicates?: non-negative-int, invalid?: non-negative-int, invalidLines?: list<non-negative-int>, type?: int<-1, 6>}, array{}>
	 *
	 * 200: All entries imported successfully
	 * 400: Import was not successful. When message is provided the string is in user language and should be displayed as an error.
	 */
	#[NoAdminRequired]
	#[RequireModeratorParticipant]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/import-emails', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function importEmailsAsParticipants(bool $testRun = false): DataResponse {
		$file = $this->request->getUploadedFile('file');
		if ($file === null) {
			return new DataResponse([
				'error' => 'file',
				'message' => $this->l->t('Uploading the file failed'),
			], Http::STATUS_BAD_REQUEST);
		}
		if ($file['error'] !== 0) {
			$this->logger->error('Uploading email CSV file failed with error: ' . $file['error']);
			return new DataResponse([
				'error' => 'file',
				'message' => $this->l->t('Uploading the file failed'),
			], Http::STATUS_BAD_REQUEST);
		}

		try {
			$data = $this->guestManager->importEmails($this->room, $file['tmp_name'], $testRun);
			return new DataResponse($data);
		} catch (GuestImportException $e) {
			return new DataResponse($e->getData(), Http::STATUS_BAD_REQUEST);
		}

	}

	/**
	 * Get capabilities for a room
	 *
	 * See "Capability handling in federated conversations" in https://github.com/nextcloud/spreed/issues/10680
	 * to learn which capabilities should be considered from the local server or from the remote server.
	 *
	 * @return DataResponse<Http::STATUS_OK, TalkCapabilities|\stdClass, array{X-Nextcloud-Talk-Hash?: string, X-Nextcloud-Talk-Proxy-Hash?: string}>
	 *
	 * 200: Get capabilities successfully
	 */
	#[FederationSupported]
	#[PublicPage]
	#[RequireParticipant]
	#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
	#[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/room/{token}/capabilities', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function getCapabilities(): DataResponse {
		$headers = [];
		if ($this->room->isFederatedConversation()) {
			/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\RoomController $proxy */
			$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\RoomController::class);
			$response = $proxy->getCapabilities($this->room, $this->participant);

			/** @var TalkCapabilities|array<empty> $data */
			$data = $response->getData();

			/**
			 * IMPORTANT:
			 * When adding, changing or removing anything here, update
			 * @see ProxyRequest::overwrittenRemoteTalkHash()
			 * so clients correctly refresh their capabilities.
			 */
			if (isset($data['config']['chat']['read-privacy'])) {
				$data['config']['chat']['read-privacy'] = Participant::PRIVACY_PRIVATE;
			}
			if (isset($data['config']['chat']['typing-privacy'])) {
				$data['config']['chat']['typing-privacy'] = $this->talkConfig->getUserTypingPrivacy($this->userId);
			}
			if (isset($data['config']['call']['start-without-media'])) {
				$data['config']['call']['start-without-media'] = $this->talkConfig->getCallsStartWithoutMedia($this->userId);
			}
			if (isset($data['config']['call']['blur-virtual-background'])) {
				$data['config']['call']['blur-virtual-background'] = $this->talkConfig->getBlurVirtualBackground($this->userId);
			}
			if (isset($data['config']['conversations']['list-style'])) {
				$data['config']['conversations']['list-style'] = $this->talkConfig->getConversationsListStyle($this->userId);
			}
			if (isset($data['config']['attachments']['conversation-subfolders'])) {
				$data['config']['attachments']['conversation-subfolders'] = $this->talkConfig->isConversationSubfoldersEnabled();
			}

			if ($response->getHeaders()['X-Nextcloud-Talk-Hash']) {
				$headers['X-Nextcloud-Talk-Proxy-Hash'] = $response->getHeaders()['X-Nextcloud-Talk-Hash'];
			}

			/** @var TalkCapabilities|\stdClass $data */
			$data = !empty($data) ? $data : new \stdClass();
		} else {
			$capabilities = $this->capabilities->getCapabilities();
			$data = $capabilities['spreed'] ?? new \stdClass();
			$headers['X-Nextcloud-Talk-Hash'] = sha1(json_encode($capabilities));
		}

		return new DataResponse($data, Http::STATUS_OK, $headers);
	}

	/**
	 * Schedule a meeting for a conversation
	 *
	 * Required capability: `schedule-meeting`
	 *
	 * @param string $calendarUri Last part of the calendar URI as seen by the participant e.g. 'personal' or 'company_shared_by_other_user'
	 * @param int $start Unix timestamp when the meeting starts
	 * @param ?list<int> $attendeeIds List of attendee ids to invite, if null everyone will be invited, if empty array only the actor will receive the event
	 * @param ?int $end Unix timestamp when the meeting ends, falls back to 60 minutes after start
	 * @param ?string $title Title or summary of the event, falling back to the conversation name if none is given
	 * @param ?string $description Description of the event, falling back to the conversation description if none is given
	 * @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'calendar'|'conversation'|'email'|'end'|'start'}, array{}>
	 *
	 * 200: Meeting scheduled
	 * 400: Meeting could not be created successfully
	 */
	#[NoAdminRequired]
	#[RequireLoggedInModeratorParticipant]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/meeting', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function scheduleMeeting(string $calendarUri, int $start, ?array $attendeeIds = null, ?int $end = null, ?string $title = null, ?string $description = null): DataResponse {
		if ($this->room->getType() === Room::TYPE_ONE_TO_ONE_FORMER) {
			return new DataResponse(['error' => 'conversation'], Http::STATUS_BAD_REQUEST);
		}

		$eventBuilder = $this->calendarManager->createEventBuilder();
		$calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $this->userId, [$calendarUri]);

		if (empty($calendars)) {
			return new DataResponse(['error' => 'calendar'], Http::STATUS_BAD_REQUEST);
		}

		/** @var ICreateFromString $calendar */
		$calendar = array_pop($calendars);

		$user = $this->userManager->get($this->userId);
		if (!$user instanceof IUser || $user->getEMailAddress() === null) {
			return new DataResponse(['error' => 'email'], Http::STATUS_BAD_REQUEST);
		}

		$timezoneService = Server::get(TimezoneService::class);
		$userTimezone = $timezoneService->getUserTimezone($user->getUID());
		$timezone = new \DateTimeZone($userTimezone);
		$startDate = $this->timeFactory->getDateTime('@' . $start)->setTimezone($timezone);
		if ($start < $this->timeFactory->getTime()) {
			return new DataResponse(['error' => 'start'], Http::STATUS_BAD_REQUEST);
		}

		if ($end !== null) {
			$endDate = $this->timeFactory->getDateTime('@' . $end)->setTimezone($timezone);
			if ($start >= $end) {
				return new DataResponse(['error' => 'end'], Http::STATUS_BAD_REQUEST);
			}
		} else {
			$endDate = clone $startDate;
			$endDate->add(new \DateInterval('PT1H'));
		}

		$eventBuilder->setLocation(
			$this->url->linkToRouteAbsolute(
				'spreed.Page.showCall',
				['token' => $this->room->getToken()]
			)
		);
		$eventBuilder->setSummary($title ?: $this->room->getDisplayName($this->userId));
		$eventBuilder->setDescription($description ?: $this->room->getDescription());
		$eventBuilder->setOrganizer($user->getEMailAddress(), $user->getDisplayName() ?: $this->userId);
		$eventBuilder->setStartDate($startDate);
		$eventBuilder->setEndDate($endDate);
		$eventBuilder->setStatus(CalendarEventStatus::CONFIRMED);

		if ($this->room->getType() === Room::TYPE_ONE_TO_ONE) {
			$this->participantService->ensureOneToOneRoomIsFilled($this->room);
		}

		$userAttendees = $this->participantService->getParticipantsByActorType($this->room, Attendee::ACTOR_USERS);
		foreach ($userAttendees as $userAttendee) {
			if ($attendeeIds !== null && !in_array($userAttendee->getAttendee()->getId(), $attendeeIds, true)) {
				continue;
			}

			$targetUser = $this->userManager->get($userAttendee->getAttendee()->getActorId());
			if (!$targetUser instanceof IUser) {
				continue;
			}
			if ($targetUser->getEMailAddress() === null) {
				continue;
			}
			// Do not add the organizer as an attendee
			if ($targetUser->getEMailAddress() === $user->getEMailAddress()) {
				continue;
			}

			$eventBuilder->addAttendee(
				$targetUser->getEMailAddress(),
				$targetUser->getDisplayName(),
			);
		}

		$emailGuests = $this->participantService->getParticipantsByActorType($this->room, Attendee::ACTOR_EMAILS);
		foreach ($emailGuests as $emailGuest) {
			if ($attendeeIds !== null && !in_array($emailGuest->getAttendee()->getId(), $attendeeIds, true)) {
				continue;
			}

			$eventBuilder->addAttendee(
				$emailGuest->getAttendee()->getInvitedCloudId(),
				$emailGuest->getAttendee()->getDisplayName(),
			);
		}

		try {
			$eventBuilder->createInCalendar($calendar);
		} catch (\InvalidArgumentException|CalendarException $e) {
			$this->logger->debug('Failed to get calendar to schedule a meeting', ['exception' => $e]);
			return new DataResponse(['error' => 'calendar'], Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse(null, Http::STATUS_OK);
	}

	/**
	 * Get the URL of the call if done with an external call service
	 *
	 * @return DataResponse<Http::STATUS_OK, array{url: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'field'|'response'|'server'|'type'}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, null, array{}>
	 *
	 * 200: URL returned successfully
	 * 400: Request was invalid, wrong conversation type or no service configured
	 * 403: Participant is not allowed to join the call
	 */
	#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
	#[PublicPage]
	#[RequireParticipant]
	#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/room/{token}/external-call', requirements: [
		'apiVersion' => '(v4)',
		'token' => '[a-z0-9]{4,30}',
	])]
	public function getExternalCallUrl(): DataResponse {
		if (!$this->talkConfig->isExternalCallServiceConfigured()) {
			return new DataResponse(['error' => 'server'], Http::STATUS_BAD_REQUEST);
		}

		if ($this->room->getObjectType() !== Room::OBJECT_TYPE_EXTERNAL_CALL) {
			return new DataResponse(['error' => 'type'], Http::STATUS_BAD_REQUEST);
		}

		$response = $this->requestExternalCallUrlFromService($this->room, $this->userId);
		if ($response['status'] === Http::STATUS_FORBIDDEN) {
			$this->participantService->removeAttendee($this->room, $this->participant, AAttendeeRemovedEvent::REASON_REMOVED);
			$this->dispatcher->dispatchTyped(new CriticalActionPerformedEvent(
				'Removed former %s "%s" from conversation "%s" [%s] due to external call policy',
				[$this->participant->getAttendee()->getActorType(), $this->participant->getAttendee()->getActorId(), $this->room->getToken(), $this->room->getObjectId()],
			));
			return new DataResponse(null, Http::STATUS_FORBIDDEN);
		}
		if ($response['status'] === Http::STATUS_BAD_REQUEST) {
			return new DataResponse(['error' => $response['error']], Http::STATUS_BAD_REQUEST);
		}

		return new DataResponse(['url' => $response['url']]);
	}

	/**
	 * @return array{url?: string, error?: 'response'|'field', status: 200|400|403}
	 */
	private function requestExternalCallUrlFromService(Room $room, ?string $userId): array {
		$options = [
			'headers' => [
				'Accept' => 'application/json',
			],
		];

		if ($this->talkConfig->getExternalCallServiceAuthUser()) {
			$options['auth'] = [
				urlencode($this->talkConfig->getExternalCallServiceAuthUser()),
				$this->talkConfig->getExternalCallServiceAuthPassword(),
			];
		}

		if ($userId !== null) {
			$options['headers']['x-nextcloud-user-id'] = $userId;
		}

		$serviceRequestUrl = str_replace(
			'{meetingId}',
			$room->getObjectId(),
			$this->talkConfig->getExternalCallService()
		);

		$clientService = \OCP\Server::get(IClientService::class);
		$client = $clientService->newClient();
		try {
			$response = $client->get($serviceRequestUrl, $options);
		} catch (ClientException $e) {
			if ($e->getResponse()->getStatusCode() === Http::STATUS_FORBIDDEN) {
				return ['status' => Http::STATUS_FORBIDDEN];
			}

			return ['error' => 'response', 'status' => Http::STATUS_BAD_REQUEST];
		} catch (\Throwable $e) {
			return ['error' => 'response', 'status' => Http::STATUS_BAD_REQUEST];
		}

		$responseBody = (string)$response->getBody();

		$responseField = $this->talkConfig->getExternalCallServiceIFrameResponseField();
		$url = null;
		if ($responseField !== '') {
			$data = (array)json_decode($responseBody, true, flags: JSON_THROW_ON_ERROR);
			$url = $data[$responseField] ?? null;
		}

		if ($url === null || $url === '') {
			return ['error' => 'field', 'status' => Http::STATUS_BAD_REQUEST];
		}

		return ['url' => $url, 'status' => Http::STATUS_OK];
	}
}
