<?php

namespace Fifa\ConnectId\Sdk\Registration;

use DateTime;
use Exception;
use Fifa\ConnectId\Sdk\ActorResolver\SimpleActorResolver;
use Fifa\ConnectId\Sdk\Api\ApiException;
use Fifa\ConnectId\Sdk\Api\Model\GetDuplicatesOfRegisteredPersonResponseType;
use Fifa\ConnectId\Sdk\Api\Model\PersonDuplicateType;
use Fifa\ConnectId\Sdk\Api\Model\PersonType;
use Fifa\ConnectId\Sdk\Api\Model\RegistrationType;
use Fifa\ConnectId\Sdk\Environment\EnvironmentInterface;
use Fifa\ConnectId\Sdk\Exception\FifaConnectIdException;
use Fifa\ConnectId\Sdk\Exception\FifaDuplicatedPersonFoundException;
use Fifa\ConnectId\Sdk\Exception\FifaDuplicateOfRegisteredPersonFoundException;
use Fifa\ConnectId\Sdk\Exception\FifaEntityNotFoundException;
use Fifa\ConnectId\Sdk\Exception\FifaPersonMergedException;
use Fifa\ConnectId\Sdk\Exception\InvalidClientDataException;
use Fifa\ConnectId\Sdk\Exception\MemberAssociationDoesNotRespondException;
use Fifa\ConnectId\Sdk\Exception\PersonDetailsNotReceivedException;
use Fifa\ConnectId\Sdk\Exception\TooManyRequestsException;
use Fifa\ConnectId\Sdk\FifaConnectIdClient;
use Fifa\ConnectId\Sdk\FifaConnectIdClientInterface;
use Fifa\ConnectId\Sdk\HashedPersonProcessor\HashedPersonTypeHashingProcessor;
use Fifa\ConnectId\Sdk\MessageHandlers\MessageHandlerInterface;
use Fifa\ConnectId\Sdk\Model\PersonData;
use Fifa\ConnectId\Sdk\Model\PersonDuplicateWithDetails;
use Fifa\ConnectId\Sdk\Model\PersonName;
use Fifa\ConnectId\Sdk\Registration\Event\Handler\DefaultPersonDetailsReceivedHandler;
use Fifa\ConnectId\Sdk\Registration\Event\Handler\DefaultPersonDetailsRequestReceivedHandler;
use Fifa\ConnectId\Sdk\Registration\Event\MessageEventHandler;
use Fifa\ConnectId\Sdk\Registration\Event\MessageEventHandlerInterface;
use Fifa\ConnectId\Sdk\Registration\Model\DuplicatesOfRegisteredPerson;
use Fifa\ConnectId\Sdk\Registration\Model\RegistrationFacadeSettings;
use Fifa\ConnectId\Sdk\Registration\Model\RegistrationResult;
use Fifa\ConnectId\Sdk\Registration\Model\UpdateResult;
use Fifa\ConnectId\Sdk\Storage\PersonDetailsStorageInterface;
use Fifa\ConnectId\Sdk\Storage\SqliteStorage;
use Fifa\ConnectId\Sdk\Util\CorrelationId;
use Fifa\ConnectId\Sdk\Util\ToString\Exception\ToStringException;
use Fifa\ConnectId\Sdk\Util\ToString\ToString;
use Fifa\ConnectServiceBus\Sdk\Authentication\Model\ClientCredentials;
use Fifa\ConnectServiceBus\Sdk\Encryption\Decrypt\PrivateCertificateStorageInterface;
use Fifa\ConnectServiceBus\Sdk\Exception\AuthenticationException;
use Fifa\ConnectServiceBus\Sdk\Exception\FifaConnectServiceBusSdkException;
use Fifa\ConnectServiceBus\Sdk\Exception\UnauthorizedException;
use Fifa\ConnectServiceBus\Sdk\FifaConnectServiceBusClient;
use Fifa\ConnectServiceBus\Sdk\FifaConnectServiceBusClientInterface;
use Psr\Log\LoggerInterface;
use UnexpectedValueException;

/**
 * Class RegistrationFacade
 * @package Fifa\ConnectId\Sdk\Registration
 */
class RegistrationFacade implements RegistrationFacadeInterface
{
    const FIFA_WORLD_ASSOCIATION_ID = '105C3ZB';

