<?php

declare(strict_types=1);

namespace ShlinkioTest\Shlink\Core\EventDispatcher;

use Doctrine\ORM\EntityManagerInterface;
use DomainException;
use Exception;
use PhpAmqpLib\Channel\AMQPChannel;
use PhpAmqpLib\Connection\AMQPStreamConnection;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToRabbitMq;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Transformer\OrphanVisitDataTransformer;
use Throwable;

use function count;
use function Functional\contains;

class NotifyVisitToRabbitMqTest extends TestCase
{
    use ProphecyTrait;

    private NotifyVisitToRabbitMq $listener;
    private ObjectProphecy $connection;
    private ObjectProphecy $em;
    private ObjectProphecy $logger;
    private ObjectProphecy $orphanVisitTransformer;
    private ObjectProphecy $channel;

    protected function setUp(): void
    {
        $this->channel = $this->prophesize(AMQPChannel::class);

        $this->connection = $this->prophesize(AMQPStreamConnection::class);
        $this->connection->isConnected()->willReturn(false);
        $this->connection->channel()->willReturn($this->channel->reveal());

        $this->em = $this->prophesize(EntityManagerInterface::class);
        $this->logger = $this->prophesize(LoggerInterface::class);

        $this->listener = new NotifyVisitToRabbitMq(
            $this->connection->reveal(),
            $this->em->reveal(),
            $this->logger->reveal(),
            new OrphanVisitDataTransformer(),
            true,
        );
    }

    /** @test */
    public function doesNothingWhenTheFeatureIsNotEnabled(): void
    {
        $listener = new NotifyVisitToRabbitMq(
            $this->connection->reveal(),
            $this->em->reveal(),
            $this->logger->reveal(),
            new OrphanVisitDataTransformer(),
            false,
        );

        $listener(new VisitLocated('123'));

        $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled();
        $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled();
        $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled();
        $this->connection->isConnected()->shouldNotHaveBeenCalled();
        $this->connection->close()->shouldNotHaveBeenCalled();
    }

    /** @test */
    public function notificationsAreNotSentWhenVisitCannotBeFound(): void
    {
        $visitId = '123';
        $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(null);
        $logWarning = $this->logger->warning(
            'Tried to notify RabbitMQ for visit with id "{visitId}", but it does not exist.',
            ['visitId' => $visitId],
        );

        ($this->listener)(new VisitLocated($visitId));

        $findVisit->shouldHaveBeenCalledOnce();
        $logWarning->shouldHaveBeenCalledOnce();
        $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled();
        $this->connection->isConnected()->shouldNotHaveBeenCalled();
        $this->connection->close()->shouldNotHaveBeenCalled();
    }

    /**
     * @test
     * @dataProvider provideVisits
     */
    public function expectedChannelsAreNotifiedBasedOnTheVisitType(Visit $visit, array $expectedChannels): void
    {
        $visitId = '123';
        $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit);
        $argumentWithExpectedChannel = Argument::that(fn (string $channel) => contains($expectedChannels, $channel));

        ($this->listener)(new VisitLocated($visitId));

        $findVisit->shouldHaveBeenCalledOnce();
        $this->channel->exchange_declare($argumentWithExpectedChannel, Argument::cetera())->shouldHaveBeenCalledTimes(
            count($expectedChannels),
        );
        $this->channel->queue_declare($argumentWithExpectedChannel, Argument::cetera())->shouldHaveBeenCalledTimes(
            count($expectedChannels),
        );
        $this->channel->queue_bind(
            $argumentWithExpectedChannel,
            $argumentWithExpectedChannel,
        )->shouldHaveBeenCalledTimes(count($expectedChannels));
        $this->channel->basic_publish(Argument::any(), $argumentWithExpectedChannel)->shouldHaveBeenCalledTimes(
            count($expectedChannels),
        );
        $this->channel->close()->shouldHaveBeenCalledOnce();
        $this->connection->reconnect()->shouldHaveBeenCalledOnce();
        $this->connection->close()->shouldHaveBeenCalledOnce();
        $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled();
    }

    public function provideVisits(): iterable
    {
        $visitor = Visitor::emptyInstance();

        yield 'orphan visit' => [Visit::forBasePath($visitor), ['https://shlink.io/new-orphan-visit']];
        yield 'non-orphan visit' => [
            Visit::forValidShortUrl(
                ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
                    'longUrl' => 'foo',
                    'customSlug' => 'bar',
                ])),
                $visitor,
            ),
            ['https://shlink.io/new-visit', 'https://shlink.io/new-visit/bar'],
        ];
    }

    /**
     * @test
     * @dataProvider provideExceptions
     */
    public function printsDebugMessageInCaseOfError(Throwable $e): void
    {
        $visitId = '123';
        $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(Visit::forBasePath(Visitor::emptyInstance()));
        $channel = $this->connection->channel()->willThrow($e);

        ($this->listener)(new VisitLocated($visitId));

        $this->logger->debug(
            'Error while trying to notify RabbitMQ with new visit. {e}',
            ['e' => $e],
        )->shouldHaveBeenCalledOnce();
        $this->connection->close()->shouldHaveBeenCalledOnce();
        $this->connection->reconnect()->shouldHaveBeenCalledOnce();
        $findVisit->shouldHaveBeenCalledOnce();
        $channel->shouldHaveBeenCalledOnce();
        $this->channel->close()->shouldNotHaveBeenCalled();
    }

    public function provideExceptions(): iterable
    {
        yield [new RuntimeException('RuntimeException Error')];
        yield [new Exception('Exception Error')];
        yield [new DomainException('DomainException Error')];
    }
}