<?php

namespace Firebase\JWT;

use LogicException;
use OutOfBoundsException;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use RuntimeException;

class CachedKeySetTest extends TestCase
{
    use ProphecyTrait;

    private $testJwksUri = 'https://jwk.uri';
    private $testJwksUriKey = 'jwkshttpsjwk.uri';
    private $testJwks1 = '{"keys": [{"kid":"foo","kty":"RSA","alg":"foo","n":"","e":""}]}';
    private $testCachedJwks1 = ['foo' => ['kid' => 'foo', 'kty' => 'RSA', 'alg' => 'foo', 'n' => '', 'e' => '']];
    private $testJwks2 = '{"keys": [{"kid":"bar","kty":"RSA","alg":"bar","n":"","e":""}]}';
    private $testJwks3 = '{"keys": [{"kid":"baz","kty":"RSA","n":"","e":""}]}';

    private $googleRsaUri = 'https://www.googleapis.com/oauth2/v3/certs';
    private $googleEcUri = 'https://www.gstatic.com/iap/verify/public_key-jwk';

    public function testEmptyUriThrowsException()
    {
        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('JWKS URI is empty');

        $cachedKeySet = new CachedKeySet(
            '',
            $this->prophesize(ClientInterface::class)->reveal(),
            $this->prophesize(RequestFactoryInterface::class)->reveal(),
            $this->prophesize(CacheItemPoolInterface::class)->reveal()
        );

        $cachedKeySet['foo'];
    }

    public function testOffsetSetThrowsException()
    {
        $this->expectException(LogicException::class);
        $this->expectExceptionMessage('Method not implemented');

        $cachedKeySet = new CachedKeySet(
            $this->testJwksUri,
            $this->prophesize(ClientInterface::class)->reveal(),
            $this->prophesize(RequestFactoryInterface::class)->reveal(),
            $this->prophesize(CacheItemPoolInterface::class)->reveal()
        );

        $cachedKeySet['foo'] = 'bar';
    }

    public function testOffsetUnsetThrowsException()
    {
        $this->expectException(LogicException::class);
        $this->expectExceptionMessage('Method not implemented');

        $cachedKeySet = new CachedKeySet(
            $this->testJwksUri,
            $this->prophesize(ClientInterface::class)->reveal(),
            $this->prophesize(RequestFactoryInterface::class)->reveal(),
            $this->prophesize(CacheItemPoolInterface::class)->reveal()
        );

        unset($cachedKeySet['foo']);
    }

    public function testOutOfBoundsThrowsException()
    {
        $this->expectException(OutOfBoundsException::class);
        $this->expectExceptionMessage('Key ID not found');

        $cachedKeySet = new CachedKeySet(
            $this->testJwksUri,
            $this->getMockHttpClient($this->testJwks1),
            $this->getMockHttpFactory(),
            $this->getMockEmptyCache()
        );

        // keyID doesn't exist
        $cachedKeySet['bar'];
    }

    public function testInvalidHttpResponseThrowsException()
    {
        $this->expectException(\UnexpectedValueException::class);
        $this->expectExceptionMessage('HTTP Error: 404 URL not found');
        $this->expectExceptionCode(404);

        $response = $this->prophesize('Psr\Http\Message\ResponseInterface');
        $response->getStatusCode()
            ->shouldBeCalled()
            ->willReturn(404);
        $response->getReasonPhrase()
            ->shouldBeCalledTimes(1)
            ->willReturn('URL not found');

        $http = $this->prophesize(ClientInterface::class);
        $http->sendRequest(Argument::any())
            ->shouldBeCalledTimes(1)
            ->willReturn($response->reveal());

        $cachedKeySet = new CachedKeySet(
            $this->testJwksUri,
            $http->reveal(),
            $this->getMockHttpFactory(),
            $this->getMockEmptyCache()
        );

        isset($cachedKeySet[0]);
    }