    /**
     * @var LoggerInterface
     */
    private $logger;

    /**
     * @var FifaConnectIdClientInterface
     */
    private $connectIdClient;

    /**
     * @var FifaConnectServiceBusClientInterface
     */
    private $serviceBusClient;

    /**
     * @var PersonDetailsStorageInterface
     */
    private $storage;

    /**
     * @var int
     */
    private $pollingIntervalMilliseconds;

    /**
     * @var MessageEventHandlerInterface
     */
    private $messageEventHandler;

    /**
     * @var bool
     */
    private $areDefaultEventHandlersConfigured = false;

    /**
     * @var int
     */
    private $receivingOperationExecutionTime;

    /**
     * @var PersonDetailsMessageExchanger
     */
    private $personDetailsMessageExchanger;

    /**
     * Constructor
     *
     * @param EnvironmentInterface $environment
     * @param ClientCredentials $clientCredentials
     * @param PrivateCertificateStorageInterface $privateCertificateStorage
     * @param LoggerInterface $logger
     * @param RegistrationFacadeSettings|null $settings
     */
    public function __construct(EnvironmentInterface $environment, ClientCredentials $clientCredentials, PrivateCertificateStorageInterface $privateCertificateStorage, LoggerInterface $logger, ?RegistrationFacadeSettings $settings = null)
    {
        $hasSettings = !is_null($settings);

        $this->storage = $hasSettings ? $settings->getStorage() : SqliteStorage::getInstance($logger);
        $this->receivingOperationExecutionTime = $hasSettings ? $settings->getReceivingOperationExecutionTime() : 60;
        $this->pollingIntervalMilliseconds = $hasSettings ? $settings->getPollingIntervalMilliseconds() : 50;
        $this->logger = $logger;
        $actorResolver = $hasSettings ? $settings->getActorResolver() : SimpleActorResolver::emptyActor();
        $hashedPersonProcessor = $hasSettings ? ($settings->getHashedPersonProcessor() ?? new HashedPersonTypeHashingProcessor()) : new HashedPersonTypeHashingProcessor();

        $this->connectIdClient = new FifaConnectIdClient($environment, $clientCredentials, $this->getLogger(), $actorResolver, $hashedPersonProcessor);
        $this->serviceBusClient = FifaConnectServiceBusClient::create(
            $environment->getServiceBusEnvironment(), $clientCredentials, $privateCertificateStorage, $this->getLogger()
        );
        $this->messageEventHandler = new MessageEventHandler($this->getLogger(), $this->getServiceBusClient(), $this->receivingOperationExecutionTime);
        $this->personDetailsMessageExchanger = new PersonDetailsMessageExchanger($this->serviceBusClient, $this->logger);
    }

    /**
     * Getter for connectIdClient
     *
     * @return FifaConnectIdClientInterface
     */
    public function getConnectIdClient(): FifaConnectIdClientInterface
    {
        return $this->connectIdClient;
    }

    /**
     * Getter for serviceBusClient
     *
     * @return FifaConnectServiceBusClientInterface
     */
    public function getServiceBusClient(): FifaConnectServiceBusClientInterface
    {
        return $this->serviceBusClient;
    }

    /**
     * Getter for logger
     *
     * @return LoggerInterface
     */
    public function getLogger(): LoggerInterface
    {
        return $this->logger;
    }

