<?php

namespace Velis\Test\RateLimiter;

use Velis\Cache\CacheInterface;
use Velis\RateLimiter\LeakyBucketRateLimiter;
use Velis\RateLimiter\LeakyBucketRateLimiterInterface;
use Velis\RateLimiter\TooManyAttemptsException;
use Velis\Test\Utility\FakeCache;
use Velis\TestCase;

/**
 * @author Szymon Janaczek <szymon.janaczek@velistech.com>
 */
final class LeakyBucketRateLimiterTest extends TestCase
{
    private CacheInterface $cache;
    private string $prefix = 'rate-limiter-';
    private LeakyBucketRateLimiterInterface $rateLimiter;

    protected function setUp(): void
    {
        $this->cache = new FakeCache();
        $this->rateLimiter = new LeakyBucketRateLimiter(
            cache: $this->cache,
            prefix: $this->prefix,
        );
    }

    protected function tearDown(): void
    {
        $this->cache->clear();
    }

    public function testAttemptsOnEmpty(): void
    {
        $attempts = $this->rateLimiter->attempts('test');

        $this->assertEquals(0, $attempts);
    }

    public function testAttemptsOnNotEmpty(): void
    {
        $this->cache->set($this->rateLimiter->getCleanKey('test'), 3);

        $attempts = $this->rateLimiter->attempts('test');

        $this->assertEquals(3, $attempts);
    }

    public function testHit(): void
    {
        $key = 'test';
        $cacheKey = $this->rateLimiter->getCleanKey($key);

        $this->assertEquals(0, $this->cache->get($cacheKey));

        $this->rateLimiter->hit($key, leakRate: 1, decaySeconds: 60);
        $this->assertEquals(1, $this->cache->get($cacheKey));

        $this->rateLimiter->hit($key, leakRate: 1, decaySeconds: 60);
        $this->assertEquals(2, $this->cache->get($cacheKey));
    }

    public function testIsRateLimitExceeded(): void
    {
        $key = 'test';
        $this->rateLimiter->hit($key, leakRate: 1, decaySeconds: 60);
        $this->rateLimiter->hit($key, leakRate: 1, decaySeconds: 60);

        $this->assertTrue($this->rateLimiter->isRateLimitExceeded($key, 2, 0));
    }

    public function testAttempt(): void
    {
        $key = 'test';
        $this->rateLimiter->attempt(key: $key, rateLimit: 3, leakRate: 1, decaySeconds: 90);
        $this->rateLimiter->attempt(key: $key, rateLimit: 3, leakRate: 1, decaySeconds: 90);

        $this->assertEquals(2, $this->rateLimiter->attempts($key));
    }

    public function testAttemptTooManyTimes(): void
    {
        $this->expectException(TooManyAttemptsException::class);

        $key = 'test';
        $this->rateLimiter->attempt(key: $key, rateLimit: 2, leakRate: 1, decaySeconds: 90);
        $this->rateLimiter->attempt(key: $key, rateLimit: 2, leakRate: 1, decaySeconds: 90);
        $this->rateLimiter->attempt(key: $key, rateLimit: 2, leakRate: 1, decaySeconds: 90);
    }

    /**
     * @dataProvider getCleanKeyDataProvider
     */
    public function testGetCleanKey($originalKey, $expected): void
    {
        $cleanKey = $this->rateLimiter->getCleanKey($originalKey);

        $this->assertEquals($this->prefix . md5($expected), $cleanKey);
    }

    public function getCleanKeyDataProvider(): array
    {
        return [
            'Simple string' => ['Hello', 'Hello'],
            'Ampersand' => ['&', 'a'],
            'HTML special chars' => ['<>"\'&', 'lgq&#039;a'],
            'Already encoded entity' => ['&amp;', 'aamp;'],
            'Mixed content' => ['Hello & World', 'Hello a World'],
            'Multiple special chars' => ['a & b > c < d', 'a a b g c l d'],
            'Numeric entity' => ['&#123;', 'a#123;'],
            'Named entity' => ['&copy;', 'acopy;'],
            'Mixed entities and special chars' => ['Copyright &copy; 2023 & <Company>', 'Copyright acopy; 2023 a lCompanyg'],
            'Unicode character' => ['é', 'e'],
            'Multiple Unicode characters' => ['áéíóú', 'aeiou'],
            'Mixed case entities' => ['&Auml; &auml; &COPY;', 'aAuml; aauml; aCOPY;'],
            'Entity in word' => ['S&shy;peech', 'Sashy;peech'],
            'Illegal symbol :' => ['test:test', 'test-test'],
        ];
    }

    /**
     * @throws TooManyAttemptsException
     */
    public function testAttemptByIdentifierTooManyAttemptsSameIdentifier(): void
    {
        $this->expectException(TooManyAttemptsException::class);

        $this->rateLimiter->setIdentifier('192.168.1.100');

        $this->rateLimiter->attemptByIdentifier(rateLimit: 1, leakRate: 1, key: 'widgets', decaySeconds: 90);
        $this->rateLimiter->attemptByIdentifier(rateLimit: 1, leakRate: 1, key: 'widgets', decaySeconds: 90);
    }

