<?php

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

namespace OCA\Talk;

use OC\Authentication\Token\IProvider as IAuthTokenProvider;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Events\AAttendeeRemovedEvent;
use OCA\Talk\Exceptions\ImpossibleToKillException;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Exceptions\WrongPermissionsException;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Service\ParticipantService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Authentication\Token\IToken;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IAvatarManager;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\Security\ISecureRandom;
use Psr\Log\LoggerInterface;

/**
 * @psalm-import-type TalkMatterbridge from ResponseDefinitions
 * @psalm-import-type TalkMatterbridgeProcessState from ResponseDefinitions
 */
class MatterbridgeManager {
	public const BRIDGE_BOT_USERID = 'bridge-bot';

	public function __construct(
		private IDBConnection $db,
		private IConfig $config,
		private IURLGenerator $urlGenerator,
		private IUserManager $userManager,
		private Manager $manager,
		private ParticipantService $participantService,
		private ChatManager $chatManager,
		private IAuthTokenProvider $tokenProvider,
		private ISecureRandom $random,
		private IAvatarManager $avatarManager,
		private LoggerInterface $logger,
		private ITimeFactory $timeFactory,
	) {
	}

	/**
	 * Get bridge information for a specific room
	 *
	 * @param Room $room the room
	 * @return TalkMatterbridge
	 */
	public function getBridgeOfRoom(Room $room): array {
		return $this->getBridgeFromDb($room);
	}

	/**
	 * Get bridge process information for a specific room
	 *
	 * @param Room $room the room
	 * @return TalkMatterbridgeProcessState process state and log
	 */
	public function getBridgeProcessState(Room $room): array {
		$bridge = $this->getBridgeFromDb($room);

		$logContent = $this->getBridgeLog($room);

		$pid = $this->checkBridgeProcess($room, $bridge, false);
		return [
			'running' => ($pid !== 0),
			'log' => $logContent
		];
	}

	/**
	 * Get bridge log file content
	 *
	 * @param Room $room the room
	 * @return string log file content
	 */
	public function getBridgeLog(Room $room): string {
		$outputPath = sprintf('/tmp/bridge-%s.log', $room->getToken());
		$logContent = file_get_contents($outputPath);
		return $logContent !== false ? $logContent : '';
	}

	/**
	 * Edit bridge information for a room
	 *
	 * @param Room $room the room
	 * @param string $userId
	 * @param bool $enabled desired state of the bridge
	 * @param array $parts parts of the bridge (what it connects to)
	 * @return TalkMatterbridgeProcessState
	 */
	public function editBridgeOfRoom(Room $room, string $userId, bool $enabled, array $parts = []): array {
		$currentBridge = $this->getBridgeOfRoom($room);
		// kill matterbridge if we edit a running bridge config file so that it will be launched again
		// matterbridge dynamic config reload does not fully work
		if ($currentBridge['enabled'] && $enabled && $currentBridge['pid'] && $currentBridge['pid'] !== 0) {
			$this->killPid($currentBridge['pid']);
		}
		$newBridge = [
			'enabled' => $enabled,
			'pid' => $currentBridge['pid'] ?? 0,
			'parts' => $this->validateParts($parts),
		];

		$this->notify($room, $userId, $currentBridge, $newBridge);

		$this->writeBridgeConfig($room, $newBridge);

		// check state and manage the binary
		$pid = $this->checkBridgeProcess($room, $newBridge);
		$newBridge['pid'] = $pid;

		// save config
		$this->saveBridgeToDb($room->getId(), $newBridge);

		$logContent = $this->getBridgeLog($room);
		return [
			'running' => ($pid !== 0),
			'log' => $logContent
		];
	}

	/**
	 * Delete bridge information for a room
	 *
	 * @param Room $room the room
	 * @return bool success
	 */
	public function deleteBridgeOfRoom(Room $room): bool {
		// first potentially kill the process
		$currentBridge = $this->getBridgeOfRoom($room);
		$currentBridge['enabled'] = false;
		$this->checkBridgeProcess($room, $currentBridge);
		// then actually delete the config
		$this->config->deleteAppValue('spreed', 'bridge_' . $room->getToken());
		return true;
	}

