<?php

declare(strict_types=1);
/**
 * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com>
 *
 * @author Joas Schilling <coding@schilljs.com>
 *
 * @license GNU AGPL version 3 or any later version
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 */

namespace OCA\Talk\Service;

use InvalidArgumentException;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Config;
use OCA\Talk\Events\AAttendeeRemovedEvent;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Manager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\BreakoutRoom;
use OCA\Talk\Participant;
use OCA\Talk\Room;
use OCA\Talk\Webinary;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IL10N;
use OCP\Notification\IManager as INotificationManager;

class BreakoutRoomService {
	public function __construct(
		protected Config $config,
		protected Manager $manager,
		protected RoomService $roomService,
		protected ParticipantService $participantService,
		protected ChatManager $chatManager,
		protected INotificationManager $notificationManager,
		protected ITimeFactory $timeFactory,
		protected IEventDispatcher $dispatcher,
		protected IL10N $l,
	) {
	}

	/**
	 * @param string $map
	 * @param int $max
	 * @return array
	 */
	protected function parseAttendeeMap(string $map, int $max): array {
		if ($map === '') {
			return [];
		}

		try {
			$attendeeMap = json_decode($map, true, 2, JSON_THROW_ON_ERROR);
		} catch (\JsonException) {
			throw new InvalidArgumentException('attendeeMap');
		}

		if (!is_array($attendeeMap)) {
			throw new InvalidArgumentException('attendeeMap');
		}

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

		try {
			$attendeeMap = array_filter($attendeeMap, static fn (int $roomNumber, int $attendeeId) => true, ARRAY_FILTER_USE_BOTH);
		} catch (\Throwable) {
			throw new InvalidArgumentException('attendeeMap');
		}

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

		if (max($attendeeMap) >= $max) {
			throw new InvalidArgumentException('attendeeMap');
		}

		if (min($attendeeMap) < 0) {
			throw new InvalidArgumentException('attendeeMap');
		}

		if (min(array_keys($attendeeMap)) <= 0) {
			throw new InvalidArgumentException('attendeeMap');
		}

		return $attendeeMap;
	}

	/**
	 * @param Room $parent
	 * @param 0|1|2|3 $mode
	 * @psalm-param BreakoutRoom::MODE_* $mode
	 * @param int $amount
	 * @param string $attendeeMap
	 * @return Room[]
	 * @throws InvalidArgumentException When the breakout rooms are configured already
	 */
	public function setupBreakoutRooms(Room $parent, int $mode, int $amount, string $attendeeMap): array {
		if (!$this->config->isBreakoutRoomsEnabled()) {
			throw new InvalidArgumentException('config');
		}

		if ($parent->getBreakoutRoomMode() !== BreakoutRoom::MODE_NOT_CONFIGURED) {
			throw new InvalidArgumentException('room');
		}

		if ($parent->getType() !== Room::TYPE_GROUP) {
			// Can only do breakout rooms in group rooms
			throw new InvalidArgumentException('room');
		}

		if ($parent->getObjectType() === BreakoutRoom::PARENT_OBJECT_TYPE) {
			// Can not nest breakout rooms
			throw new InvalidArgumentException('room');
		}

		if (!$this->roomService->setBreakoutRoomMode($parent, $mode)) {
			throw new InvalidArgumentException('mode');
		}

		if ($amount < BreakoutRoom::MINIMUM_ROOM_AMOUNT) {
			throw new InvalidArgumentException('amount');
		}

		if ($amount > BreakoutRoom::MAXIMUM_ROOM_AMOUNT) {
			throw new InvalidArgumentException('amount');
		}

		if ($mode === BreakoutRoom::MODE_MANUAL) {
			$cleanedMap = $this->parseAttendeeMap($attendeeMap, $amount);
		}

		$breakoutRooms = $this->createBreakoutRooms($parent, $amount);

		$participants = $this->participantService->getParticipantsForRoom($parent);
		// TODO Removing any non-users here as breakout rooms only support logged in users in version 1
		$participants = array_filter($participants, static fn (Participant $participant) => $participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS);

		$moderators = array_filter($participants, static fn (Participant $participant) => $participant->hasModeratorPermissions());
		$this->addModeratorsToBreakoutRooms($breakoutRooms, $moderators);

		$others = array_filter($participants, static fn (Participant $participant) => !$participant->hasModeratorPermissions());
		if ($mode === BreakoutRoom::MODE_AUTOMATIC) {
			// Shuffle the attendees, so they are not always distributed in the same way
			shuffle($others);

			$map = [];
			foreach ($others as $index => $participant) {
				$map[$index % $amount] ??= [];
				$map[$index % $amount][] = $participant;
			}

			$this->addOthersToBreakoutRooms($breakoutRooms, $map);
		} elseif ($mode === BreakoutRoom::MODE_MANUAL) {
			$map = [];
			foreach ($others as $participant) {
				if (!isset($cleanedMap[$participant->getAttendee()->getId()])) {
					continue;
				}

				$roomNumber = (int) $cleanedMap[$participant->getAttendee()->getId()];

				$map[$roomNumber] ??= [];
				$map[$roomNumber][] = $participant;
			}

			$this->addOthersToBreakoutRooms($breakoutRooms, $map);
		}


		return $breakoutRooms;
	}