    /**
     * Sends requests for duplicates details
     *
     * @param PersonDuplicateType[] $duplicates
     * @return PersonDuplicateWithDetails[]
     * @throws ToStringException
     */
    public function sendRequestsForDetails(array $duplicates): array
    {
        // no validation because of custom error handling
        $correlationIdsAndDuplicates = array();

        foreach ($duplicates as $duplicate) {

            $this->logger->debug('Handling duplicate: ' . ToString::getValue($duplicate));
            $correlationId = CorrelationId::create();
            $registration = null;

            try {

                $registration = $duplicate->getPrimaryDataProviderRegistrationType();

                if (is_null($registration) || empty($registration->getOrganisationFifaId())) {
                    $this->logger->debug(sprintf('No organisation ID was found for person with ID %s. Skipping.', $duplicate->getPersonFifaId()));
                    $correlationIdsAndDuplicates[$correlationId] = new PersonDuplicateWithDetails(
                        $duplicate, null, !is_null($registration) ? $registration->getSystemId() : null,
                        new ValidationException(sprintf('No organisation found in person registrations for person `%s`', $duplicate->getPersonFifaId()))
                    );
                    continue;
                }

                $recipient = empty($registration->getSystemId()) ? $registration->getOrganisationFifaId() : $registration->getSystemId();

                try {
                    $this->personDetailsMessageExchanger->sendRequestForPersonDetails($duplicate->getPersonFifaId(), $recipient, $correlationId);
                    $correlationIdsAndDuplicates[$correlationId] = new PersonDuplicateWithDetails($duplicate, $registration->getOrganisationFifaId(), $registration->getSystemId());
                } catch (FifaConnectServiceBusSdkException $exception) {
                    $this->logger->error(sprintf('Error occurred when sending request for details of `%s` to organisation `%s`', $duplicate->getPersonFifaId(), $recipient), array('exception' => $exception->__toString()));
                    $correlationIdsAndDuplicates[$correlationId] = new PersonDuplicateWithDetails($duplicate, $registration->getOrganisationFifaId(), $registration->getSystemId(), $exception);
                }
            } catch (Exception $exception) {
                $this->logger->error(
                    sprintf('Unexpected error occurred when trying to send request for details of `%s` to organisation: `%s`.', $duplicate->getPersonFifaId(), $recipient ?? ''),
                    array('exception' => $exception->__toString())
                );
                $correlationIdsAndDuplicates[$correlationId] = new PersonDuplicateWithDetails(
                    $duplicate,
                    is_null($registration) ? null : $registration->getOrganisationFifaId(),
                    is_null($registration) ? null : $registration->getSystemId(),
                    $exception
                );
            }
        }

        return $correlationIdsAndDuplicates;
    }

    /**
     * Sends requests for duplicates details
     *
     * @param PersonDuplicateType[] $duplicates - Collection of person duplicates
     * @param int $timeoutSeconds - Time to wait for duplicates details in seconds
     * @return PersonDuplicateWithDetails[]
     *
     * @throws ToStringException
     * @throws UnexpectedValueException
     */
    public function sendRequestsForDetailsAndWaitForThem(array $duplicates, int $timeoutSeconds): array
    {
        $idsAndDetails = $this->sendRequestsForDetails($duplicates);
        $endTime = time() + $timeoutSeconds;
        $stillExpectedResponses = array();

        foreach ($idsAndDetails as $key => $entry) {
            if ($entry->wasRequestSend()) {
                $stillExpectedResponses[$key] = $key;
            }
        }

        while (true) {

            if (time() > $endTime) {
                $this->logger->debug(
                    sprintf('Waiting for details stopped. Unable to receive %s message(s) within %s seconds timeout.', sizeof($stillExpectedResponses), $timeoutSeconds)
                );
                break;
            }
            usleep($this->pollingIntervalMilliseconds);
            $receivedResponses = array();

            foreach ($idsAndDetails as $correlationId => $duplicate) {

                $response = $this->storage->getByCorrelationId($correlationId);
                if (is_null($response)) {
                    continue;
                }

                $idsAndDetails[$correlationId] = $duplicate->setPersonDetails($response);
                $receivedResponses[$correlationId] = $correlationId;
            }

            foreach ($receivedResponses as $responseCorrelationId) {
                unset($stillExpectedResponses[$responseCorrelationId]);
            }

            if (sizeof($stillExpectedResponses) === 0) {
                break;
            }
        }

        return $idsAndDetails;
    }