	/**
	 * Check everything bridge-related is running fine
	 * For each room, check mattermost process respects desired state
	 */
	public function checkAllBridges(): void {
		$query = $this->db->getQueryBuilder();
		$query->select('*')
			->from('talk_bridges')
			->where($query->expr()->eq('enabled', $query->createNamedParameter(1, IQueryBuilder::PARAM_INT)));

		$result = $query->executeQuery();
		while ($row = $result->fetch()) {
			$bridge = [
				'enabled' => (bool)$row['enabled'],
				'pid' => (int)$row['pid'],
				'parts' => json_decode($row['json_values'], true),
			];
			try {
				$room = $this->manager->getRoomById((int)$row['room_id']);
			} catch (RoomNotFoundException $e) {
				continue;
			}
			$this->checkBridge($room, $bridge);
		}
		$result->closeCursor();
	}

	/**
	 * For one room, check mattermost process respects desired state
	 * @param Room $room the room
	 * @param array|null $bridge
	 * @return int the bridge process ID
	 */
	public function checkBridge(Room $room, ?array $bridge = null): int {
		$bridge = $bridge ?: $this->getBridgeOfRoom($room);
		$pid = $this->checkBridgeProcess($room, $bridge);
		if ($pid !== $bridge['pid']) {
			// save the new PID if necessary
			$bridge['pid'] = $pid;
			$this->saveBridgeToDb($room->getId(), $bridge);
		}
		return $pid;
	}

	/**
	 * Write the mattermost configuration file for one room
	 * This method takes care of connecting the bridge to the Talk room with a bot user
	 *
	 * @param Room $room
	 * @param array $newBridge
	 */
	private function writeBridgeConfig(Room $room, array $newBridge): void {
		// check bot user exists and is member of the room
		// add the 'local' bridge part
		$newBridge = $this->addLocalPart($room, $newBridge);

		// TODO adapt that to use appData
		$configPath = sprintf('/tmp/bridge-%s.toml', $room->getToken());
		$configContent = $this->generateConfig($newBridge);

		// Create the config file and set permissions on it
		touch($configPath);
		chmod($configPath, 0600);

		file_put_contents($configPath, $configContent);
	}

	/**
	 * Add a bridge part with bot credentials to connect to the room
	 *
	 * @param Room $room the room
	 * @param array $bridge bridge information
	 * @return array the bridge with local part added
	 */
	private function addLocalPart(Room $room, array $bridge): array {
		$botInfo = $this->checkBotUser($room, $bridge['enabled']);
		$localPart = [
			'type' => 'nctalk',
			'login' => $botInfo['id'],
			'password' => $botInfo['password'],
			'channel' => $room->getToken(),
			'skiptls' => true,
		];
		$bridge['parts'][] = $localPart;
		return $bridge;
	}

	/**
	 * routine to check the Nextcloud bot user exists (and create it if not)
	 * and to add it in the room in necessary
	 * and to revoke its old app token
	 * and to generate a new app token (used to connect via matterbridge)
	 *
	 * @param Room $room the room
	 * @param bool $isBridgeEnabled whether we should add the bot and generate a new app token or remove the bot from the room
	 * @return array Bot user information (username and app token). token is an empty string if creation was not asked.
	 */
	private function checkBotUser(Room $room, bool $isBridgeEnabled): array {
		// check if user exists and create it if necessary
		if (!$this->userManager->userExists(self::BRIDGE_BOT_USERID)) {
			$pass = $this->generatePassword();
			$this->config->setAppValue('spreed', 'bridge_bot_password', $pass);
			$botUser = $this->userManager->createUser(self::BRIDGE_BOT_USERID, $pass);
			// set avatar
			$avatar = $this->avatarManager->getAvatar(self::BRIDGE_BOT_USERID);
			$imageData = file_get_contents(\OC::$SERVERROOT . '/apps/spreed/img/bridge-bot.png');
			$avatar->set($imageData);
		} else {
			$botUser = $this->userManager->get(self::BRIDGE_BOT_USERID);
		}

		// check if the bot user is member of the room and add or remove it
		try {
			$this->participantService->getParticipant($room, self::BRIDGE_BOT_USERID, false);
			if (!$isBridgeEnabled) {
				$this->participantService->removeUser($room, $botUser, AAttendeeRemovedEvent::REASON_REMOVED);
			}
		} catch (ParticipantNotFoundException $e) {
			if ($isBridgeEnabled) {
				$this->participantService->addUsers($room, [[
					'actorType' => Attendee::ACTOR_USERS,
					'actorId' => self::BRIDGE_BOT_USERID,
					'displayName' => $botUser->getDisplayName(),
					'participantType' => Participant::USER,
				]]);
			}
		}

		// delete old bot app tokens for this room
		$tokenName = 'spreed_' . $room->getToken();
		$tokens = $this->tokenProvider->getTokenByUser(self::BRIDGE_BOT_USERID);
		foreach ($tokens as $t) {
			if ($t->getName() === $tokenName) {
				$this->tokenProvider->invalidateTokenById(self::BRIDGE_BOT_USERID, $t->getId());
			}
		}

		if ($isBridgeEnabled) {
			// generate app token for the bot
			$appToken = $this->generatePassword();
			$botPassword = $this->config->getAppValue('spreed', 'bridge_bot_password', '');
			$generatedToken = $this->tokenProvider->generateToken(
				$appToken,
				self::BRIDGE_BOT_USERID,
				self::BRIDGE_BOT_USERID,
				$botPassword,
				$tokenName,
				IToken::PERMANENT_TOKEN,
				IToken::REMEMBER
			);
		} else {
			$appToken = '';
		}

		return [
			'id' => self::BRIDGE_BOT_USERID,
			'password' => $appToken,
		];
	}