	/**
	 * @param Room $parent
	 * @param string $attendeeMap
	 * @return Room[]
	 * @throws InvalidArgumentException When the map was invalid, breakout rooms are disabled or not configured for this conversation
	 */
	public function applyAttendeeMap(Room $parent, string $attendeeMap): array {
		if (!$this->config->isBreakoutRoomsEnabled()) {
			throw new InvalidArgumentException('config');
		}

		if ($parent->getBreakoutRoomMode() === BreakoutRoom::MODE_NOT_CONFIGURED) {
			throw new InvalidArgumentException('mode');
		}

		$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken());
		$amount = count($breakoutRooms);
		usort($breakoutRooms, static function (Room $roomA, Room $roomB) {
			return $roomA->getId() - $roomB->getId();
		});

		$cleanedMap = $this->parseAttendeeMap($attendeeMap, $amount);
		$attendeeIds = array_keys($cleanedMap);

		$participants = $this->participantService->getParticipantsForRoom($parent);
		$participants = array_filter($participants, static fn (Participant $participant) => in_array($participant->getAttendee()->getId(), $attendeeIds, true));
		// TODO Removing any non-users here as breakout rooms only support logged in users in version 1
		$participants = array_filter($participants, static fn (Participant $participant) => $participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS);

		$userIds = array_map(static fn (Participant $participant) => $participant->getAttendee()->getActorId(), $participants);

		$removals = [];
		foreach ($breakoutRooms as $breakoutRoom) {
			$breakoutRoomParticipants = $this->participantService->getParticipantsForRoom($breakoutRoom);

			foreach ($breakoutRoomParticipants as $participant) {
				$attendee = $participant->getAttendee();
				if ($attendee->getActorType() === Attendee::ACTOR_USERS && in_array($attendee->getActorId(), $userIds, true)) {
					if ($participant->hasModeratorPermissions()) {
						// Can not remove moderators with this method
						throw new \InvalidArgumentException('moderator');
					}

					$removals[] = [
						'room' => $breakoutRoom,
						'participant' => $participant,
					];
				}
			}
		}

		foreach ($removals as $removal) {
			$this->participantService->removeAttendee($removal['room'], $removal['participant'], AAttendeeRemovedEvent::REASON_REMOVED);
		}

		$map = [];
		foreach ($participants as $participant) {
			if (!isset($cleanedMap[$participant->getAttendee()->getId()])) {
				continue;
			}

			$roomNumber = (int) $cleanedMap[$participant->getAttendee()->getId()];

			$map[$roomNumber] ??= [];
			$map[$roomNumber][] = $participant;
		}

		$this->addOthersToBreakoutRooms($breakoutRooms, $map);

