<?php

/**
 * Chill is a software for social workers.
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

declare(strict_types=1);

/*
 * Chill is a software for social workers
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace Chill\CalendarBundle\RemoteCalendar\Connector;

use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CalendarRange;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\MapCalendarToUser;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\OnBehalfOfUserHttpClient;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\OnBehalfOfUserTokenStorage;
use Chill\CalendarBundle\RemoteCalendar\Connector\MSGraph\RemoteEventConverter;
use Chill\CalendarBundle\Repository\CalendarRangeRepository;
use Chill\CalendarBundle\Repository\CalendarRepository;
use Chill\MainBundle\Entity\User;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface
{
    private array $cacheScheduleTimeForUser = [];

    public function __construct(private readonly CalendarRepository $calendarRepository, private readonly CalendarRangeRepository $calendarRangeRepository, private readonly HttpClientInterface $machineHttpClient, private readonly MapCalendarToUser $mapCalendarToUser, private readonly LoggerInterface $logger, private readonly OnBehalfOfUserTokenStorage $tokenStorage, private readonly OnBehalfOfUserHttpClient $userHttpClient, private readonly RemoteEventConverter $remoteEventConverter, private readonly TranslatorInterface $translator, private readonly UrlGeneratorInterface $urlGenerator, private readonly Security $security) {}

    public function countEventsForUser(User $user, \DateTimeImmutable $startDate, \DateTimeImmutable $endDate): int
    {
        $userId = $this->mapCalendarToUser->getUserId($user);

        if (null === $userId) {
            return 0;
        }

        try {
            $data = $this->userHttpClient->request(
                'GET',
                'users/'.$userId.'/calendarView',
                [
                    'query' => [
                        'startDateTime' => $startDate->setTimezone(RemoteEventConverter::getRemoteTimeZone())->format(RemoteEventConverter::getRemoteDateTimeSimpleFormat()),
                        'endDateTime' => $endDate->setTimezone(RemoteEventConverter::getRemoteTimeZone())->format(RemoteEventConverter::getRemoteDateTimeSimpleFormat()),
                        '$count' => 'true',
                        '$top' => 0,
                    ],
                ]
            )->toArray();
        } catch (ClientExceptionInterface $e) {
            if (403 === $e->getResponse()->getStatusCode()) {
                return \count($this->getScheduleTimesForUser($user, $startDate, $endDate));
            }

            $this->logger->error('Could not get list of event on MSGraph', [
                'error_code' => $e->getResponse()->getStatusCode(),
                'error' => $e->getResponse()->getInfo(),
            ]);

            return 0;
        }

        return $data['@odata.count'];
    }

    public function getMakeReadyResponse(string $returnPath): Response
    {
        return new RedirectResponse($this->urlGenerator
            ->generate('chill_calendar_remote_connect_azure', ['returnPath' => $returnPath]));
    }

    public function isReady(): bool
    {
        $user = $this->security->getUser();

        if (!$user instanceof User) {
            // this is not a user from chill. This is not the role of this class to
            // restrict access, so we will just say that we do not have to do anything more
            // here...
            return true;
        }

        if (null === $this->mapCalendarToUser->getUserId($user)) {
            // this user is not mapped with remote calendar. The user will have to wait for
            // the next calendar subscription iteration
            $this->logger->debug('mark user ready for msgraph calendar as he does not have any mapping', [
                'userId' => $user->getId(),
            ]);

            return true;
        }

        return $this->tokenStorage->hasToken();
    }

    /**
     * @return array|\Chill\CalendarBundle\RemoteCalendar\Model\RemoteEvent[]
     *
     * @throws \Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface
     * @throws \Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface
     * @throws \Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface
     * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
     */
    public function listEventsForUser(User $user, \DateTimeImmutable $startDate, \DateTimeImmutable $endDate, ?int $offset = 0, ?int $limit = 50): array
    {
        $userId = $this->mapCalendarToUser->getUserId($user);

        if (null === $userId) {
            return [];
        }

        try {
            $bareEvents = $this->userHttpClient->request(
                'GET',
                'users/'.$userId.'/calendarView',
                [
                    'query' => [
                        'startDateTime' => $startDate->setTimezone(RemoteEventConverter::getRemoteTimeZone())->format(RemoteEventConverter::getRemoteDateTimeSimpleFormat()),
                        'endDateTime' => $endDate->setTimezone(RemoteEventConverter::getRemoteTimeZone())->format(RemoteEventConverter::getRemoteDateTimeSimpleFormat()),
                        '$select' => 'id,subject,start,end,isAllDay',
                        '$top' => $limit,
                        '$skip' => $offset,
                    ],
                ]
            )->toArray();

            $ids = array_map(static fn ($item) => $item['id'], $bareEvents['value']);
            $existingIdsInRange = $this->calendarRangeRepository->findRemoteIdsPresent($ids);
            $existingIdsInCalendar = $this->calendarRepository->findRemoteIdsPresent($ids);

            return array_values(
                array_map(
                    fn ($item) => $this->remoteEventConverter->convertToRemote($item),
                    // filter all event to keep only the one not in range
                    array_filter(
                        $bareEvents['value'],
                        static fn ($item) => ((!$existingIdsInRange[$item['id']]) ?? true) && ((!$existingIdsInCalendar[$item['id']]) ?? true)
                    )
                )
            );
        } catch (ClientExceptionInterface $e) {
            if (403 === $e->getResponse()->getStatusCode()) {
                return $this->getScheduleTimesForUser($user, $startDate, $endDate);
            }

            $this->logger->error('Could not get list of event on MSGraph', [
                'error_code' => $e->getResponse()->getStatusCode(),
                'error' => $e->getResponse()->getInfo(),
            ]);

            return [];
        }
    }

    public function removeCalendar(string $remoteId, array $remoteAttributes, User $user, ?CalendarRange $associatedCalendarRange = null): void
    {
        if ('' === $remoteId) {
            return;
        }

        $this->removeEvent($remoteId, $user);

        if (null !== $associatedCalendarRange) {
            $this->syncCalendarRange($associatedCalendarRange);
        }
    }

    public function removeCalendarRange(string $remoteId, array $remoteAttributes, User $user): void
    {
        if ('' === $remoteId) {
            return;
        }

        $this->removeEvent($remoteId, $user);
    }

    public function syncCalendar(Calendar $calendar, string $action, ?CalendarRange $previousCalendarRange, ?User $previousMainUser, ?array $oldInvites, ?array $newInvites): void
    {
        /*
         * cases to support:
         *
         * * a calendar range is created:
         *     * create on remote
         *     * if calendar range is associated: remove the range
         * * a Calendar change the CalendarRange:
         *     * re-create the previous calendar range;
         *     * remove the current calendar range
         * * a calendar change the mainUser
         *     * cancel the calendar in the previous mainUser
         *     * recreate the previous calendar range in the previousMainUser, if any
         *     * delete the current calendar range in the current mainUser, if any
         *     * create the calendar in the current mainUser
         *
         */

        if (!$calendar->hasRemoteId()) {
            $this->createCalendarOnRemote($calendar);
        } else {
            if (null !== $previousMainUser) {
                // cancel event in previousMainUserCalendar
                $this->cancelOnRemote(
                    $calendar->getRemoteId(),
                    $this->translator->trans('remote_ms_graph.cancel_event_because_main_user_is_%label%', ['%label%' => $calendar->getMainUser()]),
                    $previousMainUser,
                    'calendar_'.$calendar->getRemoteId()
                );
                $this->createCalendarOnRemote($calendar);
            } else {
                $this->patchCalendarOnRemote($calendar, $newInvites);
            }
        }

        if ($calendar->hasCalendarRange() && $calendar->getCalendarRange()->hasRemoteId()) {
            $this->removeEvent(
                $calendar->getCalendarRange()->getRemoteId(),
                $calendar->getCalendarRange()->getUser()
            );

            $calendar->getCalendarRange()
                ->addRemoteAttributes([
                    'lastModifiedDateTime' => null,
                    'changeKey' => null,
                    'previousId' => $calendar->getCalendarRange()->getRemoteId(),
                ])
                ->setRemoteId('');
        }

        if (null !== $previousCalendarRange) {
            $this->createRemoteCalendarRange($previousCalendarRange);
        }
    }

    public function syncCalendarRange(CalendarRange $calendarRange): void
    {
        if ($calendarRange->hasRemoteId()) {
            $this->updateRemoteCalendarRange($calendarRange);
        } else {
            $this->createRemoteCalendarRange($calendarRange);
        }
    }

    public function syncInvite(Invite $invite): void
    {
        if ('' === $remoteId = $invite->getCalendar()->getRemoteId()) {
            return;
        }

        if (null === $invite->getUser()) {
            return;
        }

        if (null === $userId = $this->mapCalendarToUser->getUserId($invite->getUser())) {
            return;
        }

        if ($invite->hasRemoteId()) {
            $remoteIdAttendeeCalendar = $invite->getRemoteId();
        } else {
            $remoteIdAttendeeCalendar = $this->findRemoteIdOnUserCalendar($invite->getCalendar(), $invite->getUser());
            $invite->setRemoteId($remoteIdAttendeeCalendar);
        }

        switch ($invite->getStatus()) {
            case Invite::PENDING:
                return;

            case Invite::ACCEPTED:
                $url = "/v1.0/users/{$userId}/calendar/events/{$remoteIdAttendeeCalendar}/accept";

                break;

            case Invite::TENTATIVELY_ACCEPTED:
                $url = "/v1.0/users/{$userId}/calendar/events/{$remoteIdAttendeeCalendar}/tentativelyAccept";

                break;

            case Invite::DECLINED:
                $url = "/v1.0/users/{$userId}/calendar/events/{$remoteIdAttendeeCalendar}/decline";

                break;

            default:
                throw new \Exception('not supported');
        }

        try {
            $this->machineHttpClient->request(
                'POST',
                $url,
                ['json' => ['sendResponse' => true]]
            );
        } catch (ClientExceptionInterface $e) {
            $this->logger->warning('could not update calendar range to remote', [
                'exception' => $e->getTraceAsString(),
                'content' => $e->getResponse()->getContent(),
                'calendarRangeId' => 'invite_'.$invite->getId(),
            ]);

            throw $e;
        }
    }

    private function cancelOnRemote(string $remoteId, string $comment, User $user, string $identifier): void
    {
        $userId = $this->mapCalendarToUser->getUserId($user);

        if (null === $userId) {
            return;
        }

        try {
            $this->machineHttpClient->request(
                'POST',
                "users/{$userId}/calendar/events/{$remoteId}/cancel",
                [
                    'json' => ['Comment' => $comment],
                ]
            );
        } catch (ClientExceptionInterface $e) {
            $this->logger->warning('could not update calendar range to remote', [
                'exception' => $e->getTraceAsString(),
                'content' => $e->getResponse()->getContent(),
                'calendarRangeId' => $identifier,
            ]);

            throw $e;
        }
    }

    private function createCalendarOnRemote(Calendar $calendar): void
    {
        $eventData = $this->remoteEventConverter->calendarToEvent($calendar);

        [
            'id' => $id,
            'lastModifiedDateTime' => $lastModified,
            'changeKey' => $changeKey,
        ] = $this->createOnRemote($eventData, $calendar->getMainUser(), 'calendar_'.$calendar->getId());

        if (null === $id) {
            return;
        }

        $calendar
            ->setRemoteId($id)
            ->addRemoteAttributes([
                'lastModifiedDateTime' => $lastModified,
                'changeKey' => $changeKey,
            ]);
    }

    /**
     * @param string $identifier an identifier for logging in case of something does not work
     *
     * @return array{?id: string, ?lastModifiedDateTime: int, ?changeKey: string}
     */
    private function createOnRemote(array $eventData, User $user, string $identifier): array
    {
        $userId = $this->mapCalendarToUser->getUserId($user);

        if (null === $userId) {
            $this->logger->warning('user does not have userId nor calendarId', [
                'user_id' => $user->getId(),
                'calendar_identifier' => $identifier,
            ]);

            return ['id' => null, 'lastModifiedDateTime' => null, 'changeKey' => null];
        }

        try {
            $event = $this->machineHttpClient->request(
                'POST',
                'users/'.$userId.'/calendar/events',
                [
                    'json' => $eventData,
                ]
            )->toArray();
        } catch (ClientExceptionInterface $e) {
            $this->logger->warning('could not save calendar range to remote', [
                'exception' => $e->getTraceAsString(),
                'content' => $e->getResponse()->getContent(),
                'calendar_identifier' => $identifier,
            ]);

            throw $e;
        }

        return [
            'id' => $event['id'],
            'lastModifiedDateTime' => $this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp(),
            'changeKey' => $event['changeKey'],
        ];
    }

    private function createRemoteCalendarRange(CalendarRange $calendarRange): void
    {
        $userId = $this->mapCalendarToUser->getUserId($calendarRange->getUser());

        if (null === $userId) {
            $this->logger->warning('user does not have userId nor calendarId', [
                'user_id' => $calendarRange->getUser()->getId(),
                'calendar_range_id' => $calendarRange->getId(),
            ]);

            return;
        }

        $eventData = $this->remoteEventConverter->calendarRangeToEvent($calendarRange);

        [
            'id' => $id,
            'lastModifiedDateTime' => $lastModified,
            'changeKey' => $changeKey,
        ] = $this->createOnRemote(
            $eventData,
            $calendarRange->getUser(),
            'calendar_range_'.$calendarRange->getId()
        );

        $calendarRange->setRemoteId($id)
            ->addRemoteAttributes([
                'lastModifiedDateTime' => $lastModified,
                'changeKey' => $changeKey,
            ]);
    }

    /**
     * the remoteId is not the same across different user calendars. This method allow to find
     * the correct remoteId in another calendar.
     *
     * For achieving this, the iCalUid is used.
     */
    private function findRemoteIdOnUserCalendar(Calendar $calendar, User $user): ?string
    {
        // find the icalUid on original user
        $event = $this->getOnRemote($calendar->getMainUser(), $calendar->getRemoteId());
        $userId = $this->mapCalendarToUser->getUserId($user);

        if ('' === $iCalUid = ($event['iCalUId'] ?? '')) {
            throw new \Exception('no iCalUid for this event');
        }

        try {
            $events = $this->machineHttpClient->request(
                'GET',
                "/v1.0/users/{$userId}/calendar/events",
                [
                    'query' => [
                        '$select' => 'id',
                        '$filter' => "iCalUId eq '{$iCalUid}'",
                    ],
                ]
            )->toArray();
        } catch (ClientExceptionInterface $clientException) {
            throw $clientException;
        }

        if (1 !== \count($events['value'])) {
            throw new \Exception('multiple events found with same iCalUid');
        }

        return $events['value'][0]['id'];
    }

    private function getOnRemote(User $user, string $remoteId): array
    {
        $userId = $this->mapCalendarToUser->getUserId($user);

        if (null === $userId) {
            throw new \Exception(sprintf('no remote calendar for this user: %s, remoteid: %s', $user->getId(), $remoteId));
        }

        try {
            return $this->machineHttpClient->request(
                'GET',
                'users/'.$userId.'/calendar/events/'.$remoteId
            )->toArray();
        } catch (ClientExceptionInterface $e) {
            $this->logger->warning('Could not get event from calendar', [
                'remoteId' => $remoteId,
            ]);

            throw $e;
        }
    }

    private function getScheduleTimesForUser(User $user, \DateTimeImmutable $startDate, \DateTimeImmutable $endDate): array
    {
        $userId = $this->mapCalendarToUser->getUserId($user);

        if (\array_key_exists($userId, $this->cacheScheduleTimeForUser)) {
            return $this->cacheScheduleTimeForUser[$userId];
        }

        if (null === $userId) {
            return [];
        }

        if (null === $user->getEmailCanonical() || '' === $user->getEmailCanonical()) {
            return [];
        }

        $body = [
            'schedules' => [$user->getEmailCanonical()],
            'startTime' => [
                'dateTime' => $startDate->setTimezone(RemoteEventConverter::getRemoteTimeZone())->format(RemoteEventConverter::getRemoteDateTimeSimpleFormat()),
                'timeZone' => 'UTC',
            ],
            'endTime' => [
                'dateTime' => $endDate->setTimezone(RemoteEventConverter::getRemoteTimeZone())->format(RemoteEventConverter::getRemoteDateTimeSimpleFormat()),
                'timeZone' => 'UTC',
            ],
        ];

        try {
            $response = $this->userHttpClient->request('POST', 'users/'.$userId.'/calendar/getSchedule', [
                'json' => $body,
            ])->toArray();
        } catch (ClientExceptionInterface $e) {
            $this->logger->debug('Could not get schedule on MSGraph', [
                'error_code' => $e->getResponse()->getStatusCode(),
                'error' => $e->getResponse()->getInfo(),
            ]);

            return [];
        }

        $this->cacheScheduleTimeForUser[$userId] = array_map(
            fn ($item) => $this->remoteEventConverter->convertAvailabilityToRemoteEvent($item),
            $response['value'][0]['scheduleItems']
        );

        return $this->cacheScheduleTimeForUser[$userId];
    }

    private function patchCalendarOnRemote(Calendar $calendar, array $newInvites): void
    {
        $eventDatas = [];
        $eventDatas[] = $this->remoteEventConverter->calendarToEvent($calendar);

        if (0 < \count($newInvites)) {
            // it seems that invitaiton are always send, even if attendee changes are mixed with other datas
            // $eventDatas[] = $this->remoteEventConverter->calendarToEventAttendeesOnly($calendar);
        }

        foreach ($eventDatas as $eventData) {
            [
                'id' => $id,
                'lastModifiedDateTime' => $lastModified,
                'changeKey' => $changeKey,
            ] = $this->patchOnRemote(
                $calendar->getRemoteId(),
                $eventData,
                $calendar->getMainUser(),
                'calendar_'.$calendar->getId()
            );

            $calendar->addRemoteAttributes([
                'lastModifiedDateTime' => $lastModified,
                'changeKey' => $changeKey,
            ]);
        }
    }

    /**
     * @param string $identifier an identifier for logging in case of something does not work
     *
     * @return array{?id: string, ?lastModifiedDateTime: int, ?changeKey: string}
     */
    private function patchOnRemote(string $remoteId, array $eventData, User $user, string $identifier): array
    {
        $userId = $this->mapCalendarToUser->getUserId($user);

        if (null === $userId) {
            $this->logger->warning('user does not have userId nor calendarId', [
                'user_id' => $user->getId(),
                'calendar_identifier' => $identifier,
            ]);

            return ['id' => null, 'lastModifiedDateTime' => null, 'changeKey' => null];
        }

        try {
            $event = $this->machineHttpClient->request(
                'PATCH',
                'users/'.$userId.'/calendar/events/'.$remoteId,
                [
                    'json' => $eventData,
                ]
            )->toArray();
        } catch (ClientExceptionInterface $e) {
            $this->logger->warning('could not update calendar range to remote', [
                'exception' => $e->getTraceAsString(),
                'calendarRangeId' => $identifier,
            ]);

            throw $e;
        }

        return [
            'id' => $event['id'],
            'lastModifiedDateTime' => $this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp(),
            'changeKey' => $event['changeKey'],
        ];
    }

    private function removeEvent($remoteId, User $user): void
    {
        $userId = $this->mapCalendarToUser->getUserId($user);

        try {
            $this->machineHttpClient->request(
                'DELETE',
                'users/'.$userId.'/calendar/events/'.$remoteId
            );
        } catch (ClientExceptionInterface) {
            $this->logger->warning('could not remove event from calendar', [
                'event_remote_id' => $remoteId,
                'user_id' => $user->getId(),
            ]);
        }
    }

    private function updateRemoteCalendarRange(CalendarRange $calendarRange): void
    {
        $userId = $this->mapCalendarToUser->getUserId($calendarRange->getUser());
        $calendarId = $this->mapCalendarToUser->getCalendarId($calendarRange->getUser());

        if (null === $userId || null === $calendarId) {
            $this->logger->warning('user does not have userId nor calendarId', [
                'user_id' => $calendarRange->getUser()->getId(),
                'calendar_range_id' => $calendarRange->getId(),
            ]);

            return;
        }

        try {
            $event = $this->machineHttpClient->request(
                'GET',
                'users/'.$userId.'/calendar/events/'.$calendarRange->getRemoteId()
            )->toArray();
        } catch (ClientExceptionInterface $e) {
            $this->logger->warning('Could not get event from calendar', [
                'calendar_range_id' => $calendarRange->getId(),
                'calendar_range_remote_id' => $calendarRange->getRemoteId(),
            ]);

            throw $e;
        }

        if ($this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp() > $calendarRange->getUpdatedAt()->getTimestamp()) {
            $this->logger->info('Skip updating as the lastModified date seems more fresh than the database one', [
                'calendar_range_id' => $calendarRange->getId(),
                'calendar_range_remote_id' => $calendarRange->getRemoteId(),
                'db_last_updated' => $calendarRange->getUpdatedAt()->getTimestamp(),
                'remote_last_updated' => $this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp(),
            ]);

            return;
        }

        $eventData = $this->remoteEventConverter->calendarRangeToEvent($calendarRange);

        try {
            $event = $this->machineHttpClient->request(
                'PATCH',
                'users/'.$userId.'/calendar/events/'.$calendarRange->getRemoteId(),
                [
                    'json' => $eventData,
                ]
            )->toArray();
        } catch (ClientExceptionInterface $e) {
            $this->logger->warning('could not update calendar range to remote', [
                'exception' => $e->getTraceAsString(),
                'calendarRangeId' => $calendarRange->getId(),
            ]);

            throw $e;
        }

        $calendarRange
            ->addRemoteAttributes([
                'lastModifiedDateTime' => $this->remoteEventConverter->getLastModifiedDate($event)->getTimestamp(),
                'changeKey' => $event['changeKey'],
            ]);
    }
}
