<?php

namespace App\Service;

use App\Config\DnsKey\Algorithm;
use App\Config\DnsKey\DigestType;
use App\Config\EventAction;
use App\Entity\DnsKey;
use App\Entity\Domain;
use App\Entity\DomainEntity;
use App\Entity\DomainEvent;
use App\Entity\DomainStatus;
use App\Entity\Entity;
use App\Entity\EntityEvent;
use App\Entity\Nameserver;
use App\Entity\NameserverEntity;
use App\Entity\RdapServer;
use App\Entity\Tld;
use App\Exception\DomainNotFoundException;
use App\Exception\MalformedDomainException;
use App\Exception\RdapServerException;
use App\Exception\TldNotSupportedException;
use App\Exception\UnknownRdapServerException;
use App\Repository\DomainEntityRepository;
use App\Repository\DomainEventRepository;
use App\Repository\DomainRepository;
use App\Repository\DomainStatusRepository;
use App\Repository\EntityEventRepository;
use App\Repository\EntityRepository;
use App\Repository\IcannAccreditationRepository;
use App\Repository\NameserverEntityRepository;
use App\Repository\NameserverRepository;
use App\Repository\RdapServerRepository;
use App\Repository\TldRepository;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\OptimisticLockException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpClient\Exception\ClientException;
use Symfony\Component\HttpClient\Exception\ServerException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

class RDAPService
{
    public const ENTITY_HANDLE_BLACKLIST = [
        'REDACTED_FOR_PRIVACY',
        'ANO00-FRNIC',
        'not applicable',
        'REDACTED FOR PRIVACY-REGISTRANT',
        'REDACTED FOR PRIVACY-TECH',
        'REDACTED FOR PRIVACY',
        'REDACTED-SIDN',
        'REGISTRANT',
        'REGISTRAR',
        'ABUSE-CONTACT',
        'None',
        'Private',
    ];

    public function __construct(
        private readonly HttpClientInterface $client,
        private readonly EntityRepository $entityRepository,
        private readonly DomainRepository $domainRepository,
        private readonly DomainEventRepository $domainEventRepository,
        private readonly NameserverRepository $nameserverRepository,
        private readonly NameserverEntityRepository $nameserverEntityRepository,
        private readonly EntityEventRepository $entityEventRepository,
        private readonly DomainEntityRepository $domainEntityRepository,
        private readonly RdapServerRepository $rdapServerRepository,
        private readonly TldRepository $tldRepository,
        private readonly IcannAccreditationRepository $icannAccreditationRepository,
        private readonly EntityManagerInterface $em,
        private readonly LoggerInterface $logger,
        private readonly StatService $statService,
        private readonly InfluxdbService $influxService,
        #[Autowire(param: 'influxdb_enabled')]
        private readonly bool $influxdbEnabled,
        private readonly DomainStatusRepository $domainStatusRepository,
    ) {
    }

    /**
     * @throws RedirectionExceptionInterface
     * @throws DomainNotFoundException
     * @throws DecodingExceptionInterface
     * @throws TldNotSupportedException
     * @throws ClientExceptionInterface
     * @throws OptimisticLockException
     * @throws TransportExceptionInterface
     * @throws ServerExceptionInterface
     * @throws MalformedDomainException
     * @throws UnknownRdapServerException
     */
    public function registerDomains(array $domains): void
    {
        foreach ($domains as $fqdn) {
            $this->registerDomain($fqdn);
        }
    }