    public function testWithExistingKeyId()
    {
        $cachedKeySet = new CachedKeySet(
            $this->testJwksUri,
            $this->getMockHttpClient($this->testJwks1),
            $this->getMockHttpFactory(),
            $this->getMockEmptyCache()
        );
        $this->assertInstanceOf(Key::class, $cachedKeySet['foo']);
        $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm());
    }

    public function testWithDefaultAlg()
    {
        $cachedKeySet = new CachedKeySet(
            $this->testJwksUri,
            $this->getMockHttpClient($this->testJwks3),
            $this->getMockHttpFactory(),
            $this->getMockEmptyCache(),
            null,
            false,
            'baz256'
        );
        $this->assertInstanceOf(Key::class, $cachedKeySet['baz']);
        $this->assertSame('baz256', $cachedKeySet['baz']->getAlgorithm());
    }

    public function testKeyIdIsCached()
    {
        $cacheItem = $this->prophesize(CacheItemInterface::class);
        $cacheItem->isHit()
            ->willReturn(true);
        $cacheItem->get()
            ->willReturn($this->testCachedJwks1);

        $cache = $this->prophesize(CacheItemPoolInterface::class);
        $cache->getItem($this->testJwksUriKey)
            ->willReturn($cacheItem->reveal());
        $cache->save(Argument::any())
            ->willReturn(true);

        $cachedKeySet = new CachedKeySet(
            $this->testJwksUri,
            $this->prophesize(ClientInterface::class)->reveal(),
            $this->prophesize(RequestFactoryInterface::class)->reveal(),
            $cache->reveal()
        );
        $this->assertInstanceOf(Key::class, $cachedKeySet['foo']);
        $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm());
    }

    public function testCachedKeyIdRefresh()
    {
        $cacheItem = $this->prophesize(CacheItemInterface::class);
        $cacheItem->isHit()
            ->shouldBeCalledOnce()
            ->willReturn(true);
        $cacheItem->get()
            ->shouldBeCalledOnce()
            ->willReturn($this->testCachedJwks1);
        $cacheItem->set(Argument::any())
            ->shouldBeCalledOnce()
            ->will(function () {
                return $this;
            });

        $cache = $this->prophesize(CacheItemPoolInterface::class);
        $cache->getItem($this->testJwksUriKey)
            ->shouldBeCalledOnce()
            ->willReturn($cacheItem->reveal());
        $cache->save(Argument::any())
            ->shouldBeCalledOnce()
            ->willReturn(true);

        $cachedKeySet = new CachedKeySet(
            $this->testJwksUri,
            $this->getMockHttpClient($this->testJwks2), // updated JWK
            $this->getMockHttpFactory(),
            $cache->reveal()
        );
        $this->assertInstanceOf(Key::class, $cachedKeySet['foo']);
        $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm());

        $this->assertInstanceOf(Key::class, $cachedKeySet['bar']);
        $this->assertSame('bar', $cachedKeySet['bar']->getAlgorithm());
    }

    public function testKeyIdIsCachedFromPreviousFormat()
    {
        $cacheItem = $this->prophesize(CacheItemInterface::class);
        $cacheItem->isHit()
            ->willReturn(true);
        $cacheItem->get()
            ->willReturn($this->testJwks1);

        $cache = $this->prophesize(CacheItemPoolInterface::class);
        $cache->getItem($this->testJwksUriKey)
            ->willReturn($cacheItem->reveal());
        $cache->save(Argument::any())
            ->willReturn(true);

        $cachedKeySet = new CachedKeySet(
            $this->testJwksUri,
            $this->prophesize(ClientInterface::class)->reveal(),
            $this->prophesize(RequestFactoryInterface::class)->reveal(),
            $cache->reveal()
        );
        $this->assertInstanceOf(Key::class, $cachedKeySet['foo']);
        $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm());
    }

    public function testCachedKeyIdRefreshFromPreviousFormat()
    {
        $cacheItem = $this->prophesize(CacheItemInterface::class);
        $cacheItem->isHit()
            ->shouldBeCalledOnce()
            ->willReturn(true);
        $cacheItem->get()
            ->shouldBeCalledOnce()
            ->willReturn($this->testJwks1);
        $cacheItem->set(Argument::any())
            ->shouldBeCalledOnce()
            ->will(function () {
                return $this;
            });

        $cache = $this->prophesize(CacheItemPoolInterface::class);
        $cache->getItem($this->testJwksUriKey)
            ->shouldBeCalledOnce()
            ->willReturn($cacheItem->reveal());
        $cache->save(Argument::any())
            ->shouldBeCalledOnce()
            ->willReturn(true);

        $cachedKeySet = new CachedKeySet(
            $this->testJwksUri,
            $this->getMockHttpClient($this->testJwks2), // updated JWK
            $this->getMockHttpFactory(),
            $cache->reveal()
        );
        $this->assertInstanceOf(Key::class, $cachedKeySet['foo']);
        $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm());

        $this->assertInstanceOf(Key::class, $cachedKeySet['bar']);
        $this->assertSame('bar', $cachedKeySet['bar']->getAlgorithm());
    }

    public function testCacheItemWithExpiresAfter()
    {
        $expiresAfter = 10;
        $cacheItem = $this->prophesize(CacheItemInterface::class);
        $cacheItem->isHit()
            ->shouldBeCalledOnce()
            ->willReturn(false);
        $cacheItem->set(Argument::any())
            ->shouldBeCalledOnce()
            ->will(function () {
                return $this;
            });
        $cacheItem->expiresAfter($expiresAfter)
            ->shouldBeCalledOnce()
            ->will(function () {
                return $this;
            });

        $cache = $this->prophesize(CacheItemPoolInterface::class);
        $cache->getItem($this->testJwksUriKey)
            ->shouldBeCalledOnce()
            ->willReturn($cacheItem->reveal());
        $cache->save(Argument::any())
            ->shouldBeCalledOnce();

        $cachedKeySet = new CachedKeySet(
            $this->testJwksUri,
            $this->getMockHttpClient($this->testJwks1),
            $this->getMockHttpFactory(),
            $cache->reveal(),
            $expiresAfter
        );
        $this->assertInstanceOf(Key::class, $cachedKeySet['foo']);
        $this->assertSame('foo', $cachedKeySet['foo']->getAlgorithm());
    }

    public function testJwtVerify()
    {
        $privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem');
        $payload = ['sub' => 'foo', 'exp' => strtotime('+10 seconds')];
        $msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1');

        // format the cached value to match the expected format
        $cachedJwks = [];
        $rsaKeySet = file_get_contents(__DIR__ . '/data/rsa-jwkset.json');
        foreach (json_decode($rsaKeySet, true)['keys'] as $k => $v) {
            $cachedJwks[$v['kid']] = $v;
        }

        $cacheItem = $this->prophesize(CacheItemInterface::class);
        $cacheItem->isHit()
            ->willReturn(true);
        $cacheItem->get()
            ->willReturn($cachedJwks);

        $cache = $this->prophesize(CacheItemPoolInterface::class);
        $cache->getItem($this->testJwksUriKey)
            ->willReturn($cacheItem->reveal());

        $cachedKeySet = new CachedKeySet(
            $this->testJwksUri,
            $this->prophesize(ClientInterface::class)->reveal(),
            $this->prophesize(RequestFactoryInterface::class)->reveal(),
            $cache->reveal()
        );

        $result = JWT::decode($msg, $cachedKeySet);

        $this->assertSame('foo', $result->sub);
    }

    public function testRateLimit()
    {
        // We request the key 11 times, HTTP should only be called 10 times
        $shouldBeCalledTimes = 10;

        // Instantiate the cached key set
        $cachedKeySet = new CachedKeySet(
            $this->testJwksUri,
            $this->getMockHttpClient($this->testJwks1, $shouldBeCalledTimes),
            $this->getMockHttpFactory($shouldBeCalledTimes),
            new TestMemoryCacheItemPool(),
            10,  // expires after seconds
            true // enable rate limiting
        );

        $invalidKid = 'invalidkey';
        for ($i = 0; $i < 10; $i++) {
            $this->assertFalse(isset($cachedKeySet[$invalidKid]));
        }
        // The 11th time does not call HTTP
        $this->assertFalse(isset($cachedKeySet[$invalidKid]));
    }

    public function testRateLimitWithExpiresAfter()
    {
        // We request the key 17 times, HTTP should only be called 15 times
        $shouldBeCalledTimes = 10;
        $cachedTimes = 2;
        $afterExpirationTimes = 5;

        $totalHttpTimes = $shouldBeCalledTimes + $afterExpirationTimes;

        $cachePool = new TestMemoryCacheItemPool();

        // Instantiate the cached key set
        $cachedKeySet = new CachedKeySet(
            $this->testJwksUri,
            $this->getMockHttpClient($this->testJwks1, $totalHttpTimes),
            $this->getMockHttpFactory($totalHttpTimes),
            $cachePool,
            10,   // expires after seconds
            true // enable rate limiting
        );

        // Set the rate limit cache to expire after 1 second
        $cacheItem = $cachePool->getItem('jwksratelimitjwkshttpsjwk.uri');
        $cacheItem->set([
            'expiry' => new \DateTime('+1 second', new \DateTimeZone('UTC')),
            'callsPerMinute' => 0,
        ]);
        $cacheItem->expiresAfter(1);
        $cachePool->save($cacheItem);

        $invalidKid = 'invalidkey';
        for ($i = 0; $i < $shouldBeCalledTimes; $i++) {
            $this->assertFalse(isset($cachedKeySet[$invalidKid]));
        }

        // The next calls do not call HTTP
        for ($i = 0; $i < $cachedTimes; $i++) {
            $this->assertFalse(isset($cachedKeySet[$invalidKid]));
        }

        sleep(1); // wait for cache to expire

        // These calls DO call HTTP because the cache has expired
        for ($i = 0; $i < $afterExpirationTimes; $i++) {
            $this->assertFalse(isset($cachedKeySet[$invalidKid]));
        }
    }

    /**
     * @dataProvider provideFullIntegration
     */
    public function testFullIntegration(string $jwkUri): void
    {
        if (!class_exists(\GuzzleHttp\Psr7\HttpFactory::class)) {
            self::markTestSkipped('Guzzle 7 only');
        }
        // Create cache and http objects
        $cache = new TestMemoryCacheItemPool();
        $http = new \GuzzleHttp\Client();
        $factory = new \GuzzleHttp\Psr7\HttpFactory();

        // Determine "kid" dynamically, because these constantly change
        $response = $http->get($jwkUri);
        $json = (string) $response->getBody();
        $keys = json_decode($json, true);
        $kid = $keys['keys'][0]['kid'] ?? null;
        $this->assertNotNull($kid);

        // Instantiate the cached key set
        $cachedKeySet = new CachedKeySet(
            $jwkUri,
            $http,
            $factory,
            $cache
        );

        $this->assertArrayHasKey($kid, $cachedKeySet);
        $key = $cachedKeySet[$kid];
        $this->assertInstanceOf(Key::class, $key);
        $this->assertSame($keys['keys'][0]['alg'], $key->getAlgorithm());
    }

    public function provideFullIntegration()
    {
        return [
            [$this->googleRsaUri],
            [$this->googleEcUri, 'LYyP2g']
        ];
    }

    private function getMockHttpClient($testJwks, int $timesCalled = 1)
    {
        $body = $this->prophesize('Psr\Http\Message\StreamInterface');
        $body->__toString()
            ->shouldBeCalledTimes($timesCalled)
            ->willReturn($testJwks);

        $response = $this->prophesize('Psr\Http\Message\ResponseInterface');
        $response->getBody()
            ->shouldBeCalledTimes($timesCalled)
            ->willReturn($body->reveal());
        $response->getStatusCode()
            ->shouldBeCalledTimes($timesCalled)
            ->willReturn(200);

        $http = $this->prophesize(ClientInterface::class);
        $http->sendRequest(Argument::any())
            ->shouldBeCalledTimes($timesCalled)
            ->willReturn($response->reveal());

        return $http->reveal();
    }

    private function getMockHttpFactory(int $timesCalled = 1)
    {
        $request = $this->prophesize('Psr\Http\Message\RequestInterface');
        $factory = $this->prophesize(RequestFactoryInterface::class);
        $factory->createRequest('GET', $this->testJwksUri)
            ->shouldBeCalledTimes($timesCalled)
            ->willReturn($request->reveal());

        return $factory->reveal();
    }

    private function getMockEmptyCache()
    {
        $cacheItem = $this->prophesize(CacheItemInterface::class);
        $cacheItem->isHit()
            ->shouldBeCalledOnce()
            ->willReturn(false);
        $cacheItem->set(Argument::any())
            ->will(function () {
                return $this;
            });

        $cache = $this->prophesize(CacheItemPoolInterface::class);
        $cache->getItem($this->testJwksUriKey)
            ->shouldBeCalledOnce()
            ->willReturn($cacheItem->reveal());
        $cache->save(Argument::any())
            ->willReturn(true);

        return $cache->reveal();
    }
}