    /**
     * Registers a new person and assigns FIFA Unique Identifier.
     * If duplicates are found the person is not registered.
     * Uses 'PersonLocalType' as defined in FIFA Data Standard.
     *
     * @param PersonData $personData - Object containing information about local and international names of the person, as well as the date of birth.
     * @param int $timeoutSeconds - Time to wait for duplicates details.
     * @param string|null $personLocalId - Local identifier of person
     * @return RegistrationResult
     *
     * @throws AuthenticationException
     * @throws FifaConnectIdException
     * @throws InvalidClientDataException
     * @throws UnauthorizedException
     * @throws ToStringException
     * @throws UnexpectedValueException
     * @throws ValidationException
     * @throws TooManyRequestsException
     */
    public function registerPersonAndWaitForDetailsInCaseOfDuplicatesByGivingPersonData(PersonData $personData, int $timeoutSeconds, ?string $personLocalId = null): RegistrationResult
    {
        try {
            $uniqueFifaId = $this->getConnectIdClient()->getPersonApi()->register($personData, $personLocalId);
            return RegistrationResult::success($uniqueFifaId);
        } catch (FifaDuplicatedPersonFoundException $e) {
            $duplicates = $e->getDuplicates();
            $personDuplicateWithDetails = $this->sendRequestsForDetailsAndWaitForThem($duplicates, $timeoutSeconds);
            return RegistrationResult::duplicatesFound($personDuplicateWithDetails);
        }
    }

    /**
     * Checks for duplicates of a person.
     * If duplicates are found, the details are requested by Connect Service Bus.
     *
     * @param PersonData $personData - Object containing information about local and international names of the person, as well as the date of birth.
     * @param int $timeoutSeconds - Time to wait for duplicates details.
     * @param string[]|null $organisationsIds - Organisations FIFA Unique Identifiers
     * @return PersonDuplicateWithDetails[]
     *
     * @throws InvalidClientDataException
     * @throws FifaConnectIdException
     * @throws AuthenticationException
     * @throws UnauthorizedException
     * @throws ValidationException
     * @throws ToStringException
     * @throws TooManyRequestsException
     *
     * @deprecated Method getDuplicatesAndWaitForDetailsInCaseOfDuplicates is obsolete. Use getDuplicates instead.
     */
    public function getDuplicatesAndWaitForDetailsInCaseOfDuplicates(PersonData $personData, int $timeoutSeconds, ?array $organisationsIds = null): array
    {
        return $this->getDuplicates($personData, $timeoutSeconds, $organisationsIds);
    }

    /**
     * Checks for duplicates of a person.
     * If duplicates are found, the details are requested by Connect Service Bus.
     *
     * @param PersonData $personData - Object containing information about local and international names of the person, as well as the date of birth.
     * @param int $timeoutSeconds - Time to wait for duplicates details.
     * @param string[]|null $organisationsIds - Organisations FIFA Unique Identifiers
     * @return PersonDuplicateWithDetails[]
     *
     * @throws InvalidClientDataException
     * @throws FifaConnectIdException
     * @throws AuthenticationException
     * @throws UnauthorizedException
     * @throws ToStringException
     * @throws UnexpectedValueException
     * @throws ValidationException
     * @throws TooManyRequestsException
     */
    public function getDuplicates(PersonData $personData, int $timeoutSeconds, ?array $organisationsIds = null): array
    {
        $duplicates = $this->connectIdClient->getPersonApi()->getDuplicates($personData, $organisationsIds);

        return $this->sendRequestsForDetailsAndWaitForThem($duplicates, $timeoutSeconds);
    }

    /**
     * Checks for duplicates of a person.
     * If duplicates are found, the details are requested by Connect Service Bus.
     * Duplicates marked as known duplicate by trusted organisations would not be returned.
     *
     * @param string $personFifaId - Person FIFA Unique Identifier.
     * @param int $timeoutSeconds - Time to wait for duplicates details.
     * @param string[]|null $trustedOrganisationsIds - Trusted organisations' FIFA Unique Identifiers
     * @return DuplicatesOfRegisteredPerson
     *
     * @throws InvalidClientDataException
     * @throws FifaConnectIdException
     * @throws AuthenticationException
     * @throws UnauthorizedException
     * @throws ToStringException
     * @throws UnexpectedValueException
     * @throws ValidationException
     * @throws TooManyRequestsException
     */
    public function getDuplicatesExceptTrustedKnownDuplicates(string $personFifaId, int $timeoutSeconds, ?array $trustedOrganisationsIds = null): DuplicatesOfRegisteredPerson
    {
        $duplicates = $this->connectIdClient->getPersonApi()->getDuplicatesExceptTrustedKnownDuplicates($personFifaId, $trustedOrganisationsIds);

        return $this->getDuplicatesOfRegisteredPerson($duplicates, $timeoutSeconds);
    }