    /**
     * @throws DomainNotFoundException
     * @throws RedirectionExceptionInterface
     * @throws DecodingExceptionInterface
     * @throws TldNotSupportedException
     * @throws ClientExceptionInterface
     * @throws OptimisticLockException
     * @throws TransportExceptionInterface
     * @throws MalformedDomainException
     * @throws ServerExceptionInterface
     * @throws UnknownRdapServerException
     * @throws \Exception
     */
    public function registerDomain(string $fqdn): Domain
    {
        $idnDomain = RDAPService::convertToIdn($fqdn);
        $tld = $this->getTld($idnDomain);

        $this->logger->debug('Update request for a domain name is requested', [
            'ldhName' => $idnDomain,
        ]);

        $rdapServer = $this->fetchRdapServer($tld);
        $domain = $this->domainRepository->findOneBy(['ldhName' => $idnDomain]);

        $rdapData = $this->fetchRdapResponse($rdapServer, $idnDomain, $domain);
        $this->em->beginTransaction();

        if (null === $domain) {
            $domain = $this->initNewDomain($idnDomain, $tld);
            $this->em->persist($domain);
        }

        $this->em->lock($domain, LockMode::PESSIMISTIC_WRITE);

        $this->updateDomainStatus($domain, $rdapData);

        if (in_array('free', $domain->getStatus())) {
            throw DomainNotFoundException::fromDomain($idnDomain);
        }

        $domain
            ->setRdapServer($rdapServer)
            ->setDelegationSigned(isset($rdapData['secureDNS']['delegationSigned']) && $rdapData['secureDNS']['delegationSigned']);

        $this->updateDomainHandle($domain, $rdapData);

        $this->updateDomainEvents($domain, $rdapData);
        $this->updateDomainEntities($domain, $rdapData);
        $this->updateDomainNameservers($domain, $rdapData);
        $this->updateDomainDsData($domain, $rdapData);

        $domain->setDeleted(false)->updateTimestamps();

        $this->em->flush();
        $this->em->commit();

        return $domain;
    }

    /**
     * @throws TldNotSupportedException
     * @throws MalformedDomainException
     */
    public function getTld(string $domain): Tld
    {
        if (!str_contains($domain, OfficialDataService::DOMAIN_DOT)) {
            $tldEntity = $this->tldRepository->findOneBy(['tld' => OfficialDataService::DOMAIN_DOT]);

            if (null == $tldEntity) {
                throw TldNotSupportedException::fromTld(OfficialDataService::DOMAIN_DOT);
            }

            return $tldEntity;
        }

        $lastDotPosition = strrpos($domain, OfficialDataService::DOMAIN_DOT);

        if (false === $lastDotPosition) {
            throw MalformedDomainException::fromDomain($domain);
        }

        $tld = self::convertToIdn(substr($domain, $lastDotPosition + 1));
        $tldEntity = $this->tldRepository->findOneBy(['tld' => $tld, 'deletedAt' => null]);

        if (null === $tldEntity) {
            $this->logger->debug('Domain name cannot be updated because the TLD is not supported', [
                'ldhName' => $domain,
            ]);
            throw TldNotSupportedException::fromTld($tld);
        }

        return $tldEntity;
    }

    /**
     * @throws MalformedDomainException
     */
    public static function convertToIdn(string $fqdn): string
    {
        $ascii = strtolower(idn_to_ascii($fqdn));

        if (OfficialDataService::DOMAIN_DOT !== $fqdn && !preg_match('/^(xn--)?[a-z0-9-]+(\.[a-z0-9-]+)*$/', $ascii)) {
            throw MalformedDomainException::fromDomain($fqdn);
        }

        return $ascii;
    }

    /**
     * @throws UnknownRdapServerException
     */
    private function fetchRdapServer(Tld $tld): RdapServer
    {
        $tldString = $tld->getTld();
        $rdapServer = $this->rdapServerRepository->findOneBy(['tld' => $tldString], ['updatedAt' => 'DESC']);

        if (null === $rdapServer) {
            $this->logger->debug('Unable to determine which RDAP server to contact', [
                'tld' => $tldString,
            ]);

            throw UnknownRdapServerException::fromTld($tldString);
        }

        return $rdapServer;
    }

    /**
     * @throws TransportExceptionInterface
     * @throws ServerExceptionInterface
     * @throws RedirectionExceptionInterface
     * @throws DecodingExceptionInterface
     * @throws ClientExceptionInterface
     * @throws \Exception
     */
    private function fetchRdapResponse(RdapServer $rdapServer, string $idnDomain, ?Domain $domain): array
    {
        $rdapServerUrl = $rdapServer->getUrl();
        $this->logger->info('An RDAP query to update this domain name will be made', [
            'ldhName' => $idnDomain,
            'server' => $rdapServerUrl,
        ]);

        try {
            $req = $this->client->request('GET', $rdapServerUrl.'domain/'.$idnDomain);
            $this->statService->incrementStat('stats.rdap_queries.count');

            return $req->toArray();
        } catch (\Exception $e) {
            throw $this->handleRdapException($e, $idnDomain, $domain, $req ?? null);
        } finally {
            if ($this->influxdbEnabled && isset($req)) {
                $this->influxService->addRdapQueryPoint($rdapServer, $idnDomain, $req->getInfo());
            }
        }
    }