    /**
     * @throws TooManyAttemptsException
     */
    public function testAttemptByIdentifierTooManyAttemptsDifferentIdentifiers(): void
    {
        $this->rateLimiter->setIdentifier('192.168.1.100');

        $this->rateLimiter->attemptByIdentifier(rateLimit: 1, leakRate: 1, key: 'widgets', decaySeconds: 90);

        $this->rateLimiter->setIdentifier('192.168.0.50');
        $this->rateLimiter->attemptByIdentifier(rateLimit: 1, leakRate: 1, key: 'widgets', decaySeconds: 90);

        $this->expectNotToPerformAssertions();
    }

    public function testTtlExpiryBehavior(): void
    {
        $key = 'test';
        $cacheKey = $this->rateLimiter->getCleanKey($key);

        $this->cache->set($cacheKey, 5, 1); // TTL of 1 second
        sleep(2); // Wait for TTL to expire

        $attempts = $this->rateLimiter->attempts($key);
        $this->assertEquals(0, $attempts);
    }

    public function testConcurrency(): void
    {
        $key = 'test';

        // Simulate 10 concurrent hits using a loop
        for ($i = 0; $i < 10; $i++) {
            $this->rateLimiter->hit($key, leakRate: 1, decaySeconds: 60);
        }

        // Ensure that all attempts have been correctly recorded
        $this->assertEquals(10, $this->rateLimiter->attempts($key));
    }

    // region Leaking logic tests
    public function testHitWithMultipleLeaks(): void
    {
        $key = 'test';
        $cacheKey = $this->rateLimiter->getCleanKey($key);

        $this->cache->set($cacheKey, 10);

        $this->rateLimiter->hit($key, leakRate: 3, decaySeconds: 60); // 10+1 = 11
        sleep(1); // leakRate = 3, so 3 attempts should have leaked by now (11-3 = 8)
        $this->rateLimiter->hit($key, leakRate: 3, decaySeconds: 60); // now we add one attempt (8+1 = 9)

        // 3 attempts should have leaked by now, so the final result should be 9
        $this->assertEquals(9, $this->cache->get($cacheKey));
    }

    public function testFullLeak(): void
    {
        $key = 'test';
        $cacheKey = $this->rateLimiter->getCleanKey($key);

        // Set initial attempts to 10
        $this->cache->set($cacheKey, 5);
        $this->cache->set($cacheKey . '-leak-time', microtime(true) - 5); // 5 seconds ago

        $this->rateLimiter->hit($key, leakRate: 1, decaySeconds: 60);

        // 5 seconds have passed, 5 attempts should have leaked
        // So the final result should be 0 + 1 (current attempt) = 1
        $this->assertEquals(1, $this->cache->get($cacheKey));
    }

    public function testPartialLeakRate(): void
    {
        $key = 'test';
        $cacheKey = $this->rateLimiter->getCleanKey($key);

        $this->cache->set($cacheKey, 15);
        $this->cache->set($cacheKey . '-leak-time', microtime(true) - 4); // 4 seconds ago, leakRate = 3 per second

        $this->rateLimiter->hit($key, leakRate: 3, decaySeconds: 60);

        // 4 seconds have passed, with leak rate of 3 per second, 12 attempts should have leaked
        // Since there were only 15 attempts it should now be 3.
        // But we hit it once now.
        // So the final result should be 3 + 1 (current attempt) = 4
        $this->assertEquals(4, $this->cache->get($cacheKey));
    }

    public function testPartialLeakRateWithFractionalLeakRate(): void
    {
        $key = 'test';
        $cacheKey = $this->rateLimiter->getCleanKey($key);

        $this->cache->set($cacheKey, 15);
        $this->cache->set($cacheKey . '-leak-time', microtime(true) - 7); // 7 seconds ago, leakRate = 1.5 per second

        $this->rateLimiter->hit($key, leakRate: 1.5, decaySeconds: 60);

        // 7 seconds have passed, with leak rate of 1.5 per second, 10.5 attempts should have leaked
        // Since we use integer casting with "floor" rounding only 10 attempts should have leaked leaving 15-10 = 5.
        // But we hit it once now.
        // So the final result should be 5 + 1 (current attempt) = 6.
        $this->assertEquals(6, $this->cache->get($cacheKey));
    }

    public function testZeroLeakRate(): void
    {
        $key = 'test';
        $cacheKey = $this->rateLimiter->getCleanKey($key);

        $this->rateLimiter->hit($key, leakRate: 0, decaySeconds: 60);
        $this->rateLimiter->hit($key, leakRate: 0, decaySeconds: 60);

        $this->assertEquals(2, $this->cache->get($cacheKey));
    }
    // endregion
}