    /**
     * Updates person data even if possible duplicates are found
     *
     * @param string $fifaId - Fifa identifier of person.
     * @param PersonName[] $names - Null if not changed
     * @param DateTime|null $dateOfBirth - Null if not changed
     * @param string|null $gender
     * @return PersonType
     *
     * @throws AuthenticationException
     * @throws FifaConnectIdException
     * @throws FifaEntityNotFoundException
     * @throws InvalidClientDataException
     * @throws UnauthorizedException
     * @throws ValidationException
     * @throws TooManyRequestsException
     */
    public function forceUpdatePerson(string $fifaId, ?array $names = null, ?DateTime $dateOfBirth = null, ?string $gender = null): PersonType
    {
        return $this->getConnectIdClient()->getPersonApi()->updatePerson($fifaId, $names, $dateOfBirth, $gender, true);
    }

    /**
     * Updates a person
     * If duplicates are found the person is not updated.
     *
     * @param string $fifaId
     * @param int $timeoutSeconds
     * @param PersonName[] $names - Null if not changed
     * @param DateTime|null $dateOfBirth - Null if not changed
     * @param string|null $gender
     * @return UpdateResult
     *
     * @throws AuthenticationException
     * @throws FifaConnectIdException
     * @throws FifaEntityNotFoundException
     * @throws InvalidClientDataException
     * @throws ToStringException
     * @throws UnauthorizedException
     * @throws ValidationException
     * @throws TooManyRequestsException
     */
    public function updatePersonAndWaitForDetailsInCaseOfDuplicates(string $fifaId, int $timeoutSeconds, ?array $names = null, ?DateTime $dateOfBirth = null, ?string $gender = null): UpdateResult
    {
        try {
            $result = $this->getConnectIdClient()->getPersonApi()->updatePerson($fifaId, $names, $dateOfBirth, $gender, false);
            return UpdateResult::success($result);
        } catch (FifaDuplicateOfRegisteredPersonFoundException $e) {
            $duplicates = $this->getDuplicatesOfRegisteredPerson($e->getResponse(), $timeoutSeconds);
            return UpdateResult::duplicatesFound($duplicates);
        }
    }

    /**
     * 1. Receive messages and stores them in local storage mechanism
     * 2. Sends person details when request from other MA will be received
     *
     * @throws FifaConnectIdException
     */
    public function receiveMessages()
    {
        $this->logger->debug('Receiving messages started.');
        $this->messageEventHandler->run();
        $this->logger->debug('Receiving messages stopped.');
    }

    /**
     * Setup event handlers responsible for handling messages
     *
     * @param PersonDetailsProviderInterface $personDetailsProvider
     * @param MessageHandlerInterface[]|null $messageHandlers
     * @return MessageEventHandlerInterface
     */
    public function configureEventHandlers(PersonDetailsProviderInterface $personDetailsProvider, ?array $messageHandlers = null): MessageEventHandlerInterface
    {
        if (!$this->areDefaultEventHandlersConfigured) {
            $messageHandlers = $messageHandlers != null ? $messageHandlers : [];
            $this->messageEventHandler = new MessageEventHandler($this->getLogger(), $this->getServiceBusClient(), $this->receivingOperationExecutionTime, ...$messageHandlers);
            $this->messageEventHandler->getPersonDetailsRequestReceivedEvent()->addEventHandler(
                new DefaultPersonDetailsRequestReceivedHandler($personDetailsProvider, $this->getServiceBusClient())
            );
            $this->messageEventHandler->getPersonDetailsReceivedEvent()->addEventHandler(
                new DefaultPersonDetailsReceivedHandler($this->storage)
            );
            $this->areDefaultEventHandlersConfigured = true;
        }

        return $this->messageEventHandler;
    }

    /**
     * Registers a new person even if possible duplicates are found.
     * Uses 'PersonLocalType' as defined in FIFA Data Standard.
     *
     * @param PersonData $personData - object containing information about local and international names of the person, as well as the date of birth.
     * @param string|null $personLocalId - Local identifier of person
     * @return string
     *
     * @throws InvalidClientDataException
     * @throws FifaConnectIdException
     * @throws UnauthorizedException
     * @throws AuthenticationException
     * @throws ValidationException
     * @throws TooManyRequestsException
     */
    public function forceRegisterPerson(PersonData $personData, ?string $personLocalId = null): string
    {
        return $this->getConnectIdClient()->getPersonApi()->forceRegister($personData, $personLocalId);
    }

