<?php

app_require('routes/moduleWeatherAPIRoute.php');

use routes\moduleWeatherAPIRoute;

class DepartmentWeatherRedisClientFake
{
    /** @var array<string,array<string,float>> */
    public array $zsets = [];

    public function reset(): void
    {
        $this->zsets = [];
    }

    public function zadd(string $key, int|float|string $score, string $member): void
    {
        if (!isset($this->zsets[$key])) {
            $this->zsets[$key] = [];
        }
        $this->zsets[$key][$member] = (float)$score;
    }

    public function zrevrange(string $key, int $start, int $stop): array
    {
        $members = $this->zsets[$key] ?? [];
        if ($members === []) {
            return [];
        }

        arsort($members, SORT_NUMERIC);
        $member_ids = array_keys($members);
        if ($stop < 0) {
            $stop = count($member_ids) + $stop;
        }
        $length = max(0, $stop - $start + 1);
        if ($length === 0) {
            return [];
        }

        return array_values(array_slice($member_ids, $start, $length));
    }

    public function zremrangebyscore(string $key, int|float|string $min, int|float|string $max): int
    {
        $members = $this->zsets[$key] ?? [];
        if ($members === []) {
            return 0;
        }

        $min_score = $this->toScoreBoundary($min, true);
        $max_score = $this->toScoreBoundary($max, false);
        $removed = 0;

        foreach ($members as $member => $score) {
            if ($score >= $min_score && $score <= $max_score) {
                unset($members[$member]);
                $removed++;
            }
        }

        if ($members === []) {
            unset($this->zsets[$key]);
        } else {
            $this->zsets[$key] = $members;
        }

        return $removed;
    }

    public function zrem(string $key, string $member): int
    {
        if (!isset($this->zsets[$key][$member])) {
            return 0;
        }

        unset($this->zsets[$key][$member]);
        if ($this->zsets[$key] === []) {
            unset($this->zsets[$key]);
        }

        return 1;
    }

    private function toScoreBoundary(int|float|string $value, bool $is_min): float
    {
        if (is_string($value)) {
            $trimmed = strtolower(trim($value));
            if ($trimmed === '-inf') {
                return -INF;
            }
            if ($trimmed === '+inf' || $trimmed === 'inf') {
                return INF;
            }
        }

        $numeric = (float)$value;
        if (!is_finite($numeric)) {
            return $is_min ? -INF : INF;
        }

        return $numeric;
    }
}

class DepartmentWeatherRedisFake
{
    /** @var array<string,string> */
    public array $store = [];
    /** @var array<int,array{key:string,ttl:int,value:string}> */
    public array $setExCalls = [];
    /** @var array<int,array{key:string,value:string,ttl:int}> */
    public array $lockCalls = [];
    public bool $lockResult = true;

    private DepartmentWeatherRedisClientFake $client;

    public function __construct()
    {
        $this->client = new DepartmentWeatherRedisClientFake();
    }

    public function reset(): void
    {
        $this->store = [];
        $this->setExCalls = [];
        $this->lockCalls = [];
        $this->lockResult = true;
        $this->client->reset();
    }

    public function get(string $key): ?string
    {
        return $this->store[$key] ?? null;
    }

    public function setEx(string $key, string $value, int $ttl): self
    {
        $this->store[$key] = $value;
        $this->setExCalls[] = [
            'key' => $key,
            'ttl' => $ttl,
            'value' => $value,
        ];

        return $this;
    }

    public function set_if_absent_with_expiration(string $key, string $value, int $ttl): bool
    {
        $this->lockCalls[] = [
            'key' => $key,
            'value' => $value,
            'ttl' => $ttl,
        ];

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

        $this->store[$key] = $value;

        return true;
    }

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

function department_weather_runtime_redis(): DepartmentWeatherRedisFake
{
    if (!defined('redis')) {
        define('redis', new DepartmentWeatherRedisFake());
    }

    /** @var DepartmentWeatherRedisFake $redis */
    $redis = redis;

    return $redis;
}

function department_weather_runtime_invoke_private(moduleWeatherAPIRoute $route, string $method, array $args = []): mixed
{
    $reflection = new ReflectionClass($route);
    $target = $reflection->getMethod($method);

    return $target->invokeArgs($route, $args);
}

beforeEach(function (): void {
    $_SERVER['REQUEST_URI'] = '/departments/weather';
    $this->oldTtl = getenv('DEPARTMENTS_WEATHER_CACHE_TTL');
    $this->oldStaleTtl = getenv('DEPARTMENTS_WEATHER_STALE_TTL');
    $this->oldHotTtl = getenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL');
    department_weather_runtime_redis()->reset();
});

afterEach(function (): void {
    if ($this->oldTtl === false) {
        putenv('DEPARTMENTS_WEATHER_CACHE_TTL');
    } else {
        putenv('DEPARTMENTS_WEATHER_CACHE_TTL=' . $this->oldTtl);
    }

    if ($this->oldStaleTtl === false) {
        putenv('DEPARTMENTS_WEATHER_STALE_TTL');
    } else {
        putenv('DEPARTMENTS_WEATHER_STALE_TTL=' . $this->oldStaleTtl);
    }

    if ($this->oldHotTtl === false) {
        putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL');
    } else {
        putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=' . $this->oldHotTtl);
    }
});

it('builds department weather preload targets in cli without a request uri', function (): void {
    unset($_SERVER['REQUEST_URI']);

    $targets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets(5, 900);

    expect($targets)->toBe([]);
});

it('serves fresh cached payloads without invoking the resolver', function (): void {
    $route = new moduleWeatherAPIRoute();
    putenv('DEPARTMENTS_WEATHER_CACHE_TTL=60');
    putenv('DEPARTMENTS_WEATHER_STALE_TTL=300');
    putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900');

    $range = [
        'start' => new DateTime('2026-03-24 00:00:00'),
        'endExclusive' => new DateTime('2026-03-25 00:00:00'),
    ];
    $cacheKey = department_weather_runtime_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]);
    department_weather_runtime_redis()->store[$cacheKey] = (string)json_encode([
        'generated_at' => time() - 5,
        'timeline' => [['source' => 'cache']],
    ]);

