<?php

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

namespace OCA\Talk;

use OCA\Talk\AppInfo\Application;
use OCA\Talk\Events\BeforeTurnServersGetEvent;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Service\RecordingService;
use OCA\Talk\Settings\UserPreference;
use OCA\Talk\Vendor\Firebase\JWT\JWT;
use OCP\AppFramework\Services\IAppConfig;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Config\IUserConfig;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\IFilenameValidator;
use OCP\IConfig;
use OCP\IGroupManager;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Security\ISecureRandom;

class Config {
	public const ALLOWED_BACKEND_TIMEOFFSET = 45;
	public const SIGNALING_INTERNAL = 'internal';
	public const SIGNALING_EXTERNAL = 'external';
	public const SIGNALING_CLUSTER_CONVERSATION = 'conversation_cluster';

	public const EXPERIMENTAL_UPDATE_PARTICIPANTS = 1;
	public const EXPERIMENTAL_RECOVER_SESSION = 2;
	public const EXPERIMENTAL_CHAT_RELAY = 4;

	public const SIGNALING_TICKET_V1 = 1;
	public const SIGNALING_TICKET_V2 = 2;

	/**
	 * 1. Call recording, …
	 */
	public const FEATURE_HINT = 34;

	/**
	 * Currently limiting to 1k users because the user_status API would yield
	 * an error on Oracle otherwise. Clients should use a virtual scrolling
	 * mechanism so the data should not be a problem nowadays
	 */
	public const USER_STATUS_INTEGRATION_LIMIT = 1000;

	/** @var array<string, bool> */
	protected array $canEnableSIP = [];

	public function __construct(
		private readonly IConfig $config,
		private readonly IAppConfig $appConfig,
		private readonly IUserConfig $userConfig,
		private readonly ISecureRandom $secureRandom,
		private readonly IGroupManager $groupManager,
		private readonly IUserManager $userManager,
		private readonly IURLGenerator $urlGenerator,
		private readonly ITimeFactory $timeFactory,
		private readonly IEventDispatcher $dispatcher,
		private readonly IFilenameValidator $filenameValidator,
	) {
	}

	/**
	 * @return string[]
	 */
	public function getAllowedTalkGroupIds(): array {
		$groups = $this->config->getAppValue('spreed', 'allowed_groups', '[]');
		$groups = json_decode($groups, true);
		return \is_array($groups) ? $groups : [];
	}

	/**
	 * @return Participant::PRIVACY_*
	 */
	public function getUserReadPrivacy(string $userId): int {
		return match ((int)$this->config->getUserValue(
			$userId,
			'spreed', UserPreference::READ_STATUS_PRIVACY,
			(string)Participant::PRIVACY_PUBLIC)) {
			Participant::PRIVACY_PUBLIC => Participant::PRIVACY_PUBLIC,
			default => Participant::PRIVACY_PRIVATE,
		};
	}

	/**
	 * @return Participant::PRIVACY_*
	 */
	public function getUserTypingPrivacy(string $userId): int {
		return match ((int)$this->config->getUserValue(
			$userId,
			'spreed', UserPreference::TYPING_PRIVACY,
			(string)Participant::PRIVACY_PUBLIC)) {
			Participant::PRIVACY_PUBLIC => Participant::PRIVACY_PUBLIC,
			default => Participant::PRIVACY_PRIVATE,
		};
	}

	/**
	 * @return string[]
	 */
	public function getSIPGroups(): array {
		$groups = $this->config->getAppValue('spreed', 'sip_bridge_groups', '[]');
		$groups = json_decode($groups, true);
		return \is_array($groups) ? $groups : [];
	}

	public function isSIPConfigured(): bool {
		return $this->getSIPSharedSecret() !== ''
			&& $this->getDialInInfo() !== '';
	}

	/**
	 * Determine if Talk federation is enabled on this instance
	 */
	public function isFederationEnabled(): bool {
		// TODO: Set to default true once implementation is complete
		return $this->config->getAppValue('spreed', 'federation_enabled', 'no') === 'yes';
	}