	private function generatePassword(): string {
		// remove \ and " because it messes with Matterbridge toml file parsing
		$symbols = str_replace(['"', '\\'], '', ISecureRandom::CHAR_SYMBOLS);

		// make sure we have at least one of all categories
		$upper = $this->random->generate(1, ISecureRandom::CHAR_UPPER);
		$lower = $this->random->generate(1, ISecureRandom::CHAR_LOWER);
		$digit = $this->random->generate(1, ISecureRandom::CHAR_DIGITS);
		$symbol = $this->random->generate(1, $symbols);

		$randomString = $this->random->generate(68, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS . $symbols);

		$password = $upper . $lower . $digit . $symbol . $randomString;
		$password = str_shuffle($password);
		return $password;
	}

	/**
	 * Actually generate the matterbridge configuration file content for one bridge (one room)
	 * It basically add a pair of sections for each part: authentication and target channel
	 *
	 * @param array $bridge
	 * @return string config file content
	 */
	private function generateConfig(array $bridge): string {
		$content = '';
		foreach ($bridge['parts'] as $k => $part) {
			$k = (int)$k;
			$type = $part['type'];

			if ($type === 'nctalk') {
				$content .= sprintf('[%s.%s]', $type, $k) . "\n";
				if (isset($part['server']) && $part['server'] !== '') {
					$serverUrl = $part['server'];
				} else {
					$serverUrl = preg_replace('/\/+$/', '', $this->urlGenerator->getAbsoluteURL(''));
					$content .= '	SeparateDisplayName = true' . "\n";
					// TODO remove that
					//$serverUrl = preg_replace('/https:/', 'http:', $serverUrl);
				}
				if ($part['skiptls']) {
					$content .= '	SkipTLSVerify = true' . "\n";
				}
				$content .= sprintf('	Server = "%s"', $serverUrl) . "\n";
				$content .= sprintf('	Login = "%s"', $part['login']) . "\n";
				$content .= sprintf('	Password = "%s"', $part['password']) . "\n";
				$content .= '	PrefixMessagesWithNick = true' . "\n";
				$content .= '	RemoteNickFormat="[{PROTOCOL}] <{NICK}> "' . "\n\n";
			} elseif ($type === 'mattermost') {
				// remove protocol from server URL
				if (preg_match('/^https?:/', $part['server'])) {
					$part['server'] = $this->cleanUrl($part['server']);
				}
				$content .= sprintf('[%s]', $type) . "\n";
				$content .= sprintf('	[%s.%s]', $type, $k) . "\n";
				$content .= sprintf('	Server = "%s"', $part['server']) . "\n";
				$content .= sprintf('	Team = "%s"', $part['team']) . "\n";
				$content .= sprintf('	Login = "%s"', $part['login']) . "\n";
				$content .= sprintf('	Password = "%s"', $part['password']) . "\n";
				$content .= '	PrefixMessagesWithNick = true' . "\n";
				$content .= '	RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n";
			} elseif ($type === 'matrix') {
				$content .= sprintf('[%s.%s]', $type, $k) . "\n";
				$content .= sprintf('	Server = "%s"', $part['server']) . "\n";
				$content .= sprintf('	Login = "%s"', $part['login']) . "\n";
				$content .= sprintf('	Password = "%s"', $part['password']) . "\n";
				$content .= '	PrefixMessagesWithNick = true' . "\n";
				$content .= '	NoHomeServerSuffix = true' . "\n";
				$content .= '	RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n";
			} elseif ($type === 'zulip') {
				$content .= sprintf('[%s.%s]', $type, $k) . "\n";
				$content .= sprintf('	Server = "%s"', $part['server']) . "\n";
				$content .= sprintf('	Login = "%s"', $part['login']) . "\n";
				$content .= sprintf('	Token = "%s"', $part['token']) . "\n";
				$content .= '	PrefixMessagesWithNick = true' . "\n";
				$content .= '	RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n";
			} elseif ($type === 'rocketchat') {
				// include # in channel
				if (!str_starts_with($part['channel'], '#')) {
					$bridge['parts'][$k]['channel'] = '#' . $part['channel'];
				}
				$content .= sprintf('[%s.%s]', $type, $k) . "\n";
				$content .= sprintf('	Server = "%s"', $part['server']) . "\n";
				$content .= sprintf('	Login = "%s"', $part['login']) . "\n";
				$content .= sprintf('	Password = "%s"', $part['password']) . "\n";
				if ($part['skiptls']) {
					$content .= '	SkipTLSVerify = true' . "\n";
				}
				$content .= '	PrefixMessagesWithNick = true' . "\n";
				$content .= '	RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n";
			} elseif ($type === 'slack') {
				// do not include # in channel
				if (str_starts_with($part['channel'], '#')) {
					$bridge['parts'][$k]['channel'] = ltrim($part['channel'], '#');
				}
				$content .= sprintf('[%s.%s]', $type, $k) . "\n";
				$content .= sprintf('	Token = "%s"', $part['token']) . "\n";
				$content .= '	PrefixMessagesWithNick = true' . "\n";
				$content .= '	RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n";
			} elseif ($type === 'discord') {
				// do not include # in channel
				if (str_starts_with($part['channel'], '#')) {
					$bridge['parts'][$k]['channel'] = ltrim($part['channel'], '#');
				}
				$content .= sprintf('[%s.%s]', $type, $k) . "\n";
				$content .= sprintf('	Token = "%s"', $part['token']) . "\n";
				$content .= sprintf('	Server = "%s"', $part['server']) . "\n";
				$content .= '	PrefixMessagesWithNick = true' . "\n";
				$content .= '	RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n";
			} elseif ($type === 'telegram') {
				$content .= sprintf('[%s.%s]', $type, $k) . "\n";
				$content .= sprintf('	Token = "%s"', $part['token']) . "\n";
				$content .= '	PrefixMessagesWithNick = true' . "\n";
				$content .= '	RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n";
			} elseif ($type === 'steam') {
				$content .= sprintf('[%s.%s]', $type, $k) . "\n";
				$content .= sprintf('	Login = "%s"', $part['login']) . "\n";
				$content .= sprintf('	Password = "%s"', $part['password']) . "\n";
				$content .= '	PrefixMessagesWithNick = true' . "\n";
				$content .= '	RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n";
			} elseif ($type === 'irc') {
				// include # in channel
				if (!str_starts_with($part['channel'], '#')) {
					$bridge['parts'][$k]['channel'] = '#' . $part['channel'];
				}
				$content .= sprintf('[%s.%s]', $type, $k) . "\n";
				$content .= sprintf('	Server = "%s"', $part['server']) . "\n";
				if ($part['password']) {
					$content .= sprintf('	Password = "%s"', $part['password']) . "\n";
				}
				$content .= sprintf('	Nick = "%s"', $part['nick']) . "\n";
				if ($part['nickservnick']) {
					$content .= sprintf('	NickServNick = "%s"', $part['nickservnick']) . "\n";
					$content .= sprintf('	NickServPassword = "%s"', $part['nickservpassword']) . "\n";
				}
				if ($part['usetls']) {
					$content .= '	UseTLS = true' . "\n";
				}
				if ($part['usesasl']) {
					$content .= '	UseSASL = true' . "\n";
				}
				if ($part['skiptls']) {
					$content .= '	SkipTLSVerify = true' . "\n";
				}
				$content .= '	PrefixMessagesWithNick = true' . "\n";
				$content .= '	RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n";
			} elseif ($type === 'msteams') {
				$content .= sprintf('[%s.%s]', $type, $k) . "\n";
				$content .= sprintf('	TenantID = "%s"', $part['tenantid']) . "\n";
				$content .= sprintf('	ClientID = "%s"', $part['clientid']) . "\n";
				$content .= sprintf('	TeamID = "%s"', $part['teamid']) . "\n";
				$content .= '	PrefixMessagesWithNick = true' . "\n";
				$content .= '	RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n";
			} elseif ($type === 'xmpp') {
				$content .= sprintf('[%s.%s]', $type, $k) . "\n";
				$content .= sprintf('	Server = "%s"', $part['server']) . "\n";
				$content .= sprintf('	Jid = "%s"', $part['jid']) . "\n";
				$content .= sprintf('	Password = "%s"', $part['password']) . "\n";
				$content .= sprintf('	Muc = "%s"', $part['muc']) . "\n";
				$content .= sprintf('	Nick = "%s"', $part['nick']) . "\n";
				if ($part['skiptls']) {
					$content .= '	SkipTLSVerify = true' . "\n";
				}
				$content .= '	PrefixMessagesWithNick = true' . "\n";
				$content .= '	RemoteNickFormat = "[{PROTOCOL}] <{NICK}> "' . "\n\n";
			}
		}

		$content .= '[[gateway]]' . "\n";
		$content .= '	name = "myGateway"' . "\n";
		$content .= '	enable = true' . "\n\n";

		foreach ($bridge['parts'] as $k => $part) {
			$type = $part['type'];

			$content .= '[[gateway.inout]]' . "\n";
			$content .= sprintf('	account = "%s.%s"', $type, $k) . "\n";
			$content .= sprintf('	channel = "%s"', $part['channel']) . "\n";
			if ($type === 'irc' && $part['channelpassword']) {
				$content .= sprintf('	options = { key = "%s" }', $part['channelpassword']) . "\n";
			}
			$content .= "\n";
		}

		return $content;
	}