    /**
     * @throws TransportExceptionInterface
     * @throws \Exception
     */
    private function handleRdapException(\Exception $e, string $idnDomain, ?Domain $domain, ?ResponseInterface $response): \Exception
    {
        if (
            ($e instanceof ClientException && Response::HTTP_NOT_FOUND === $e->getResponse()->getStatusCode())
            || ($e instanceof TransportExceptionInterface && null !== $response && !in_array('content-length', $response->getHeaders(false)) && 404 === $response->getStatusCode())
        ) {
            if (null !== $domain) {
                $this->logger->info('Domain name has been deleted from the WHOIS database', [
                    'ldhName' => $idnDomain,
                ]);

                $domain->updateTimestamps();

                if (!$domain->getDeleted() && $domain->getUpdatedAt() !== $domain->getCreatedAt()) {
                    $this->em->persist((new DomainStatus())
                        ->setDomain($domain)
                        ->setCreatedAt($domain->getUpdatedAt())
                        ->setDate($domain->getUpdatedAt())
                        ->setAddStatus([])
                        ->setDeleteStatus($domain->getStatus()));
                }

                $domain->setDeleted(true);
                $this->em->flush();
            }

            return DomainNotFoundException::fromDomain($idnDomain);
        }

        $this->logger->error('Unable to perform an RDAP query for this domain name', [
            'ldhName' => $idnDomain,
        ]);

        if ($e instanceof ServerException) {
            return RdapServerException::fromServerException($e);
        }

        return $e;
    }

    private function initNewDomain(string $idnDomain, Tld $tld): Domain
    {
        $domain = new Domain();

        $this->logger->debug('Domain name was not known to this instance', [
            'ldhName' => $idnDomain,
        ]);

        return $domain->setTld($tld)->setLdhName($idnDomain)->setDeleted(false);
    }

    private function updateDomainStatus(Domain $domain, array $rdapData): void
    {
        if (isset($rdapData['status']) && is_array($rdapData['status'])) {
            $status = array_map(fn ($s) => strtolower($s), array_unique($rdapData['status']));
            $addedStatus = array_values(array_diff($status, $domain->getStatus()));
            $deletedStatus = array_values(array_diff($domain->getStatus(), $status));
            $domain->setStatus($status);

            if (count($addedStatus) > 0 || count($deletedStatus) > 0) {
                $this->em->persist($domain);

                if ($domain->getUpdatedAt() !== $domain->getCreatedAt()) {
                    $this->em->persist((new DomainStatus())
                        ->setDomain($domain)
                        ->setCreatedAt(new \DateTimeImmutable('now'))
                        ->setDate($domain->getUpdatedAt())
                        ->setAddStatus($addedStatus)
                        ->setDeleteStatus($deletedStatus));
                }
            }
        } else {
            $this->logger->warning('Domain name has no WHOIS status', [
                'ldhName' => $domain->getLdhName(),
            ]);
        }
    }

    private function updateDomainHandle(Domain $domain, array $rdapData): void
    {
        if (isset($rdapData['handle'])) {
            $domain->setHandle($rdapData['handle']);
        } else {
            $this->logger->debug('Domain name has no handle key', [
                'ldhName' => $domain->getLdhName(),
            ]);
        }
    }

    /**
     * @throws \Exception
     */
    private function updateDomainEvents(Domain $domain, array $rdapData): void
    {
        $this->domainEventRepository->setDomainEventAsDeleted($domain);

        if (isset($rdapData['events']) && is_array($rdapData['events'])) {
            foreach ($rdapData['events'] as $rdapEvent) {
                if ($rdapEvent['eventAction'] === EventAction::LastUpdateOfRDAPDatabase->value) {
                    continue;
                }

                $event = $this->domainEventRepository->findOneBy([
                    'action' => $rdapEvent['eventAction'],
                    'date' => new \DateTimeImmutable($rdapEvent['eventDate']),
                    'domain' => $domain,
                ]);

                if (null === $event) {
                    $event = new DomainEvent();
                } else {
                    // at this point Doctrine doesn't know that the events are
                    // deleted in the database, so refresh in order to make the diff work
                    $this->em->refresh($event);
                }

                $domain->addEvent($event
                    ->setAction(strtolower($rdapEvent['eventAction']))
                    ->setDate(new \DateTimeImmutable($rdapEvent['eventDate']))
                    ->setDeleted(false));

                $this->em->persist($domain);
            }
        }
    }

