<?php

declare(strict_types=1);

namespace ShlinkioTest\Shlink\Rest\Middleware;

use Fig\Http\Message\RequestMethodInterface;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequest;
use Laminas\Diactoros\ServerRequestFactory;
use Mezzio\Router\Route;
use Mezzio\Router\RouteResult;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Rest\Action\HealthAction;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Exception\MissingAuthenticationException;
use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
use Shlinkio\Shlink\Rest\Service\ApiKeyCheckResult;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;

use function Laminas\Stratigility\middleware;

class AuthenticationMiddlewareTest extends TestCase
{
    use ProphecyTrait;

    private AuthenticationMiddleware $middleware;
    private ObjectProphecy $apiKeyService;
    private ObjectProphecy $handler;

    protected function setUp(): void
    {
        $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
        $this->middleware = new AuthenticationMiddleware(
            $this->apiKeyService->reveal(),
            [HealthAction::class],
            ['with_query_api_key'],
        );
        $this->handler = $this->prophesize(RequestHandlerInterface::class);
    }

    /**
     * @test
     * @dataProvider provideRequestsWithoutAuth
     */
    public function someSituationsFallbackToNextMiddleware(ServerRequestInterface $request): void
    {
        $handle = $this->handler->handle($request)->willReturn(new Response());
        $checkApiKey = $this->apiKeyService->check(Argument::any());

        $this->middleware->process($request, $this->handler->reveal());

        $handle->shouldHaveBeenCalledOnce();
        $checkApiKey->shouldNotHaveBeenCalled();
    }

    public function provideRequestsWithoutAuth(): iterable
    {
        $dummyMiddleware = $this->getDummyMiddleware();

        yield 'no route result' => [new ServerRequest()];
        yield 'failure route result' => [(new ServerRequest())->withAttribute(
            RouteResult::class,
            RouteResult::fromRouteFailure([RequestMethodInterface::METHOD_GET]),
        )];
        yield 'route without API key required' => [(new ServerRequest())->withAttribute(
            RouteResult::class,
            RouteResult::fromRoute(
                new Route('foo', $dummyMiddleware, Route::HTTP_METHOD_ANY, HealthAction::class),
            ),
        )];
        yield 'OPTIONS method' => [(new ServerRequest())->withAttribute(
            RouteResult::class,
            RouteResult::fromRoute(new Route('bar', $dummyMiddleware), []),
        )->withMethod(RequestMethodInterface::METHOD_OPTIONS)];
    }

    /**
     * @test
     * @dataProvider provideRequestsWithoutApiKey
     */
    public function throwsExceptionWhenNoApiKeyIsProvided(
        ServerRequestInterface $request,
        string $expectedMessage,
    ): void {
        $this->apiKeyService->check(Argument::any())->shouldNotBeCalled();
        $this->handler->handle($request)->shouldNotBeCalled();
        $this->expectException(MissingAuthenticationException::class);
        $this->expectExceptionMessage($expectedMessage);

        $this->middleware->process($request, $this->handler->reveal());
    }

    public function provideRequestsWithoutApiKey(): iterable
    {
        $baseRequest = fn (string $routeName) => ServerRequestFactory::fromGlobals()->withAttribute(
            RouteResult::class,
            RouteResult::fromRoute(new Route($routeName, $this->getDummyMiddleware()), []),
        );
        $apiKeyMessage = 'Expected one of the following authentication headers, ["X-Api-Key"], but none were provided';
        $queryMessage = 'Expected authentication to be provided in "apiKey" query param';

        yield 'no api key in header' => [$baseRequest('bar'), $apiKeyMessage];
        yield 'empty api key in header' => [$baseRequest('bar')->withHeader('X-Api-Key', ''), $apiKeyMessage];
        yield 'no api key in query' => [$baseRequest('with_query_api_key'), $queryMessage];
        yield 'empty api key in query' => [
            $baseRequest('with_query_api_key')->withQueryParams(['apiKey' => '']),
            $queryMessage,
        ];
    }

    /** @test */
    public function throwsExceptionWhenProvidedApiKeyIsInvalid(): void
    {
        $apiKey = 'abc123';
        $request = ServerRequestFactory::fromGlobals()
            ->withAttribute(
                RouteResult::class,
                RouteResult::fromRoute(new Route('bar', $this->getDummyMiddleware()), []),
            )
            ->withHeader('X-Api-Key', $apiKey);

        $this->apiKeyService->check($apiKey)->willReturn(new ApiKeyCheckResult())->shouldBeCalledOnce();
        $this->handler->handle($request)->shouldNotBeCalled();
        $this->expectException(VerifyAuthenticationException::class);
        $this->expectExceptionMessage('Provided API key does not exist or is invalid');

        $this->middleware->process($request, $this->handler->reveal());
    }

    /** @test */
    public function validApiKeyFallsBackToNextMiddleware(): void
    {
        $apiKey = ApiKey::create();
        $key = $apiKey->toString();
        $request = ServerRequestFactory::fromGlobals()
            ->withAttribute(
                RouteResult::class,
                RouteResult::fromRoute(new Route('bar', $this->getDummyMiddleware()), []),
            )
            ->withHeader('X-Api-Key', $key);

        $handle = $this->handler->handle($request->withAttribute(ApiKey::class, $apiKey))->willReturn(new Response());
        $checkApiKey = $this->apiKeyService->check($key)->willReturn(new ApiKeyCheckResult($apiKey));

        $this->middleware->process($request, $this->handler->reveal());

        $handle->shouldHaveBeenCalledOnce();
        $checkApiKey->shouldHaveBeenCalledOnce();
    }

    private function getDummyMiddleware(): MiddlewareInterface
    {
        return middleware(fn () => new Response\EmptyResponse());
    }
}