	public function isFederationEnabledForUserId(IUser $user): bool {
		$allowedGroups = $this->appConfig->getAppValueArray('federation_allowed_groups', lazy: true);
		if (empty($allowedGroups)) {
			return true;
		}

		$userGroups = $this->groupManager->getUserGroupIds($user);
		return !empty(array_intersect($allowedGroups, $userGroups));
	}

	public function isBreakoutRoomsEnabled(): bool {
		return $this->config->getAppValue('spreed', 'breakout_rooms', 'yes') === 'yes';
	}

	public function isConversationSubfoldersEnabled(): bool {
		return $this->appConfig->getAppValueBool('conversation_subfolders', true);
	}

	public function getDialInInfo(): string {
		return $this->config->getAppValue('spreed', 'sip_bridge_dialin_info');
	}

	public function getSIPSharedSecret(): string {
		return $this->config->getAppValue('spreed', 'sip_bridge_shared_secret');
	}

	public function canUserEnableSIP(IUser $user): bool {
		if (isset($this->canEnableSIP[$user->getUID()])) {
			return $this->canEnableSIP[$user->getUID()];
		}

		$this->canEnableSIP[$user->getUID()] = false;

		$allowedGroups = $this->getSIPGroups();
		if (empty($allowedGroups)) {
			$this->canEnableSIP[$user->getUID()] = true;
		} else {
			$userGroups = $this->groupManager->getUserGroupIds($user);
			$this->canEnableSIP[$user->getUID()] = !empty(array_intersect($allowedGroups, $userGroups));
		}

		return $this->canEnableSIP[$user->getUID()];
	}

	public function canUserDialOutSIP(IUser $user): bool {
		if (!$this->isSIPDialOutEnabled()) {
			return false;
		}

		return $this->canUserEnableSIP($user);
	}

	public function isSIPDialOutEnabled(): bool {
		return $this->config->getAppValue('spreed', 'sip_dialout', 'no') !== 'no';
	}

	public function getRecordingServers(): array {
		$config = $this->config->getAppValue('spreed', 'recording_servers');
		$recording = json_decode($config, true);

		if (!is_array($recording) || !isset($recording['servers'])) {
			return [];
		}

		return $recording['servers'];
	}

	public function getRecordingSecret(): string {
		$config = $this->config->getAppValue('spreed', 'recording_servers');
		$recording = json_decode($config, true);

		if (!is_array($recording)) {
			return '';
		}

		return $recording['secret'];
	}

	public function isRecordingEnabled(): bool {
		if ($this->getSignalingMode() === self::SIGNALING_INTERNAL) {
			return false;
		}

		if ($this->config->getAppValue('spreed', 'call_recording', 'yes') !== 'yes') {
			return false;
		}

		if ($this->getRecordingSecret() === '') {
			return false;
		}

		$recordingServers = $this->getRecordingServers();
		if (empty($recordingServers)) {
			return false;
		}

		return true;
	}

	/**
	 * @return RecordingService::CONSENT_REQUIRED_*
	 */
	public function recordingConsentRequired(): int {
		if (!$this->isRecordingEnabled()) {
			return RecordingService::CONSENT_REQUIRED_NO;
		}

		return $this->getRecordingConsentConfig();
	}

	/**
	 * @return RecordingService::CONSENT_REQUIRED_*
	 */
	public function getRecordingConsentConfig(): int {
		return match ((int)$this->config->getAppValue('spreed', 'recording_consent', (string)RecordingService::CONSENT_REQUIRED_NO)) {
			RecordingService::CONSENT_REQUIRED_YES => RecordingService::CONSENT_REQUIRED_YES,
			RecordingService::CONSENT_REQUIRED_OPTIONAL => RecordingService::CONSENT_REQUIRED_OPTIONAL,
			default => RecordingService::CONSENT_REQUIRED_NO,
		};
	}

	public function getRecordingFolder(string $userId): string {
		return $this->config->getUserValue(
			$userId,
			'spreed',
			UserPreference::RECORDING_FOLDER,
			$this->getAttachmentFolder($userId) . '/Recording'
		);
	}

	public function getLiveTranscriptionTargetLanguageId(?string $userId = null): string {
		return $this->config->getUserValue(
			$userId,
			'spreed',
			UserPreference::LIVE_TRANSCRIPTION_TARGET_LANGUAGE_ID,
			''
		);
	}