    /**
     * @throws \Exception
     */
    private function updateDomainEntities(Domain $domain, array $rdapData): void
    {
        $this->domainEntityRepository->setDomainEntityAsDeleted($domain);

        if (!isset($rdapData['entities']) || !is_array($rdapData['entities'])) {
            return;
        }

        foreach ($rdapData['entities'] as $rdapEntity) {
            $roles = $this->extractEntityRoles($rdapData['entities'], $rdapEntity);
            $entity = $this->registerEntity($rdapEntity, $roles, $domain->getLdhName(), $domain->getTld());

            $domainEntity = $this->domainEntityRepository->findOneBy([
                'domain' => $domain,
                'entity' => $entity,
            ]);

            if (null === $domainEntity) {
                $domainEntity = new DomainEntity();
            } else {
                $this->em->refresh($domainEntity);
            }

            $domain->addDomainEntity($domainEntity
                ->setDomain($domain)
                ->setEntity($entity)
                ->setRoles($roles)
                ->setDeletedAt(null));

            $this->em->persist($domainEntity);
            $this->em->flush();
        }
    }

    /**
     * @throws \Exception
     */
    private function updateDomainNameservers(Domain $domain, array $rdapData): void
    {
        if (isset($rdapData['nameservers']) && is_array($rdapData['nameservers'])) {
            $domain->getNameservers()->clear();
            $this->em->persist($domain);

            foreach ($rdapData['nameservers'] as $rdapNameserver) {
                $nameserver = $this->fetchOrCreateNameserver($rdapNameserver, $domain);
                $this->updateNameserverEntities($nameserver, $rdapNameserver, $domain->getTld());

                if (!$domain->getNameservers()->contains($nameserver)) {
                    $domain->addNameserver($nameserver);
                }
            }
        } else {
            $this->logger->debug('Domain name has no nameservers', [
                'ldhName' => $domain->getLdhName(),
            ]);
        }
    }

    private function fetchOrCreateNameserver(array $rdapNameserver, Domain $domain): Nameserver
    {
        $ldhName = strtolower(rtrim($rdapNameserver['ldhName'], '.'));
        $nameserver = $this->nameserverRepository->findOneBy([
            'ldhName' => $ldhName,
        ]);

        $existingDomainNS = $domain->getNameservers()->findFirst(fn (int $key, Nameserver $ns) => $ns->getLdhName() === $ldhName);

        if (null !== $existingDomainNS) {
            return $existingDomainNS;
        } elseif (null === $nameserver) {
            $nameserver = new Nameserver();
        }

        $nameserver->setLdhName($ldhName);

        return $nameserver;
    }

    /**
     * @throws \Exception
     */
    private function updateNameserverEntities(Nameserver $nameserver, array $rdapNameserver, Tld $tld): void
    {
        if (!isset($rdapNameserver['entities']) || !is_array($rdapNameserver['entities'])) {
            return;
        }

        foreach ($rdapNameserver['entities'] as $rdapEntity) {
            $roles = $this->extractEntityRoles($rdapNameserver['entities'], $rdapEntity);
            $entity = $this->registerEntity($rdapEntity, $roles, $nameserver->getLdhName(), $tld);

            $nameserverEntity = $this->nameserverEntityRepository->findOneBy([
                'nameserver' => $nameserver,
                'entity' => $entity,
            ]);

            if (null === $nameserverEntity) {
                $nameserverEntity = new NameserverEntity();
            }

            if (isset($rdapNameserver['status']) && is_array($rdapNameserver['status'])) {
                $nameserverEntity->setStatus(array_map(fn ($s) => strtolower($s), array_unique($rdapNameserver['status'])));
            }

            $nameserver->addNameserverEntity($nameserverEntity
                ->setNameserver($nameserver)
                ->setEntity($entity)
                ->setRoles($roles));

            $this->em->persist($nameserverEntity);
            $this->em->flush();
        }
    }

