<?php

declare(strict_types=1);

namespace ShlinkioTest\Shlink\Core\EventDispatcher;

use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Promise\FulfilledPromise;
use GuzzleHttp\Promise\RejectedPromise;
use GuzzleHttp\RequestOptions;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToWebHooks;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Options\WebhookOptions;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;

use function count;
use function Functional\contains;

class NotifyVisitToWebHooksTest extends TestCase
{
    use ProphecyTrait;

    private ObjectProphecy $httpClient;
    private ObjectProphecy $em;
    private ObjectProphecy $logger;

    public function setUp(): void
    {
        $this->httpClient = $this->prophesize(ClientInterface::class);
        $this->em = $this->prophesize(EntityManagerInterface::class);
        $this->logger = $this->prophesize(LoggerInterface::class);
    }

    /** @test */
    public function emptyWebhooksMakeNoFurtherActions(): void
    {
        $find = $this->em->find(Visit::class, '1')->willReturn(null);

        $this->createListener([])(new VisitLocated('1'));

        $find->shouldNotHaveBeenCalled();
    }

    /** @test */
    public function invalidVisitDoesNotPerformAnyRequest(): void
    {
        $find = $this->em->find(Visit::class, '1')->willReturn(null);
        $requestAsync = $this->httpClient->requestAsync(
            RequestMethodInterface::METHOD_POST,
            Argument::type('string'),
            Argument::type('array'),
        )->willReturn(new FulfilledPromise(''));
        $logWarning = $this->logger->warning(
            'Tried to notify webhooks for visit with id "{visitId}", but it does not exist.',
            ['visitId' => '1'],
        );

        $this->createListener(['foo', 'bar'])(new VisitLocated('1'));

        $find->shouldHaveBeenCalledOnce();
        $logWarning->shouldHaveBeenCalledOnce();
        $requestAsync->shouldNotHaveBeenCalled();
    }

    /** @test */
    public function orphanVisitDoesNotPerformAnyRequestWhenDisabled(): void
    {
        $find = $this->em->find(Visit::class, '1')->willReturn(Visit::forBasePath(Visitor::emptyInstance()));
        $requestAsync = $this->httpClient->requestAsync(
            RequestMethodInterface::METHOD_POST,
            Argument::type('string'),
            Argument::type('array'),
        )->willReturn(new FulfilledPromise(''));
        $logWarning = $this->logger->warning(Argument::cetera());

        $this->createListener(['foo', 'bar'], false)(new VisitLocated('1'));

        $find->shouldHaveBeenCalledOnce();
        $logWarning->shouldNotHaveBeenCalled();
        $requestAsync->shouldNotHaveBeenCalled();
    }

    /**
     * @test
     * @dataProvider provideVisits
     */
    public function expectedRequestsArePerformedToWebhooks(Visit $visit, array $expectedResponseKeys): void
    {
        $webhooks = ['foo', 'invalid', 'bar', 'baz'];
        $invalidWebhooks = ['invalid', 'baz'];

        $find = $this->em->find(Visit::class, '1')->willReturn($visit);
        $requestAsync = $this->httpClient->requestAsync(
            RequestMethodInterface::METHOD_POST,
            Argument::type('string'),
            Argument::that(function (array $requestOptions) use ($expectedResponseKeys) {
                Assert::assertArrayHasKey(RequestOptions::HEADERS, $requestOptions);
                Assert::assertArrayHasKey(RequestOptions::JSON, $requestOptions);
                Assert::assertArrayHasKey(RequestOptions::TIMEOUT, $requestOptions);
                Assert::assertEquals($requestOptions[RequestOptions::TIMEOUT], 10);
                Assert::assertEquals($requestOptions[RequestOptions::HEADERS], ['User-Agent' => 'Shlink:v1.2.3']);

                $json = $requestOptions[RequestOptions::JSON];
                Assert::assertCount(count($expectedResponseKeys), $json);
                foreach ($expectedResponseKeys as $key) {
                    Assert::assertArrayHasKey($key, $json);
                }

                return $requestOptions;
            }),
        )->will(function (array $args) use ($invalidWebhooks) {
            [, $webhook] = $args;
            $shouldReject = contains($invalidWebhooks, $webhook);

            return $shouldReject ? new RejectedPromise(new Exception('')) : new FulfilledPromise('');
        });
        $logWarning = $this->logger->warning(
            'Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}',
            Argument::that(function (array $extra) {
                Assert::assertArrayHasKey('webhook', $extra);
                Assert::assertArrayHasKey('visitId', $extra);
                Assert::assertArrayHasKey('e', $extra);

                return $extra;
            }),
        );

        $this->createListener($webhooks)(new VisitLocated('1'));

        $find->shouldHaveBeenCalledOnce();
        $requestAsync->shouldHaveBeenCalledTimes(count($webhooks));
        $logWarning->shouldHaveBeenCalledTimes(count($invalidWebhooks));
    }

    public function provideVisits(): iterable
    {
        yield 'regular visit' => [
            Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()),
            ['shortUrl', 'visit'],
        ];
        yield 'orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), ['visit'],];
    }

    private function createListener(array $webhooks, bool $notifyOrphanVisits = true): NotifyVisitToWebHooks
    {
        return new NotifyVisitToWebHooks(
            $this->httpClient->reveal(),
            $this->em->reveal(),
            $this->logger->reveal(),
            new WebhookOptions(
                ['visits_webhooks' => $webhooks, 'notify_orphan_visits_to_webhooks' => $notifyOrphanVisits],
            ),
            new ShortUrlDataTransformer(new ShortUrlStringifier([])),
            new AppOptions(['name' => 'Shlink', 'version' => '1.2.3']),
        );
    }
}