	public function isDisabledForUser(IUser $user): bool {
		$allowedGroups = $this->getAllowedTalkGroupIds();
		if (empty($allowedGroups)) {
			return false;
		}

		$userGroups = $this->groupManager->getUserGroupIds($user);
		return empty(array_intersect($allowedGroups, $userGroups));
	}

	/**
	 * @return string[]
	 */
	public function getAllowedConversationsGroupIds(): array {
		$groups = $this->config->getAppValue('spreed', 'start_conversations', '[]');
		$groups = json_decode($groups, true);
		return \is_array($groups) ? $groups : [];
	}

	public function isNotAllowedToCreateConversations(IUser $user): bool {
		$allowedGroups = $this->getAllowedConversationsGroupIds();
		if (empty($allowedGroups)) {
			return false;
		}

		$userGroups = $this->groupManager->getUserGroupIds($user);
		return empty(array_intersect($allowedGroups, $userGroups));
	}

	/**
	 * @return int<0, 511>
	 * @psalm-return int-mask-of<Attendee::PERMISSIONS_*>
	 */
	public function getDefaultPermissions(): int {
		// Admin configured default permissions
		$configurableDefault = $this->config->getAppValue('spreed', 'default_permissions');
		if ($configurableDefault !== '') {
			return min(Attendee::PERMISSIONS_MAX_CUSTOM, max(Attendee::PERMISSIONS_DEFAULT, (int)$configurableDefault));
		}

		// Falling back to an unrestricted set of permissions, only ignoring the lobby is off
		return Attendee::PERMISSIONS_MAX_DEFAULT & ~Attendee::PERMISSIONS_LOBBY_IGNORE;
	}

	public function getAttachmentFolder(string $userId): string {
		$defaultAttachmentFolder = $this->config->getAppValue('spreed', 'default_attachment_folder', '/Talk');
		return $this->config->getUserValue($userId, 'spreed', UserPreference::ATTACHMENT_FOLDER, $defaultAttachmentFolder);
	}

	/**
	 * Returns the per-conversation folder name: "<sanitizedDisplayName>-<token>"
	 * The display name is sanitized and trimmed to 64 characters.
	 *
	 * @throws \LogicException if the conversation-subfolders feature is disabled
	 */
	public function getConversationFolderName(Room $room, string $userId): string {
		if (!$this->isConversationSubfoldersEnabled()) {
			throw new \LogicException('getConversationFolderName called while conversation subfolders are disabled');
		}
		return $this->buildConversationFolderName($room->getDisplayName($userId), $room->getToken());
	}

	public function buildConversationFolderName(string $displayName, string $token): string {
		return $this->sanitizeDisplayName($displayName, 64) . '-' . $token;
	}

	/**
	 * Returns the per-user subfolder name within a conversation folder.
	 *
	 * Format: "<displayPrefix>-<uid>" where the prefix length is capped so that
	 * the total segment length stays under 64 characters on all filesystems.
	 * If the uid is 63+ characters, the prefix is omitted.
	 */
	public function getConversationSubfolderName(string $userId): string {
		if (!$this->isConversationSubfoldersEnabled()) {
			throw new \LogicException('getConversationSubfolderName called while conversation subfolders are disabled');
		}
		$displayName = $this->userManager->getDisplayName($userId) ?? '';
		return $this->buildConversationSubfolderName($userId, $displayName);
	}

	/**
	 * Builds the per-user subfolder name from a pre-fetched display name.
	 * Use this when the caller already holds the IUser object (e.g. the current
	 * user from IUserSession) to avoid an extra IUserManager::get() lookup.
	 */
	public function buildConversationSubfolderName(string $userId, string $displayName): string {
		$prefixLen = min(16, max(0, 63 - strlen($userId)));
		if ($prefixLen > 0) {
			$prefix = $this->sanitizeDisplayName($displayName, $prefixLen);
			if ($prefix !== '') {
				return $prefix . '-' . $userId;
			}
		}
		return $userId;
	}

