<?php

app_require('classes/shelly.php');

use classes\shelly;

class GlobalShellyRateLimitClock
{
    public float $now = 0.0;
}

class GlobalShellyRateLimitRedisClientFake
{
    public function __construct(private readonly GlobalShellyRateLimitRedisFake $owner) {}

    public function set(string $key, string $value, mixed ...$args): bool|string
    {
        return $this->owner->clientSet($key, $value, $args);
    }

    public function pttl(string $key): int
    {
        return $this->owner->clientPttl($key);
    }
}

class GlobalShellyRateLimitRedisFake
{
    /** @var array<string,string> */
    private array $store = [];
    /** @var array<string,float> */
    private array $expiresAt = [];
    private GlobalShellyRateLimitRedisClientFake $client;

    public function __construct(private readonly GlobalShellyRateLimitClock $clock)
    {
        $this->client = new GlobalShellyRateLimitRedisClientFake($this);
    }

    public function get_client(): GlobalShellyRateLimitRedisClientFake
    {
        return $this->client;
    }

    public function clientSet(string $key, string $value, array $args): bool|string
    {
        $this->purgeExpired($key);

        $useNx = false;
        $ttlMs = null;
        $count = count($args);
        for ($i = 0; $i < $count; $i++) {
            $token = strtoupper((string)$args[$i]);
            if ($token === 'NX') {
                $useNx = true;
                continue;
            }
            if ($token === 'PX' && isset($args[$i + 1])) {
                $ttlMs = max(1, (int)$args[$i + 1]);
                $i++;
            }
        }

        if ($useNx && array_key_exists($key, $this->store)) {
            return false;
        }

        $this->store[$key] = $value;
        if ($ttlMs === null) {
            unset($this->expiresAt[$key]);
        } else {
            $this->expiresAt[$key] = $this->clock->now + ($ttlMs / 1000);
        }

        return 'OK';
    }

    public function clientPttl(string $key): int
    {
        $this->purgeExpired($key);
        if (!array_key_exists($key, $this->store)) {
            return -2;
        }
        if (!isset($this->expiresAt[$key])) {
            return -1;
        }

        $remainingMs = (int)ceil(($this->expiresAt[$key] - $this->clock->now) * 1000);
        return $remainingMs > 0 ? $remainingMs : 0;
    }

    private function purgeExpired(string $key): void
    {
        if (!isset($this->expiresAt[$key])) {
            return;
        }
        if ($this->clock->now < $this->expiresAt[$key]) {
            return;
        }
        unset($this->expiresAt[$key], $this->store[$key]);
    }
}

class GlobalShellyRateLimitHarness extends shelly
{
    /** @var array<int,int> */
    public array $sleepCalls = [];

    public function __construct(
        private readonly GlobalShellyRateLimitClock $clock,
        private readonly ?GlobalShellyRateLimitRedisFake $redis = null
    ) {
    }

    public function waitForShellyRateLimitWindowForTest(): void
    {
        $invoke = \Closure::bind(function (): void {
            $this->waitForShellyRateLimitWindow();
        }, $this, shelly::class);

        $invoke();
    }

    protected function redisFacade(): mixed
    {
        return $this->redis;
    }

    protected function nowTimestamp(): float
    {
        return $this->clock->now;
    }

    protected function sleepMicroseconds(int $microseconds): void
    {
        if ($microseconds <= 0) {
            return;
        }
        $this->sleepCalls[] = $microseconds;
        $this->clock->now += ($microseconds / 1000000);
    }
}

it('enforces the 2 second Shelly gate across separate request contexts', function (): void {
    $clock = new GlobalShellyRateLimitClock();
    $redis = new GlobalShellyRateLimitRedisFake($clock);

    $requestA = new GlobalShellyRateLimitHarness($clock, $redis);
    $requestB = new GlobalShellyRateLimitHarness($clock, $redis);

    $requestA->waitForShellyRateLimitWindowForTest();
    $timeBeforeRequestB = $clock->now;
    $requestB->waitForShellyRateLimitWindowForTest();

    expect(array_sum($requestA->sleepCalls))->toBe(0);
    expect(array_sum($requestB->sleepCalls))->toBeGreaterThanOrEqual(2000000);
    expect($clock->now - $timeBeforeRequestB)->toBeGreaterThanOrEqual(2.0);
});

it('does not delay when the Shelly gate is already expired', function (): void {
    $clock = new GlobalShellyRateLimitClock();
    $redis = new GlobalShellyRateLimitRedisFake($clock);

    $firstRequest = new GlobalShellyRateLimitHarness($clock, $redis);
    $firstRequest->waitForShellyRateLimitWindowForTest();

    $clock->now += 2.1;

    $secondRequest = new GlobalShellyRateLimitHarness($clock, $redis);
    $secondRequest->waitForShellyRateLimitWindowForTest();

    expect(array_sum($secondRequest->sleepCalls))->toBe(0);
});