    $calls = 0;
    $result = department_weather_runtime_invoke_private($route, 'withCachedDepartmentWeatherTimeline', [
        [1, 3, 5],
        $range,
        static function () use (&$calls): array {
            $calls++;
            return [['source' => 'resolver']];
        },
    ]);

    expect($result)->toBe([['source' => 'cache']]);
    expect($calls)->toBe(0);
});

it('serves stale cached payloads and enqueues a refresh signal', function (): void {
    $route = new moduleWeatherAPIRoute();
    putenv('DEPARTMENTS_WEATHER_CACHE_TTL=60');
    putenv('DEPARTMENTS_WEATHER_STALE_TTL=300');
    putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900');

    $range = [
        'start' => new DateTime('2026-03-24 00:00:00'),
        'endExclusive' => new DateTime('2026-03-25 00:00:00'),
    ];
    $cacheKey = department_weather_runtime_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]);
    department_weather_runtime_redis()->store[$cacheKey] = (string)json_encode([
        'generated_at' => time() - 120,
        'timeline' => [['source' => 'stale-cache']],
    ]);

    $calls = 0;
    $result = department_weather_runtime_invoke_private($route, 'withCachedDepartmentWeatherTimeline', [
        [1, 3, 5],
        $range,
        static function () use (&$calls): array {
            $calls++;
            return [['source' => 'resolver']];
        },
    ]);

    $targets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets(5, 900);

    expect($result)->toBe([['source' => 'stale-cache']]);
    expect($calls)->toBe(0);
    expect(department_weather_runtime_redis()->lockCalls)->toHaveCount(1);
    expect($targets)->not->toBeEmpty();
});

it('recomputes and rewrites cache payloads when stale window is exceeded', function (): void {
    $route = new moduleWeatherAPIRoute();
    putenv('DEPARTMENTS_WEATHER_CACHE_TTL=60');
    putenv('DEPARTMENTS_WEATHER_STALE_TTL=300');
    putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900');

    $range = [
        'start' => new DateTime('2026-03-24 00:00:00'),
        'endExclusive' => new DateTime('2026-03-25 00:00:00'),
    ];
    $cacheKey = department_weather_runtime_invoke_private($route, 'getDepartmentWeatherCacheKey', [[1, 3, 5], $range]);
    department_weather_runtime_redis()->store[$cacheKey] = (string)json_encode([
        'generated_at' => time() - 400,
        'timeline' => [['source' => 'expired-cache']],
    ]);

    $calls = 0;
    $result = department_weather_runtime_invoke_private($route, 'withCachedDepartmentWeatherTimeline', [
        [1, 3, 5],
        $range,
        static function () use (&$calls): array {
            $calls++;
            return [['source' => 'resolver']];
        },
    ]);

    $cacheWriteFound = false;
    foreach (department_weather_runtime_redis()->setExCalls as $call) {
        if ($call['key'] === $cacheKey && $call['ttl'] === 300) {
            $cacheWriteFound = true;
            break;
        }
    }

    expect($result)->toBe([['source' => 'resolver']]);
    expect($calls)->toBe(1);
    expect($cacheWriteFound)->toBeTrue();
});

it('records hot keys order-insensitively and applies preload target caps', function (): void {
    $route = new moduleWeatherAPIRoute();
    putenv('DEPARTMENTS_WEATHER_PRELOAD_HOT_TTL=900');

    $range = [
        'start' => new DateTime('2026-03-24 00:00:00'),
        'endExclusive' => new DateTime('2026-03-25 00:00:00'),
    ];

    department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[5, 1, 3], $range]);
    department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[1, 3, 5], $range]);
    department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[8], $range]);
    department_weather_runtime_invoke_private($route, 'recordDepartmentWeatherHotRequest', [[9], $range]);

    $hotSet = department_weather_runtime_redis()->get_client()->zsets['departments_weather:hot_activity:v1'] ?? [];
    $targets = moduleWeatherAPIRoute::getDepartmentWeatherHotPreloadTargets(2, 900);

    expect($hotSet)->toHaveCount(3);
    expect($targets)->toHaveCount(2);
});