	/**
	 * Sanitizes a display name for use as (part of) a filesystem folder name.
	 *
	 * Replaces forward slash, backslash, and ASCII control characters (U+0000–U+001F)
	 * with a space, trims whitespace, then truncates to $maxChars Unicode code points.
	 */
	private function sanitizeDisplayName(string $name, int $maxChars): string {
		// phpcs:ignore -- control characters are intentional
		$name = preg_replace('/[\x00-\x1f\/\\\\]+/', ' ', $name);
		$name = trim((string)$name);
		$name = $this->filenameValidator->sanitizeFilename($name, '_');
		if (mb_strlen($name) > $maxChars) {
			$name = trim(mb_substr($name, 0, $maxChars));
		}
		return rtrim($name, '_-');
	}

	/**
	 * @return string[]
	 */
	public function getAllServerUrlsForCSP(): array {
		$urls = [];

		foreach ($this->getStunServers() as $server) {
			$urls[] = $server;
		}

		foreach ($this->getTurnServers() as $server) {
			$urls[] = $server['server'];
		}

		foreach ($this->getSignalingServers() as $server) {
			$urls[] = $this->getWebSocketDomainForSignalingServer($server['server']);
		}

		return array_filter($urls);
	}

	protected function getWebSocketDomainForSignalingServer(string $url): string {
		if (str_ends_with($url, ':') || str_ends_with($url, ':/') || str_ends_with($url, '://')) {
			return '';
		}

		$url .= '/';
		if (str_starts_with($url, 'https://')) {
			return 'wss://' . substr($url, 8, strpos($url, '/', 8) - 8);
		}

		if (str_starts_with($url, 'http://')) {
			return 'ws://' . substr($url, 7, strpos($url, '/', 7) - 7);
		}

		if (str_starts_with($url, 'wss://')) {
			return substr($url, 0, strpos($url, '/', 6));
		}

		if (str_starts_with($url, 'ws://')) {
			return substr($url, 0, strpos($url, '/', 5));
		}

		$protocol = strpos($url, '://');
		if ($protocol !== false) {
			return substr($url, $protocol + 3, strpos($url, '/', $protocol + 3) - $protocol - 3);
		}

		return substr($url, 0, strpos($url, '/'));
	}

	/**
	 * @return string[]
	 */
	public function getStunServers(): array {
		$config = $this->config->getAppValue('spreed', 'stun_servers', json_encode(['stun.nextcloud.com:443']));
		$servers = json_decode($config, true);

		if (!is_array($servers) || empty($servers)) {
			$servers = ['stun.nextcloud.com:443'];
		}

		if (!$this->config->getSystemValueBool('has_internet_connection', true)) {
			$servers = array_filter($servers, static fn ($server) => $server !== 'stun.nextcloud.com:443');
		}

		return $servers;
	}

	/**
	 * Generates a username and password for the TURN server
	 *
	 * @return array
	 */
	public function getTurnServers(bool $withEvent = true): array {
		$config = $this->config->getAppValue('spreed', 'turn_servers');
		$servers = json_decode($config, true);

		if ($servers === null || empty($servers) || !is_array($servers)) {
			$servers = [];
		}

		if ($withEvent) {
			$event = new BeforeTurnServersGetEvent($servers);
			$this->dispatcher->dispatchTyped($event);
			$servers = $event->getServers();
		}

		foreach ($servers as $key => $server) {
			$servers[$key]['schemes'] = $server['schemes'] ?? 'turn';
		}

		return $servers;
	}

	/**
	 * Prepares a list of TURN servers with username and password
	 *
	 * @return array
	 */
	public function getTurnSettings(): array {
		$servers = $this->getTurnServers();

		if (empty($servers)) {
			return [];
		}

		// Credentials are valid for 24h
		// FIXME add the TTL to the response and properly reconnect then
		$timestamp = $this->timeFactory->getTime() + 86400;
		$rnd = $this->secureRandom->generate(16);
		$username = $timestamp . ':' . $rnd;

		foreach ($servers as $server) {
			$u = $server['username'] ?? $username;
			$password = $server['password'] ?? base64_encode(hash_hmac('sha1', (string)$u, (string)$server['secret'], true));

			$turnSettings[] = [
				'schemes' => $server['schemes'],
				'server' => $server['server'],
				'username' => $u,
				'password' => $password,
				'protocols' => $server['protocols'],
			];
		}

		return $turnSettings;
	}