	protected function validateParts(array $parts): array {
		foreach ($parts as $k => $part) {
			if (!is_numeric($k)) {
				$this->logger->error('User tried to configure a malicious matterbridge setup');
				throw new \InvalidArgumentException('Invalid matterbridge parameters');
			}
			foreach ($part as $key => $value) {
				if (preg_match('/["\n]/', $key) || preg_match('/["\n]/', $value)) {
					$this->logger->error('User tried to configure a malicious matterbridge setup');
					throw new \InvalidArgumentException('Invalid matterbridge parameters');
				}
			}
		}
		return $parts;
	}

	/**
	 * Remove the scheme from an URL and add port
	 *
	 * @param string $url
	 * @return string
	 */
	private function cleanUrl(string $url): string {
		$uo = parse_url($url);
		$result = $uo['host'];
		if ($uo['scheme'] === 'https' && !isset($uo['port'])) {
			$result .= ':443';
		} elseif (isset($uo['port'])) {
			$result .= ':' . $uo['port'];
		}
		$result .= $uo['path'];
		return $result;
	}

	/**
	 * check if a bridge process is running
	 *
	 * @param Room $room the room
	 * @param array $bridge bridge information
	 * @param bool $relaunch whether to launch the process if it's down but bridge is enabled
	 * @return int the corresponding matterbridge process ID, 0 if none
	 */
	private function checkBridgeProcess(Room $room, array $bridge, bool $relaunch = true): int {
		$pid = 0;

		if (isset($bridge['pid']) && (int)$bridge['pid'] !== 0) {
			// config : there is a PID stored
			$isRunning = $this->isRunning($bridge['pid']);
			// if bridge running and enabled is false : kill it
			if ($isRunning) {
				if ($bridge['enabled']) {
					$this->logger->info('Process running AND bridge enabled in config : doing nothing');
					$pid = $bridge['pid'];
				} else {
					$this->logger->info('Process running AND bridge disabled in config : KILL ' . $bridge['pid']);
					$killed = $this->killPid($bridge['pid']);
					if ($killed) {
						$pid = 0;
					} else {
						$this->logger->info('Impossible to kill ' . $bridge['pid']);
						throw new ImpossibleToKillException('Impossible to kill bridge process [' . $bridge['pid'] . ']');
					}
				}
			} else {
				// no process found
				if ($bridge['enabled']) {
					if ($relaunch) {
						$this->logger->info('Process not found AND bridge enabled in config : relaunching');
						$pid = $this->launchMatterbridge($room);
					}
				} else {
					$this->logger->info('Process not found AND bridge disabled in config : doing nothing');
				}
			}
		} elseif ($bridge['enabled']) {
			if ($relaunch) {
				// config : no PID stored
				// config : enabled => launch it
				$pid = $this->launchMatterbridge($room);
				$this->logger->info('Launch process, PID is ' . $pid);
			}
		} else {
			$this->logger->info('No PID defined in config AND bridge disabled in config : doing nothing');
		}

		return $pid;
	}