    private function extractEntityRoles(array $entities, array $targetEntity): array
    {
        $roles = array_map(
            fn ($e) => $e['roles'],
            array_filter(
                $entities,
                fn ($e) => isset($targetEntity['handle']) && isset($e['handle'])
                    ? $targetEntity['handle'] === $e['handle']
                    : (
                        isset($targetEntity['vcardArray']) && isset($e['vcardArray'])
                                ? $targetEntity['vcardArray'] === $e['vcardArray']
                                : $targetEntity === $e
                    )
            )
        );

        if (count($roles) !== count($roles, COUNT_RECURSIVE)) {
            $roles = array_merge(...$roles);
        }

        return array_map(fn ($x) => strtolower($x), $roles);
    }

    /**
     * @throws \Exception
     */
    private function registerEntity(array $rdapEntity, array $roles, string $domain, Tld $tld): Entity
    {
        /*
         * If there is no number to identify the entity, one is generated from the domain name and the roles associated with this entity
         */
        if (!isset($rdapEntity['handle']) || '' === $rdapEntity['handle'] || in_array($rdapEntity['handle'], self::ENTITY_HANDLE_BLACKLIST)) {
            sort($roles);
            $rdapEntity['handle'] = 'DW-FAKEHANDLE-'.$domain.'-'.implode(',', $roles);

            $this->logger->debug('Entity has no handle key', [
                'handle' => $rdapEntity['handle'],
                'ldhName' => $domain,
            ]);
        }

        $entity = $this->entityRepository->findOneBy([
            'handle' => $rdapEntity['handle'],
            'tld' => $tld,
        ]);

        if (null === $entity) {
            $entity = (new Entity())->setTld($tld);

            $this->logger->debug('Entity was not known to this instance', [
                'handle' => $rdapEntity['handle'],
                'ldhName' => $domain,
            ]);
        }

        /**
         * If the RDAP server transmits the entity's IANA number, it is used as a priority to identify the entity.
         *
         * @see https://datatracker.ietf.org/doc/html/rfc7483#section-4.8
         */
        $icannAccreditation = null;
        if (isset($rdapEntity['publicIds'])) {
            foreach ($rdapEntity['publicIds'] as $publicId) {
                if ('IANA Registrar ID' === $publicId['type'] && isset($publicId['identifier']) && '' !== $publicId['identifier']) {
                    $icannAccreditation = $this->icannAccreditationRepository->findOneBy([
                        'id' => (int) $publicId['identifier'],
                    ]);
                }
            }
        }

        $entity->setHandle($rdapEntity['handle'])->setIcannAccreditation($icannAccreditation);

        if (isset($rdapEntity['remarks']) && is_array($rdapEntity['remarks'])) {
            $entity->setRemarks($rdapEntity['remarks']);
        }

        if (isset($rdapEntity['vcardArray'])) {
            if (empty($entity->getJCard())) {
                if (!array_key_exists('elements', $rdapEntity['vcardArray'])) {
                    $entity->setJCard($rdapEntity['vcardArray']);
                } else {
                    /*
                     * UZ registry
                     */
                    $entity->setJCard([
                        'vcard',
                        $rdapEntity['vcardArray']['elements'],
                    ]);
                }
            } else {
                $properties = [];
                if (!array_key_exists('elements', $rdapEntity['vcardArray'])) {
                    foreach ($rdapEntity['vcardArray'][1] as $prop) {
                        $properties[$prop[0]] = $prop;
                    }
                } else {
                    /*
                     * UZ registry
                     */
                    foreach ($rdapEntity['vcardArray']['elements'] as $prop) {
                        $properties[$prop[0]] = $prop;
                    }
                }
                foreach ($entity->getJCard()[1] as $prop) {
                    $properties[$prop[0]] = $prop;
                }
                $entity->setJCard(['vcard', array_values($properties)]);
            }
        }

        if (isset($rdapEntity['events'])) {
            /** @var EntityEvent $event */
            foreach ($entity->getEvents()->getIterator() as $event) {
                $event->setDeleted(true);
            }

            $this->em->persist($entity);

            foreach ($rdapEntity['events'] as $rdapEntityEvent) {
                $eventAction = $rdapEntityEvent['eventAction'];
                if ($eventAction === EventAction::LastChanged->value || $eventAction === EventAction::LastUpdateOfRDAPDatabase->value) {
                    continue;
                }
                $event = $this->entityEventRepository->findOneBy([
                    'action' => $rdapEntityEvent['eventAction'],
                    'date' => new \DateTimeImmutable($rdapEntityEvent['eventDate']),
                ]);

                if (null !== $event) {
                    $event->setDeleted(false);
                    continue;
                }
                $entity->addEvent(
                    (new EntityEvent())
                        ->setEntity($entity)
                        ->setAction(strtolower($rdapEntityEvent['eventAction']))
                        ->setDate(new \DateTimeImmutable($rdapEntityEvent['eventDate']))
                        ->setDeleted(false));
            }
        }

        $this->em->persist($entity);
        $this->em->flush();

        return $entity;
    }