	/**
	 * @psalm-return self::SIGNALING_INTERNAL|self::SIGNALING_EXTERNAL|self::SIGNALING_CLUSTER_CONVERSATION
	 */
	public function getSignalingMode(bool $cleanExternalSignaling = true): string {
		$validModes = [
			self::SIGNALING_INTERNAL,
			self::SIGNALING_EXTERNAL,
			self::SIGNALING_CLUSTER_CONVERSATION,
		];

		$mode = $this->config->getAppValue('spreed', 'signaling_mode', null);
		if ($mode === self::SIGNALING_INTERNAL) {
			return self::SIGNALING_INTERNAL;
		}

		$numSignalingServers = count($this->getSignalingServers());
		if ($numSignalingServers === 0) {
			return self::SIGNALING_INTERNAL;
		}
		if ($numSignalingServers === 1
			&& $cleanExternalSignaling) {
			return self::SIGNALING_EXTERNAL;
		}

		return $mode === self::SIGNALING_CLUSTER_CONVERSATION ? $mode : self::SIGNALING_EXTERNAL;
	}

	/**
	 * Returns list of signaling servers. Each entry contains the URL of the
	 * server and a flag whether the certificate should be verified.
	 *
	 * @return array
	 */
	public function getSignalingServers(): array {
		$config = $this->config->getAppValue('spreed', 'signaling_servers');
		$signaling = json_decode($config, true);
		if (!is_array($signaling) || !isset($signaling['servers'])) {
			return [];
		}

		return $signaling['servers'];
	}

	/**
	 * @return string
	 */
	public function getSignalingSecret(): string {
		$config = $this->config->getAppValue('spreed', 'signaling_servers');
		$signaling = json_decode($config, true);

		if (!is_array($signaling)) {
			return '';
		}

		return $signaling['secret'];
	}

	public function getHideSignalingWarning(): bool {
		return $this->config->getAppValue('spreed', 'hide_signaling_warning', 'no') === 'yes';
	}

	/**
	 * @param int $version
	 * @param string|null $userId
	 * @return string
	 */
	public function getSignalingTicket(int $version, ?string $userId, ?string $cloudId = null): string {
		return match ($version) {
			self::SIGNALING_TICKET_V2 => $this->getSignalingTicketV2($userId, $cloudId),
			default => $this->getSignalingTicketV1($userId),
		};
	}

	/**
	 * @param string|null $userId
	 * @return string
	 */
	private function getSignalingTicketV1(?string $userId): string {
		if (empty($userId)) {
			$secret = $this->config->getAppValue('spreed', 'signaling_ticket_secret');
		} else {
			$secret = $this->config->getUserValue($userId, 'spreed', 'signaling_ticket_secret');
		}
		if (empty($secret)) {
			// Create secret lazily on first access.
			// TODO(fancycode): Is there a possibility for a race condition?
			$secret = $this->secureRandom->generate(255);
			if (empty($userId)) {
				$this->config->setAppValue('spreed', 'signaling_ticket_secret', $secret);
			} else {
				$this->config->setUserValue($userId, 'spreed', 'signaling_ticket_secret', $secret);
			}
		}

		// Format is "random:timestamp:userid:checksum" and "checksum" is the
		// SHA256-HMAC of "random:timestamp:userid" with the per-user secret.
		$random = $this->secureRandom->generate(16);
		$timestamp = $this->timeFactory->getTime();
		$data = $random . ':' . $timestamp . ':' . $userId;
		$hash = hash_hmac('sha256', $data, $secret);
		return $data . ':' . $hash;
	}