		return $breakoutRooms;
	}

	/**
	 * @param Room[] $rooms
	 * @param Participant[] $moderators
	 */
	public function addModeratorsToBreakoutRooms(array $rooms, array $moderators): void {
		$moderatorsToAdd = [];
		foreach ($moderators as $moderator) {
			$attendee = $moderator->getAttendee();

			$moderatorsToAdd[] = [
				'actorType' => $attendee->getActorType(),
				'actorId' => $attendee->getActorId(),
				'displayName' => $attendee->getDisplayName(),
				'participantType' => $attendee->getParticipantType(),
			];
		}

		foreach ($rooms as $room) {
			$this->participantService->addUsers($room, $moderatorsToAdd);
		}
	}

	/**
	 * @param array $rooms
	 * @param Participant[][] $participantsMap
	 */
	protected function addOthersToBreakoutRooms(array $rooms, array $participantsMap): void {
		foreach ($rooms as $roomNumber => $room) {
			$toAdd = [];

			$participants = $participantsMap[$roomNumber] ?? [];
			foreach ($participants as $participant) {
				$attendee = $participant->getAttendee();

				$toAdd[] = [
					'actorType' => $attendee->getActorType(),
					'actorId' => $attendee->getActorId(),
					'displayName' => $attendee->getDisplayName(),
					'participantType' => $attendee->getParticipantType(),
				];
			}

			if (empty($toAdd)) {
				continue;
			}

			$this->participantService->addUsers($room, $toAdd);
		}
	}

	protected function createBreakoutRooms(Room $parent, int $amount): array {
		// Safety caution cleaning up potential orphan rooms
		$this->deleteBreakoutRooms($parent);

		// TRANSLATORS Label for the breakout rooms, this is not a plural! The result will be "Room 1", "Room 2", "Room 3", ...
		$label = $this->l->t('Room {number}');

		$rooms = [];
		for ($i = 1; $i <= $amount; $i++) {
			$breakoutRoom = $this->roomService->createConversation(
				$parent->getType(),
				str_replace('{number}', (string) $i, $label),
				null,
				BreakoutRoom::PARENT_OBJECT_TYPE,
				$parent->getToken()
			);

			$this->roomService->setLobby($breakoutRoom, Webinary::LOBBY_NON_MODERATORS, null, false, false);

			$rooms[] = $breakoutRoom;
		}

		return $rooms;
	}

	public function removeBreakoutRooms(Room $parent): void {
		$this->deleteBreakoutRooms($parent);
		$this->roomService->setBreakoutRoomMode($parent, BreakoutRoom::MODE_NOT_CONFIGURED);
		$this->roomService->setBreakoutRoomStatus($parent, BreakoutRoom::STATUS_STOPPED);
	}

	protected function deleteBreakoutRooms(Room $parent): void {
		$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken());
		foreach ($breakoutRooms as $breakoutRoom) {
			$this->roomService->deleteRoom($breakoutRoom);
		}
	}

	/**
	 * @param Room $parent
	 * @param Participant $participant
	 * @param string $message
	 * @return Room[]
	 */
	public function broadcastChatMessage(Room $parent, Participant $participant, string $message): array {
		if ($parent->getBreakoutRoomMode() === BreakoutRoom::MODE_NOT_CONFIGURED) {
			throw new \InvalidArgumentException('mode');
		}

		$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken());
		$attendeeType = $participant->getAttendee()->getActorType();
		$attendeeId = $participant->getAttendee()->getActorId();
		$creationDateTime = new \DateTime();

		$shouldFlush = $this->notificationManager->defer();
		try {
			foreach ($breakoutRooms as $breakoutRoom) {
				$breakoutParticipant = $this->participantService->getParticipantByActor($breakoutRoom, $attendeeType, $attendeeId);
				$comment = $this->chatManager->sendMessage($breakoutRoom, $breakoutParticipant, $attendeeType, $attendeeId, $message, $creationDateTime, null, '', false);
				$breakoutRoom->setLastMessage($comment);
			}
		} finally {
			if ($shouldFlush) {
				$this->notificationManager->flush();
			}
		}

		return $breakoutRooms;
	}

	public function requestAssistance(Room $breakoutRoom): void {
		$this->setAssistanceRequest($breakoutRoom, BreakoutRoom::STATUS_ASSISTANCE_REQUESTED);
	}

	public function resetRequestForAssistance(Room $breakoutRoom): void {
		$this->setAssistanceRequest($breakoutRoom, BreakoutRoom::STATUS_ASSISTANCE_RESET);
	}

	protected function setAssistanceRequest(Room $breakoutRoom, int $status): void {
		if ($breakoutRoom->getObjectType() !== BreakoutRoom::PARENT_OBJECT_TYPE) {
			throw new \InvalidArgumentException('room');
		}

		if ($breakoutRoom->getLobbyState() !== Webinary::LOBBY_NONE) {
			throw new \InvalidArgumentException('room');
		}

		if (!in_array($status, [
			BreakoutRoom::STATUS_ASSISTANCE_RESET,
			BreakoutRoom::STATUS_ASSISTANCE_REQUESTED,
		], true)) {
			throw new \InvalidArgumentException('status');
		}

		$this->roomService->setBreakoutRoomStatus($breakoutRoom, $status);
		$this->roomService->setLastActivity($breakoutRoom, $this->timeFactory->getDateTime());
	}

	/**
	 * @param Room $parent
	 * @return Room[]
	 */
	public function startBreakoutRooms(Room $parent): array {
		if ($parent->getBreakoutRoomMode() === BreakoutRoom::MODE_NOT_CONFIGURED) {
			throw new \InvalidArgumentException('mode');
		}

		$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken(), true);
		foreach ($breakoutRooms as $breakoutRoom) {
			$this->roomService->setLobby($breakoutRoom, Webinary::LOBBY_NONE, null);
		}

		$this->roomService->setBreakoutRoomStatus($parent, BreakoutRoom::STATUS_STARTED);

		return $breakoutRooms;
	}

	/**
	 * @param Room $parent
	 * @return Room[]
	 */
	public function stopBreakoutRooms(Room $parent): array {
		if ($parent->getBreakoutRoomMode() === BreakoutRoom::MODE_NOT_CONFIGURED) {
			throw new \InvalidArgumentException('mode');
		}

		$this->roomService->setBreakoutRoomStatus($parent, BreakoutRoom::STATUS_STOPPED);

		$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken());
		foreach ($breakoutRooms as $breakoutRoom) {
			$this->roomService->setLobby($breakoutRoom, Webinary::LOBBY_NON_MODERATORS, null);

			if ($breakoutRoom->getBreakoutRoomStatus() === BreakoutRoom::STATUS_ASSISTANCE_REQUESTED) {
				$this->roomService->setBreakoutRoomStatus($breakoutRoom, BreakoutRoom::STATUS_ASSISTANCE_RESET);
			}
		}

		return $breakoutRooms;
	}

	public function switchBreakoutRoom(Room $parent, Participant $participant, string $targetToken): Room {
		if ($parent->getBreakoutRoomMode() !== BreakoutRoom::MODE_FREE) {
			throw new \InvalidArgumentException('mode');
		}

		if ($parent->getBreakoutRoomStatus() !== BreakoutRoom::STATUS_STARTED) {
			throw new \InvalidArgumentException('status');
		}

		if ($participant->hasModeratorPermissions()) {
			// Moderators don't switch, they are part of all breakout rooms
			throw new \InvalidArgumentException('moderator');
		}

		$attendee = $participant->getAttendee();

		$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken());

		$target = null;
		foreach ($breakoutRooms as $breakoutRoom) {
			if ($targetToken === $breakoutRoom->getToken()) {
				$target = $breakoutRoom;
				break;
			}
		}

		if ($target === null) {
			throw new \InvalidArgumentException('target');
		}

		foreach ($breakoutRooms as $breakoutRoom) {
			try {
				$removeParticipant = $this->participantService->getParticipantByActor(
					$breakoutRoom,
					$attendee->getActorType(),
					$attendee->getActorId()
				);

				if ($targetToken !== $breakoutRoom->getToken()) {
					// Remove from all other breakout rooms
					$this->participantService->removeAttendee(
						$breakoutRoom,
						$removeParticipant,
						AAttendeeRemovedEvent::REASON_LEFT
					);
				}
			} catch (ParticipantNotFoundException $e) {
				if ($targetToken === $breakoutRoom->getToken()) {
					// Join the target breakout room
					$this->participantService->addUsers(
						$breakoutRoom,
						[
							[
								'actorType' => $attendee->getActorType(),
								'actorId' => $attendee->getActorId(),
								'displayName' => $attendee->getDisplayName(),
								'participantType' => $attendee->getParticipantType(),
							]
						]
					);
				}
			}
		}

		return $target;
	}

	/**
	 * @param Room $parent
	 * @param Participant $participant
	 * @return Room[]
	 */
	public function getBreakoutRooms(Room $parent, Participant $participant): array {
		if ($parent->getBreakoutRoomMode() === BreakoutRoom::MODE_NOT_CONFIGURED) {
			throw new \InvalidArgumentException('mode');
		}

		if (!$participant->hasModeratorPermissions() && $parent->getBreakoutRoomStatus() !== BreakoutRoom::STATUS_STARTED) {
			throw new \InvalidArgumentException('status');
		}

		$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken(), true);

		$returnAll = $participant->hasModeratorPermissions() || $parent->getBreakoutRoomMode() === BreakoutRoom::MODE_FREE;
		if (!$returnAll) {
			$rooms = [];
			foreach ($breakoutRooms as $breakoutRoom) {
				try {
					$this->participantService->getParticipantByActor(
						$breakoutRoom,
						$participant->getAttendee()->getActorType(),
						$participant->getAttendee()->getActorId()
					);
					$rooms[] = $breakoutRoom;
				} catch (ParticipantNotFoundException $e) {
					// Skip this room
				}
			}
			return $rooms;
		}
		return $breakoutRooms;
	}

	/**
	 * @param Room $parent
	 * @param string $actorType
	 * @param string $actorId
	 * @param bool $throwOnModerator
	 * @return void
	 * @throws InvalidArgumentException When being used for a moderator
	 */
	public function removeAttendeeFromBreakoutRoom(Room $parent, string $actorType, string $actorId, bool $throwOnModerator = true): void {
		$breakoutRooms = $this->manager->getMultipleRoomsByObject(BreakoutRoom::PARENT_OBJECT_TYPE, $parent->getToken());

		foreach ($breakoutRooms as $breakoutRoom) {
			try {
				$participant = $this->participantService->getParticipantByActor(
					$breakoutRoom,
					$actorType,
					$actorId
				);

				if ($throwOnModerator && $participant->hasModeratorPermissions()) {
					throw new \InvalidArgumentException('moderator');
				}

				$this->participantService->removeAttendee($breakoutRoom, $participant, AAttendeeRemovedEvent::REASON_REMOVED);
			} catch (ParticipantNotFoundException $e) {
				// Skip this room
			}
		}
	}
}