    /**
     * Sends request to given recipient for person details (with uniqueFifaId) and returns XML string.
     * In case when no recipientId is given, it will be resolved based on person's registrations.
     *
     * @param string $uniqueFifaId - Identifier of a person whose details will be returned.
     * @param int $timeoutSeconds - Time to wait for person details.
     * @param string|null $recipientId - Identifier of an organisation (Member Association) that will be asked for person details.
     * @return string - String that contains XML complaint with FIFA Data Standard
     *
     * @throws ApiException
     * @throws AuthenticationException
     * @throws FifaConnectIdException
     * @throws FifaEntityNotFoundException
     * @throws FifaPersonMergedException
     * @throws InvalidClientDataException
     * @throws MemberAssociationDoesNotRespondException
     * @throws PersonDetailsNotReceivedException
     * @throws UnauthorizedException
     * @throws ValidationException
     * @throws TooManyRequestsException
     * @throws FifaConnectServiceBusSdkException
     * @throws Exception
     */
    public function sendRequestForDetailsAndWaitForThem(string $uniqueFifaId, int $timeoutSeconds, ?string $recipientId = null): string
    {
        try {
            $this->validateFifaAccessLevel();
            $registration = $this->connectIdClient->getPersonApi()->getPrimaryDataProviderRegistration($uniqueFifaId);
            $recipientId = is_null($recipientId) ? $this->extractRecipientFifaId($uniqueFifaId, $registration) : $recipientId;

            $correlationId = $this->sendRequestForPersonDetails($uniqueFifaId, $recipientId);
            $endTime = time() + $timeoutSeconds;
            $personDetails = null;

            while (time() < $endTime && $personDetails === null) {
                usleep($this->pollingIntervalMilliseconds);
                $personDetails = $this->storage->getByCorrelationId($correlationId);
            }

            if ($personDetails === null) {
                throw new MemberAssociationDoesNotRespondException($recipientId, $timeoutSeconds);
            }

            if (empty($personDetails->getXmlData())) {
                throw new PersonDetailsNotReceivedException($uniqueFifaId, $recipientId);
            }

            return $personDetails->getXmlData();
        }
        catch (Exception $exception) {
            $this->logger->error($exception->getMessage(), array('exception' => $exception->__toString()));
            throw $exception;
        }
    }

    /**
     * @param string $uniqueFifaId
     * @param string $recipient
     * @return string
     *
     * @throws FifaConnectServiceBusSdkException
     */
    private function sendRequestForPersonDetails(string $uniqueFifaId, string $recipient): string
    {
        $correlationId =  CorrelationId::create();
        $this->personDetailsMessageExchanger->sendRequestForPersonDetails($uniqueFifaId, $recipient, $correlationId);
        return $correlationId;
    }

    /**
     * @throws ApiException
     * @throws UnauthorizedException
     */
    private function validateFifaAccessLevel()
    {
        $actingOrganisations = $this->connectIdClient->getOrganisationApi()->getIdDirectoryApiClient()->organisationGetActingOrganisations();
        if (!in_array(self::FIFA_WORLD_ASSOCIATION_ID, $actingOrganisations->getOrganisationsFifaIds())) {
            throw new UnauthorizedException('Operation cannot be executed without FIFA World Association level permissions.');
        }
    }

    private function extractRecipientFifaId(string $uniqueFifaId, RegistrationType $registration = null): string
    {
        if ($registration === null || $registration->getOrganisationFifaId() === null) {
            $message = sprintf('Person with ID `%s` has no registrations defined. Unable to determine recipient organisation FIFA ID.', $uniqueFifaId);
            throw new UnexpectedValueException($message);
        }

        return empty($registration->getSystemId()) ? $registration->getOrganisationFifaId() : $registration->getSystemId();
    }

    private function getDuplicatesOfRegisteredPerson(GetDuplicatesOfRegisteredPersonResponseType $response, int $timeout) {
        $duplicates = $response->getDuplicates();
        $personDuplicatesWithDetails = $this->sendRequestsForDetailsAndWaitForThem($duplicates, $timeout);

        return new DuplicatesOfRegisteredPerson($response->getRequestedPersonRegistrationContext(), $personDuplicatesWithDetails);
    }
}