/**
 * A cache item pool
 */
final class TestMemoryCacheItemPool implements CacheItemPoolInterface
{
    private $items;
    private $deferredItems;

    public function getItem($key): CacheItemInterface
    {
        $item = current($this->getItems([$key]));
        $item->expiresAt(null); // mimic symfony cache behavior

        return $item;
    }

    public function getItems(array $keys = []): iterable
    {
        $items = [];

        foreach ($keys as $key) {
            $items[$key] = $this->hasItem($key) ? clone $this->items[$key] : new TestMemoryCacheItem($key);
        }

        return $items;
    }

    public function hasItem($key): bool
    {
        return isset($this->items[$key]) && $this->items[$key]->isHit();
    }

    public function clear(): bool
    {
        $this->items = [];
        $this->deferredItems = [];

        return true;
    }

    public function deleteItem($key): bool
    {
        return $this->deleteItems([$key]);
    }

    public function deleteItems(array $keys): bool
    {
        foreach ($keys as $key) {
            unset($this->items[$key]);
        }

        return true;
    }

    public function save(CacheItemInterface $item): bool
    {
        $this->items[$item->getKey()] = $item;

        return true;
    }

    public function saveDeferred(CacheItemInterface $item): bool
    {
        $this->deferredItems[$item->getKey()] = $item;

        return true;
    }