	private function ensureSignalingTokenKeys(string $alg): void {
		$secret = $this->config->getAppValue('spreed', 'signaling_token_privkey_' . strtolower($alg));
		if ($secret) {
			return;
		}

		if (str_starts_with($alg, 'ES')) {
			$privKey = openssl_pkey_new([
				'curve_name' => $alg === 'ES384' ? 'secp384r1' : 'prime256v1',
				'private_key_bits' => 2048,
				'private_key_type' => OPENSSL_KEYTYPE_EC,
			]);
			$pubKey = openssl_pkey_get_details($privKey);
			$public = $pubKey['key'];
			if (!openssl_pkey_export($privKey, $secret)) {
				throw new \Exception('Could not export private key');
			}
		} elseif (str_starts_with($alg, 'RS')) {
			$privKey = openssl_pkey_new([
				'private_key_bits' => 2048,
				'private_key_type' => OPENSSL_KEYTYPE_RSA,
			]);
			$pubKey = openssl_pkey_get_details($privKey);
			$public = $pubKey['key'];
			if (!openssl_pkey_export($privKey, $secret)) {
				throw new \Exception('Could not export private key');
			}
		} elseif ($alg === 'EdDSA') {
			$privKey = sodium_crypto_sign_keypair();
			$public = base64_encode(sodium_crypto_sign_publickey($privKey));
			$secret = base64_encode(sodium_crypto_sign_secretkey($privKey));
		} else {
			throw new \Exception('Unsupported algorithm ' . $alg);
		}

		$this->config->setAppValue('spreed', 'signaling_token_privkey_' . strtolower($alg), $secret);
		$this->config->setAppValue('spreed', 'signaling_token_pubkey_' . strtolower($alg), $public);
	}

	public function getSignalingTokenAlgorithm(): string {
		return $this->config->getAppValue('spreed', 'signaling_token_alg', 'ES256');
	}

	public function getSignalingTokenPrivateKey(?string $alg = null): string {
		if (!$alg) {
			$alg = $this->getSignalingTokenAlgorithm();
		}
		$this->ensureSignalingTokenKeys($alg);

		return $this->config->getAppValue('spreed', 'signaling_token_privkey_' . strtolower($alg));
	}

	public function getSignalingTokenPublicKey(?string $alg = null): string {
		if (!$alg) {
			$alg = $this->getSignalingTokenAlgorithm();
		}
		$this->ensureSignalingTokenKeys($alg);

		return $this->config->getAppValue('spreed', 'signaling_token_pubkey_' . strtolower($alg));
	}

	public function deriveSignalingTokenPublicKey(string $privateKey, string $alg): string {
		// Clear any existing (unrelated) OpenSSL errors
		while (openssl_error_string() !== false);

		if (str_starts_with($alg, 'ES') || str_starts_with($alg, 'RS')) {
			$opensslPrivateKey = openssl_pkey_get_private($privateKey);
			$this->throwOnOpensslError();

			$pubKey = openssl_pkey_get_details($opensslPrivateKey);
			$this->throwOnOpensslError();

			$public = $pubKey['key'];
			if (!openssl_pkey_export($privateKey, $secret)) {
				throw new \Exception('Could not export private key');
			}
		} elseif ($alg === 'EdDSA') {
			$public = base64_encode(sodium_crypto_sign_publickey_from_secretkey($privateKey));
		} else {
			throw new \Exception('Unsupported algorithm ' . $alg);
		}

		return $public;
	}

	private function throwOnOpensslError() {
		$errors = [];
		while ($error = openssl_error_string()) {
			$errors[] = $error;
		}

		if (!empty($errors)) {
			throw new \Exception("OpenSSL error:\n" . implode("\n", $errors));
		}
	}

	/**
	 * @param IUser $user
	 * @return array
	 */
	public function getSignalingUserData(IUser $user): array {
		return [
			'displayname' => $user->getDisplayName(),
		];
	}

	public function getSignalingFederatedUserData(): array {
		/** @var Authenticator $authenticator */
		$authenticator = \OCP\Server::get(Authenticator::class);
		if (!$authenticator->isFederationRequest()) {
			return [];
		}

		return [
			'displayname' => $authenticator->getParticipant()->getAttendee()->getDisplayName(),
		];
	}