	/**
	 * Send system message to the room if necessary
	 *
	 * @param Room $room the room
	 * @param string $userId the editing user
	 * @param array $currentBridge previous bridge values
	 * @param array $newBridge future bridge values
	 */
	private function notify(Room $room, string $userId, array $currentBridge, array $newBridge): void {
		$currentParts = $currentBridge['parts'];
		$newParts = $newBridge['parts'];
		if ($currentBridge['enabled'] && !$newBridge['enabled']) {
			$this->sendSystemMessage($room, $userId, 'matterbridge_config_disabled');
		} elseif (!$currentBridge['enabled'] && $newBridge['enabled']) {
			$this->sendSystemMessage($room, $userId, 'matterbridge_config_enabled');
		} elseif (empty($currentParts) && !empty($newParts)) {
			$this->sendSystemMessage($room, $userId, 'matterbridge_config_added');
		} elseif (!empty($currentParts) && empty($newParts)) {
			$this->sendSystemMessage($room, $userId, 'matterbridge_config_removed');
		} elseif (count($currentParts) !== count($newParts) || !$this->compareBridges($currentBridge, $newBridge)) {
			$this->sendSystemMessage($room, $userId, 'matterbridge_config_edited');
		}
	}

	/**
	 * Check if 2 bridge configurations are identical
	 *
	 * @param array $bridge1
	 * @param array $bridge2
	 * @return bool True if they are strictly equivalent
	 */
	private function compareBridges(array $bridge1, array $bridge2): bool {
		// try to find an equivalent for each bridge part of one side in the other
		foreach ($bridge1['parts'] as $part1) {
			$found = false;
			foreach ($bridge2['parts'] as $part2) {
				if ($this->compareBridgeParts($part1, $part2)) {
					$found = true;
					break;
				}
			}
			if (!$found) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Check if 2 bridge parts are identical
	 *
	 * @param array $part1
	 * @param array $part2
	 * @return bool True if they are strictly equivalent
	 */
	private function compareBridgeParts(array $part1, array $part2): bool {
		$keys1 = array_keys($part1);
		$keys2 = array_keys($part2);
		if (count($keys1) !== count($keys2)) {
			return false;
		}

		foreach ($part1 as $key => $val) {
			if (!isset($part2[$key]) || $val !== $part2[$key]) {
				return false;
			}
		}

		return true;
	}

	/**
	 * actually send the system message
	 * @param Room $room the room
	 * @param string $userId the involved user
	 * @param string $message the message ID
	 */
	private function sendSystemMessage(Room $room, string $userId, string $message): void {
		$this->chatManager->addSystemMessage(
			$room,
			null,
			Attendee::ACTOR_USERS,
			$userId,
			json_encode(['message' => $message, 'parameters' => []]),
			$this->timeFactory->getDateTime(),
			false
		);
	}

	/**
	 * Actually launch a matterbridge process for a room
	 *
	 * @param Room $room the room
	 * @return int the corresponding matterbridge process ID, 0 if it failed
	 */
	private function launchMatterbridge(Room $room): int {
		$binaryPath = $this->config->getAppValue('spreed', 'matterbridge_binary');
		$configPath = sprintf('/tmp/bridge-%s.toml', $room->getToken());

		// recreate config file if it's not there (can happen after a reboot)
		if (!file_exists($configPath)) {
			$currentBridge = $this->getBridgeOfRoom($room);
			$this->writeBridgeConfig($room, $currentBridge);
		}

		$outputPath = sprintf('/tmp/bridge-%s.log', $room->getToken());
		$matterbridgeCmd = sprintf('%s -conf %s', $binaryPath, $configPath);
		$cmd = sprintf('nice -n19 %s > %s 2>&1 & echo $!', $matterbridgeCmd, $outputPath);

		// Create the log file and set permissions on it
		touch($outputPath);
		chmod($outputPath, 0600);

		$cmdResult = $this->runCommand($cmd);
		if (!is_null($cmdResult) && $cmdResult['return_code'] === 0 && is_numeric($cmdResult['stdout'] ?? 0)) {
			return (int)$cmdResult['stdout'];
		}
		return 0;
	}

	/**
	 * kill the mattermost processes (owned by web server unix user) that do not match with any room
	 * @param bool $killAll
	 */
	public function killZombieBridges(bool $killAll = false): void {
		// get list of running matterbridge processes
		$runningPidList = [];
		$cmd = 'ps x -o user,pid,args';
		$cmdResult = $this->runCommand($cmd);
		if ($cmdResult && $cmdResult['return_code'] === 0) {
			$lines = explode("\n", $cmdResult['stdout']);
			foreach ($lines as $l) {
				if (preg_match('/matterbridge/i', $l)) {
					$items = preg_split('/\s+/', $l);
					if (count($items) > 1 && is_numeric($items[1])) {
						$runningPidList[] = (int)$items[1];
					}
				}
			}
		}

		if (empty($runningPidList)) {
			// No processes running, so also no zombies
			return;
		}

		if ($killAll) {
			foreach ($runningPidList as $runningPid) {
				$this->killPid($runningPid);
			}
			return;
		}

		// get list of what should be running
		$expectedPidList = [];
		$query = $this->db->getQueryBuilder();
		$query->select('*')
			->from('talk_bridges')
			->where($query->expr()->eq('enabled', $query->createNamedParameter(1, IQueryBuilder::PARAM_INT)))
			->andWhere($query->expr()->gt('pid', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT)));

		$result = $query->executeQuery();
		while ($row = $result->fetch()) {
			$expectedPidList[] = (int)$row['pid'];
		}
		$result->closeCursor();

		// kill what should not be running
		$toKill = array_diff($runningPidList, $expectedPidList);
		foreach ($toKill as $toKillPid) {
			$this->killPid($toKillPid);
		}
	}

	/**
	 * Utility to kill a process
	 *
	 * @param int $pid the process ID to kill
	 * @return bool if it was successfully killed
	 */
	private function killPid(int $pid): bool {
		// kill
		$cmdResult = $this->runCommand(sprintf('kill -9 %d', $pid));

		// check the process is gone
		$isStillRunning = $this->isRunning($pid);
		return !is_null($cmdResult) && $cmdResult['return_code'] === 0 && !$isStillRunning;
	}

	/**
	 * Check if a process is running
	 *
	 * @param int $pid the process ID
	 * @return bool true if it's running
	 */
	private function isRunning(int $pid): bool {
		try {
			$cmd = 'ps x -o user,pid,args';
			$cmdResult = $this->runCommand($cmd);
			if ($cmdResult && $cmdResult['return_code'] === 0) {
				$lines = explode("\n", $cmdResult['stdout']);
				foreach ($lines as $l) {
					$items = preg_split('/\s+/', $l);
					if (count($items) > 1 && is_numeric($items[1])) {
						$lPid = (int)$items[1];
						if ($lPid === $pid) {
							return true;
						}
					}
				}
			}
		} catch (\Exception $e) {
		}
		return false;
	}

	/**
	 * Launch a command, wait until it ends and return outputs and return code
	 *
	 * @param string $cmd command string
	 * @return ?array outputs and return code, null if process launch failed
	 */
	private function runCommand(string $cmd): ?array {
		$descriptorspec = [fopen('php://stdin', 'r'), ['pipe', 'w'], ['pipe', 'w']];
		// noopengrep: php.lang.security.exec-use.exec-use
		$process = proc_open($cmd, $descriptorspec, $pipes);
		if ($process) {
			$output = stream_get_contents($pipes[1]);
			$errorOutput = stream_get_contents($pipes[2]);
			fclose($pipes[1]);
			fclose($pipes[2]);
			$returnCode = proc_close($process);
			return [
				'stdout' => trim($output),
				'stderr' => trim($errorOutput),
				'return_code' => $returnCode,
			];
		}
		return null;
	}

	/**
	 * Stop all bridges
	 *
	 * @return bool If bridges where stopped
	 */
	public function stopAllBridges(): bool {
		$query = $this->db->getQueryBuilder();

		$query->update('talk_bridges')
			->set('enabled', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT))
			->set('pid', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT));
		$result = $query->executeStatement();