    public function commit(): bool
    {
        foreach ($this->deferredItems as $item) {
            $this->save($item);
        }

        $this->deferredItems = [];

        return true;
    }
}

/**
 * A cache item.
 */
final class TestMemoryCacheItem implements CacheItemInterface
{
    private $key;
    private $value;
    private $expiration;
    private $isHit = false;

    public function __construct(string $key)
    {
        $this->key = $key;
    }

    public function getKey(): string
    {
        return $this->key;
    }

    public function get(): mixed
    {
        return $this->isHit() ? $this->value : null;
    }

    public function isHit(): bool
    {
        if (!$this->isHit) {
            return false;
        }

        if ($this->expiration === null) {
            return true;
        }

        return $this->currentTime()->getTimestamp() < $this->expiration->getTimestamp();
    }

    public function set(mixed $value): static
    {
        $this->isHit = true;
        $this->value = $value;

        return $this;
    }

    public function expiresAt($expiration): static
    {
        $this->expiration = $expiration;
        return $this;
    }

    public function expiresAfter($time): static
    {
        $this->expiration = $this->currentTime()->add(new \DateInterval("PT{$time}S"));
        return $this;
    }

    protected function currentTime()
    {
        return new \DateTime('now', new \DateTimeZone('UTC'));
    }
}