	/**
	 * @param string|null $userId if given, the id of a user in this instance or
	 *                            a cloud id.
	 * @return string
	 */
	private function getSignalingTicketV2(?string $userId, ?string $cloudId): string {
		$timestamp = $this->timeFactory->getTime();
		$data = [
			'iss' => $this->urlGenerator->getAbsoluteURL(''),
			'iat' => $timestamp,
			'exp' => $timestamp + 60,  // Valid for 1 minute.
		];
		$user = $userId !== null ? $this->userManager->get($userId) : null;
		if ($user instanceof IUser) {
			$data['sub'] = $user->getUID();
			$data['userdata'] = $this->getSignalingUserData($user);
		} elseif ($cloudId !== null && $cloudId !== '') {
			$data['sub'] = $cloudId;
			$extendedData = $this->getSignalingFederatedUserData();
			if (!empty($extendedData)) {
				$data['userdata'] = $extendedData;
			}
		}

		$alg = $this->getSignalingTokenAlgorithm();
		$secret = $this->getSignalingTokenPrivateKey($alg);
		$token = JWT::encode($data, $secret, $alg);
		return $token;
	}

	/**
	 * @param string|null $userId
	 * @param string $ticket
	 * @return bool
	 */
	public function validateSignalingTicket(?string $userId, string $ticket): bool {
		if (empty($userId)) {
			$secret = $this->config->getAppValue('spreed', 'signaling_ticket_secret');
		} else {
			$secret = $this->config->getUserValue($userId, 'spreed', 'signaling_ticket_secret');
		}
		if (empty($secret)) {
			return false;
		}

		$lastColon = strrpos($ticket, ':');
		if ($lastColon === false) {
			// Immediately reject invalid formats.
			return false;
		}

		// TODO(fancycode): Should we reject tickets that are too old?
		$data = substr($ticket, 0, $lastColon);
		$hash = hash_hmac('sha256', $data, $secret);
		return hash_equals($hash, substr($ticket, $lastColon + 1));
	}

	public function getGridVideosLimit(): int {
		return (int)$this->config->getAppValue('spreed', 'grid_videos_limit', '19'); // 5*4 - self
	}

	public function getGridVideosLimitEnforced(): bool {
		return $this->config->getAppValue('spreed', 'grid_videos_limit_enforced', 'no') === 'yes';
	}

	/**
	 * User setting falling back to admin defined app config
	 *
	 * @param ?string $userId
	 * @return bool
	 */
	public function getCallsStartWithoutMedia(?string $userId): bool {
		if ($userId !== null) {
			$userSetting = $this->config->getUserValue($userId, 'spreed', UserPreference::CALLS_START_WITHOUT_MEDIA);
			if ($userSetting === 'yes' || $userSetting === 'no') {
				return $userSetting === 'yes';
			}
		}

		return $this->appConfig->getAppValueBool('calls_start_without_media');
	}

	/**
	 * User setting for blur background
	 *
	 * @param ?string $userId
	 * @return bool
	 */
	public function getBlurVirtualBackground(?string $userId): bool {
		if ($userId !== null) {
			$userSetting = $this->config->getUserValue($userId, 'spreed', UserPreference::BLUR_VIRTUAL_BACKGROUND);
			return $userSetting === 'yes';
		}
		return false;
	}

	/**
	 * User setting falling back to admin defined app config
	 *
	 * @param ?string $userId
	 * @return UserPreference::CONVERSATION_LIST_STYLE_*
	 */
	public function getConversationsListStyle(?string $userId): string {
		if ($userId !== null) {
			$userSetting = $this->config->getUserValue(
				$userId,
				'spreed',
				UserPreference::CONVERSATIONS_LIST_STYLE,
			);

			if (in_array($userSetting, [UserPreference::CONVERSATION_LIST_STYLE_TWO_LINES, UserPreference::CONVERSATION_LIST_STYLE_COMPACT], true)) {
				return $userSetting;
			}
		}

		$appSetting = $this->appConfig->getAppValueString(UserPreference::CONVERSATIONS_LIST_STYLE);
		if (in_array($appSetting, [UserPreference::CONVERSATION_LIST_STYLE_TWO_LINES, UserPreference::CONVERSATION_LIST_STYLE_COMPACT], true)) {
			return $appSetting;
		}

		return UserPreference::CONVERSATION_LIST_STYLE_TWO_LINES;
	}