		// finally kill all potential zombie matterbridge processes
		$this->killZombieBridges(true);
		return $result !== 0;
	}

	/**
	 * Get bridge information for one room
	 *
	 * @param Room $room the room
	 * @return TalkMatterbridge
	 */
	private function getBridgeFromDb(Room $room): array {
		$roomId = $room->getId();

		$qb = $this->db->getQueryBuilder();
		$qb->select('json_values', 'enabled', 'pid')
			->from('talk_bridges')
			->where(
				$qb->expr()->eq('room_id', $qb->createNamedParameter($roomId, IQueryBuilder::PARAM_INT))
			)
			->setMaxResults(1);
		$result = $qb->executeQuery();
		$enabled = false;
		$pid = 0;
		$jsonValues = '[]';
		if ($row = $result->fetch()) {
			$pid = (int)$row['pid'];
			$enabled = ((int)$row['enabled'] === 1);
			$jsonValues = $row['json_values'];
		}
		$result->closeCursor();

		return [
			'enabled' => $enabled,
			'pid' => $pid,
			'parts' => json_decode($jsonValues, true),
		];
	}

	/**
	 * Save bridge information for one room
	 *
	 * @param int $roomId the room ID
	 * @param array $bridge bridge values
	 */
	private function saveBridgeToDb(int $roomId, array $bridge): void {
		$jsonValues = json_encode($bridge['parts']);
		$intEnabled = $bridge['enabled'] ? 1 : 0;

		$qb = $this->db->getQueryBuilder();
		try {
			$qb->insert('talk_bridges')
				->values([
					'room_id' => $qb->createNamedParameter($roomId, IQueryBuilder::PARAM_INT),
					'json_values' => $qb->createNamedParameter($jsonValues, IQueryBuilder::PARAM_STR),
					'enabled' => $qb->createNamedParameter($intEnabled, IQueryBuilder::PARAM_INT),
					'pid' => $qb->createNamedParameter($bridge['pid'], IQueryBuilder::PARAM_INT),
				]);
			$qb->executeStatement();
		} catch (Exception $e) {
			if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
				$qb = $this->db->getQueryBuilder();
				$qb->update('talk_bridges');
				$qb->set('json_values', $qb->createNamedParameter($jsonValues, IQueryBuilder::PARAM_STR));
				$qb->set('enabled', $qb->createNamedParameter($intEnabled, IQueryBuilder::PARAM_INT));
				$qb->set('pid', $qb->createNamedParameter($bridge['pid'], IQueryBuilder::PARAM_INT));
				$qb->where(
					$qb->expr()->eq('room_id', $qb->createNamedParameter($roomId, IQueryBuilder::PARAM_INT))
				);
				$qb->executeStatement();
			} else {
				$this->logger->error($e->getMessage(), [
					'exception' => $e,
				]);
			}
		}
	}

	public function getCurrentVersionFromBinary(): ?string {
		$binaryPath = $this->config->getAppValue('spreed', 'matterbridge_binary');
		if (!file_exists($binaryPath)) {
			return null;
		}

		// is www user the file owner?
		$user = posix_getpwuid(posix_getuid());
		$fileOwner = posix_getpwuid(fileowner($binaryPath));
		$userIsOwner = $user['name'] === $fileOwner['name'];

		// get file group and user groups
		$fileGid = filegroup($binaryPath);
		$myGids = posix_getgroups();

		// check permissions
		$perms = fileperms($binaryPath);
		$uExec = (($perms & 0x0040) && !($perms & 0x0800));
		$gExec = (($perms & 0x0008) && !($perms & 0x0400));
		$aExec = (($perms & 0x0001) && !($perms & 0x0200));

		// 3 ways to have the permission :
		// * be the owner and have u+x perm
		// * not be the owner, be in the file group and have g+x perm
		// * have o+x perm
		$execPerm = $aExec
			|| ($user['name'] === $fileOwner['name'] && $uExec)
			|| ($user['name'] !== $fileOwner['name'] && in_array($fileGid, $myGids) && $gExec);

		if (!$execPerm) {
			throw new WrongPermissionsException();
		}

		$cmd = escapeshellcmd($binaryPath) . ' ' . escapeshellarg('-version');
		$cmdResult = $this->runCommand($cmd);
		if (is_null($cmdResult) || $cmdResult['return_code'] !== 0) {
			return null;
		}

		return $cmdResult['stdout'] ?? null;
	}
}
