<?php

declare(strict_types=1);

namespace ShlinkioTest\Shlink\Core\EventDispatcher;

use Doctrine\ORM\EntityManagerInterface;
use OutOfRangeException;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\Core\EventDispatcher\LocateVisit;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;

class LocateVisitTest extends TestCase
{
    use ProphecyTrait;

    private LocateVisit $locateVisit;
    private ObjectProphecy $ipLocationResolver;
    private ObjectProphecy $em;
    private ObjectProphecy $logger;
    private ObjectProphecy $dbUpdater;
    private ObjectProphecy $eventDispatcher;

    public function setUp(): void
    {
        $this->ipLocationResolver = $this->prophesize(IpLocationResolverInterface::class);
        $this->em = $this->prophesize(EntityManagerInterface::class);
        $this->logger = $this->prophesize(LoggerInterface::class);
        $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);

        $this->dbUpdater = $this->prophesize(DbUpdaterInterface::class);
        $this->dbUpdater->databaseFileExists()->willReturn(true);

        $this->locateVisit = new LocateVisit(
            $this->ipLocationResolver->reveal(),
            $this->em->reveal(),
            $this->logger->reveal(),
            $this->dbUpdater->reveal(),
            $this->eventDispatcher->reveal(),
        );
    }

    /** @test */
    public function invalidVisitLogsWarning(): void
    {
        $event = new UrlVisited('123');
        $findVisit = $this->em->find(Visit::class, '123')->willReturn(null);
        $logWarning = $this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [
            'visitId' => 123,
        ]);
        $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void {
        });

        ($this->locateVisit)($event);

        $findVisit->shouldHaveBeenCalledOnce();
        $this->em->flush()->shouldNotHaveBeenCalled();
        $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->shouldNotHaveBeenCalled();
        $logWarning->shouldHaveBeenCalled();
        $dispatch->shouldNotHaveBeenCalled();
    }

    /** @test */
    public function nonExistingGeoLiteDbLogsWarning(): void
    {
        $event = new UrlVisited('123');
        $findVisit = $this->em->find(Visit::class, '123')->willReturn(
            Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')),
        );
        $dbExists = $this->dbUpdater->databaseFileExists()->willReturn(false);
        $logWarning = $this->logger->warning(
            'Tried to locate visit with id "{visitId}", but a GeoLite2 db was not found.',
            ['visitId' => 123],
        );
        $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void {
        });

        ($this->locateVisit)($event);

        $findVisit->shouldHaveBeenCalledOnce();
        $dbExists->shouldHaveBeenCalledOnce();
        $this->em->flush()->shouldNotHaveBeenCalled();
        $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->shouldNotHaveBeenCalled();
        $logWarning->shouldHaveBeenCalled();
        $dispatch->shouldHaveBeenCalledOnce();
    }

    /** @test */
    public function invalidAddressLogsWarning(): void
    {
        $event = new UrlVisited('123');
        $findVisit = $this->em->find(Visit::class, '123')->willReturn(
            Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')),
        );
        $resolveLocation = $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->willThrow(
            WrongIpException::class,
        );
        $logWarning = $this->logger->warning(
            'Tried to locate visit with id "{visitId}", but its address seems to be wrong. {e}',
            Argument::type('array'),
        );
        $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void {
        });

        ($this->locateVisit)($event);

        $findVisit->shouldHaveBeenCalledOnce();
        $resolveLocation->shouldHaveBeenCalledOnce();
        $logWarning->shouldHaveBeenCalled();
        $this->em->flush()->shouldNotHaveBeenCalled();
        $dispatch->shouldHaveBeenCalledOnce();
    }

    /** @test */
    public function unhandledExceptionLogsError(): void
    {
        $event = new UrlVisited('123');
        $findVisit = $this->em->find(Visit::class, '123')->willReturn(
            Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')),
        );
        $resolveLocation = $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->willThrow(
            OutOfRangeException::class,
        );
        $logError = $this->logger->error(
            'An unexpected error occurred while trying to locate visit with id "{visitId}". {e}',
            Argument::type('array'),
        );
        $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void {
        });

        ($this->locateVisit)($event);

        $findVisit->shouldHaveBeenCalledOnce();
        $resolveLocation->shouldHaveBeenCalledOnce();
        $logError->shouldHaveBeenCalled();
        $this->em->flush()->shouldNotHaveBeenCalled();
        $dispatch->shouldHaveBeenCalledOnce();
    }

    /**
     * @test
     * @dataProvider provideNonLocatableVisits
     */
    public function nonLocatableVisitsResolveToEmptyLocations(Visit $visit): void
    {
        $event = new UrlVisited('123');
        $findVisit = $this->em->find(Visit::class, '123')->willReturn($visit);
        $flush = $this->em->flush()->will(function (): void {
        });
        $resolveIp = $this->ipLocationResolver->resolveIpLocation(Argument::any());
        $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void {
        });

        ($this->locateVisit)($event);

        self::assertEquals($visit->getVisitLocation(), VisitLocation::fromGeolocation(Location::emptyInstance()));
        $findVisit->shouldHaveBeenCalledOnce();
        $flush->shouldHaveBeenCalledOnce();
        $resolveIp->shouldNotHaveBeenCalled();
        $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled();
        $dispatch->shouldHaveBeenCalledOnce();
    }

    public function provideNonLocatableVisits(): iterable
    {
        $shortUrl = ShortUrl::createEmpty();

        yield 'null IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', null, ''))];
        yield 'empty IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', '', ''))];
        yield 'localhost' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', IpAddress::LOCALHOST, ''))];
    }

    /**
     * @test
     * @dataProvider provideIpAddresses
     */
    public function locatableVisitsResolveToLocation(Visit $visit, ?string $originalIpAddress): void
    {
        $ipAddr = $originalIpAddress ?? $visit->getRemoteAddr();
        $location = new Location('', '', '', '', 0.0, 0.0, '');
        $event = new UrlVisited('123', $originalIpAddress);

        $findVisit = $this->em->find(Visit::class, '123')->willReturn($visit);
        $flush = $this->em->flush()->will(function (): void {
        });
        $resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location);
        $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void {
        });

        ($this->locateVisit)($event);

        self::assertEquals($visit->getVisitLocation(), VisitLocation::fromGeolocation($location));
        $findVisit->shouldHaveBeenCalledOnce();
        $flush->shouldHaveBeenCalledOnce();
        $resolveIp->shouldHaveBeenCalledOnce();
        $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled();
        $dispatch->shouldHaveBeenCalledOnce();
    }

    public function provideIpAddresses(): iterable
    {
        yield 'no original IP address' => [
            Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')),
            null,
        ];
        yield 'original IP address' => [
            Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')),
            '1.2.3.4',
        ];
        yield 'base url' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4'];
        yield 'invalid short url' => [Visit::forInvalidShortUrl(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4'];
        yield 'regular not found' => [Visit::forRegularNotFound(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4'];
    }
}