	/**
	 * User setting falling back to admin defined app config
	 *
	 * @param ?string $userId
	 * @return UserPreference::CHAT_STYLE_*
	 */
	public function getChatStyle(?string $userId): string {
		if ($userId !== null) {
			$userSetting = $this->config->getUserValue(
				$userId,
				'spreed',
				UserPreference::CHAT_STYLE,
			);

			if (in_array($userSetting, [UserPreference::CHAT_STYLE_SPLIT, UserPreference::CHAT_STYLE_UNIFIED], true)) {
				return $userSetting;
			}
		}

		$appSetting = $this->appConfig->getAppValueString(UserPreference::CHAT_STYLE);
		if (in_array($appSetting, [UserPreference::CHAT_STYLE_SPLIT, UserPreference::CHAT_STYLE_UNIFIED], true)) {
			return $appSetting;
		}

		return UserPreference::CHAT_STYLE_SPLIT;
	}

	/**
	 * User setting for conversations sort order
	 *
	 * @param ?string $userId
	 * @return UserPreference::CONVERSATIONS_SORT_ORDER_*
	 */
	public function getConversationsSortOrder(?string $userId): string {
		if ($userId !== null) {
			$userSetting = $this->config->getUserValue(
				$userId,
				'spreed',
				UserPreference::CONVERSATIONS_SORT_ORDER,
			);

			if (in_array($userSetting, [UserPreference::CONVERSATIONS_SORT_ORDER_ACTIVITY, UserPreference::CONVERSATIONS_SORT_ORDER_ALPHABETICAL], true)) {
				return $userSetting;
			}
		}

		return UserPreference::CONVERSATIONS_SORT_ORDER_ACTIVITY;
	}

	/**
	 * User setting for conversations group mode
	 *
	 * @param ?string $userId
	 * @return UserPreference::CONVERSATIONS_GROUP_MODE_*
	 */
	public function getConversationsGroupMode(?string $userId): string {
		if ($userId !== null) {
			$userSetting = $this->config->getUserValue(
				$userId,
				'spreed',
				UserPreference::CONVERSATIONS_GROUP_MODE,
			);

			if (in_array($userSetting, [
				UserPreference::CONVERSATIONS_GROUP_MODE_NONE,
				UserPreference::CONVERSATIONS_GROUP_MODE_GROUP_FIRST,
				UserPreference::CONVERSATIONS_GROUP_MODE_PRIVATE_FIRST,
			], true)) {
				return $userSetting;
			}
		}

		return UserPreference::CONVERSATIONS_GROUP_MODE_NONE;
	}

	/**
	 * User setting falling back to admin defined app config
	 */
	public function getInactiveLockTime(): int {
		return $this->appConfig->getAppValueInt('inactivity_lock_after_days');
	}

	public function enableLobbyOnLockedRooms(): bool {
		return $this->appConfig->getAppValueBool('inactivity_enable_lobby');
	}

	/**
	 * @param self::EXPERIMENTAL_* $experiment
	 */
	public function hasExperiment(int $experiment): bool {
		return $this->appConfig->getAppValueInt('experiments_users') & $experiment
			|| $this->appConfig->getAppValueInt('experiments_guests') & $experiment;
	}

	public function isPasswordEnforced(): bool {
		return $this->appConfig->getAppValueBool('force_passwords');
	}

	public function isCallEndToEndEncryptionEnabled(): bool {
		if ($this->getSignalingMode() !== self::SIGNALING_EXTERNAL) {
			return false;
		}

		// TODO Default value will be set to true, once all mobile clients support it.
		return $this->appConfig->getAppValueBool('call_end_to_end_encryption');
	}

	public function getPlaySoundsForUser(?IUser $user): bool {
		if (!$user instanceof IUser) {
			return $this->getPlaySoundsDefaultForGuests();
		}
		return $this->userConfig->getValueBool($user->getUID(), Application::APP_ID, UserPreference::PLAY_SOUNDS);
	}

	public function getPlaySoundsDefaultForGuests(): bool {
		return $this->appConfig->getAppValueBool('guests_play_sounds', true);
	}
}