    private function updateDomainDsData(Domain $domain, array $rdapData): void
    {
        $domain->getDnsKey()->clear();
        $this->em->persist($domain);
        $this->em->flush();

        if (array_key_exists('secureDNS', $rdapData) && array_key_exists('dsData', $rdapData['secureDNS']) && is_array($rdapData['secureDNS']['dsData'])) {
            foreach ($rdapData['secureDNS']['dsData'] as $rdapDsData) {
                $dsData = new DnsKey();
                if (array_key_exists('keyTag', $rdapDsData)) {
                    $dsData->setKeyTag(pack('n', $rdapDsData['keyTag']));
                }
                if (array_key_exists('algorithm', $rdapDsData)) {
                    $dsData->setAlgorithm(Algorithm::from($rdapDsData['algorithm']));
                }
                if (array_key_exists('digest', $rdapDsData)) {
                    try {
                        $blob = hex2bin($rdapDsData['digest']);
                    } catch (\Exception) {
                        $this->logger->warning('DNSSEC digest is not a valid hexadecimal value', [
                            'ldhName' => $domain,
                            'value' => $rdapDsData['digest'],
                        ]);
                        continue;
                    }

                    if (false === $blob) {
                        $this->logger->warning('DNSSEC digest is not a valid hexadecimal value', [
                            'ldhName' => $domain,
                            'value' => $rdapDsData['digest'],
                        ]);
                        continue;
                    }
                    $dsData->setDigest($blob);
                }
                if (array_key_exists('digestType', $rdapDsData)) {
                    $dsData->setDigestType(DigestType::from($rdapDsData['digestType']));
                }

                $digestLengthByte = [
                    DigestType::SHA1->value => 20,
                    DigestType::SHA256->value => 32,
                    DigestType::GOST_R_34_11_94->value => 32,
                    DigestType::SHA384->value => 48,
                    DigestType::GOST_R_34_11_2012->value => 64,
                    DigestType::SM3->value => 32,
                ];

                if (array_key_exists($dsData->getDigestType()->value, $digestLengthByte)
                    && strlen($dsData->getDigest()) / 2 !== $digestLengthByte[$dsData->getDigestType()->value]) {
                    $this->logger->warning('DNSSEC digest does not have a valid length according to the digest type', [
                        'ldhName' => $domain,
                        'value' => $dsData->getDigest(),
                        'type' => $dsData->getDigestType()->name,
                    ]);
                    continue;
                }

                $domain->addDnsKey($dsData);
                $this->em->persist($dsData);
            }
        } else {
            $this->logger->debug('Domain name has no DS record', [
                'ldhName' => $domain->getLdhName(),
            ]);
        }
    }

    private function calculateDaysFromStatus(Domain $domain, \DateTimeImmutable $now): ?int
    {
        /** @var ?DomainStatus $lastStatus */
        $lastStatus = $this->domainStatusRepository->findLastDomainStatus($domain);

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

        if ($domain->isPendingDelete() && (
            in_array('pending delete', $lastStatus->getAddStatus())
            || in_array('redemption period', $lastStatus->getDeleteStatus()))
        ) {
            return self::daysBetween($now, $lastStatus->getCreatedAt()->add(new \DateInterval('P'. 5 .'D')));
        }

        if ($domain->isRedemptionPeriod()
            && in_array('redemption period', $lastStatus->getAddStatus())
        ) {
            return self::daysBetween($now, $lastStatus->getCreatedAt()->add(new \DateInterval('P'.(30 + 5).'D')));
        }

        return null;
    }

    private function getRelevantDates(Domain $domain): array
    {
        /** @var ?DomainEvent $expirationEvent */
        $expirationEvent = $this->domainEventRepository->findLastDomainEvent($domain, 'expiration');
        /** @var ?DomainEvent $deletionEvent */
        $deletionEvent = $this->domainEventRepository->findLastDomainEvent($domain, 'deletion');

        return [$expirationEvent?->getDate(), $deletionEvent?->getDate()];
    }

    public function getExpiresInDays(Domain $domain): ?int
    {
        if ($domain->getDeleted()) {
            return null;
        }

        $now = new \DateTimeImmutable();
        [$expiredAt, $deletedAt] = $this->getRelevantDates($domain);

        if ($expiredAt) {
            $guess = self::daysBetween($now, $expiredAt->add(new \DateInterval('P'.(45 + 30 + 5).'D')));
        }

        if ($deletedAt) {
            // It has been observed that AFNIC, on the last day, adds a "deleted" event and removes the redemption period status.
            if (0 === self::daysBetween($now, $deletedAt) && $domain->isPendingDelete()) {
                return 0;
            }

            $guess = self::daysBetween($now, $deletedAt->add(new \DateInterval('P'. 30 .'D')));
        }

        return self::returnExpiresIn([
            $guess ?? null,
            $this->calculateDaysFromStatus($domain, $now),
        ]);
    }

    /**
     * Returns true if one or more of these conditions are met:
     * - It has been more than 7 days since the domain name was last updated
     * - It has been more than 12 minutes and the domain name has statuses that suggest it is not stable
     * - It has been more than 1 day and the domain name is blocked in DNS
     *
     * @throws \Exception
     */
    public function isToBeUpdated(Domain $domain, bool $fromUser = true, bool $intensifyLastDay = false): bool
    {
        $updatedAtDiff = $domain->getUpdatedAt()->diff(new \DateTimeImmutable());

        if ($updatedAtDiff->days >= 7) {
            return true;
        }

        if ($domain->getDeleted()) {
            return $fromUser;
        }

        $expiresIn = $this->getExpiresInDays($domain);

        if ($updatedAtDiff->s >= 5 && $intensifyLastDay && (0 === $expiresIn || 1 === $expiresIn)) {
            return true;
        }

        $minutesDiff = $updatedAtDiff->h * 60 + $updatedAtDiff->i;
        if (($minutesDiff >= 12 || $fromUser) && $domain->isToBeWatchClosely()) {
            return true;
        }

        if (
            count(array_intersect($domain->getStatus(), ['auto renew period', 'client hold', 'server hold'])) > 0
            && $updatedAtDiff->days >= 1
        ) {
            return true;
        }

        return false;
    }

    /*
       private function calculateDaysFromEvents(\DateTimeImmutable $now): ?int
       {
           $lastChangedEvent = $this->getEvents()->findFirst(fn (int $i, DomainEvent $e) => !$e->getDeleted() && EventAction::LastChanged->value === $e->getAction());
           if (null === $lastChangedEvent) {
               return null;
           }

           if ($this->isRedemptionPeriod()) {
               return self::daysBetween($now, $lastChangedEvent->getDate()->add(new \DateInterval('P'.(30 + 5).'D')));
           }
           if ($this->isPendingDelete()) {
               return self::daysBetween($now, $lastChangedEvent->getDate()->add(new \DateInterval('P'. 5 .'D')));
           }

           return null;
       }
       */

    private static function daysBetween(\DateTimeImmutable $start, \DateTimeImmutable $end): int
    {
        $interval = $start->setTime(0, 0)->diff($end->setTime(0, 0));

        return $interval->invert ? -$interval->days : $interval->days;
    }

    private static function returnExpiresIn(array $guesses): ?int
    {
        $filteredGuesses = array_filter($guesses, function ($value) {
            return null !== $value;
        });

        if (empty($filteredGuesses)) {
            return null;
        }

        return max(min($filteredGuesses), 0);
    }
}
