<?php

declare(strict_types=1);

namespace ShieldX\Security;

defined('ABSPATH') || exit;

use ShieldX\Storage\FileStorage;
use ShieldX\Support\RequestPath;

// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.WP.AlternativeFunctions.file_system_operations_fwrite,WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Request-time rate limiter uses APCu when available and locked local state files as a shared-hosting fallback.

final class RateLimiter
{
    private const STATE_DIRECTORY = 'cache/rate-limit-state';
    private const MAX_STATE_BYTES = 1048576;
    private const MAX_TRACKED_REQUEST_CLIENTS_PER_SHARD = 256;
    private const MAX_TRACKED_LOGIN_IDENTITIES_PER_SHARD = 256;
    private const MAX_REQUEST_EVENTS_PER_CLIENT = 64;
    private const PRUNE_INTERVAL_SECONDS = 300;
    private const APCU_TTL_SECONDS = 3600;
    private const APCU_MUTEX_TTL_SECONDS = 2;
    private const APCU_MUTEX_RETRIES = 5;
    private const APCU_MUTEX_WAIT_MICROSECONDS = 2000;
    private const FALLBACK_STATE_TTL_SECONDS = 90000;
    private const FALLBACK_LOCK_TTL_SECONDS = 5;
    private const FALLBACK_LOCK_RETRIES = 5;
    private const FALLBACK_LOCK_WAIT_MICROSECONDS = 10000;
    private const STORAGE_FAILURE_BLOCK_PROFILES = ['login_post', 'xmlrpc_post', 'rest_user_enumeration'];

    /** @var FileStorage */
    private $storage;

    /** @var RateLimitSettings */
    private $settings;

    /** @var AuditLogger|null */
    private $logger;

    public function __construct(FileStorage $storage, RateLimitSettings $settings, ?AuditLogger $logger = null)
    {
        $this->storage = $storage;
        $this->settings = $settings;
        $this->logger = $logger;
    }

    /**
     * @param array<string,mixed> $request
     * @return array<string,mixed>
     */
    public function assessRequest(array $request): array
    {
        $profile = self::endpointProfileForRequest($request);
        $profileSettings = $profile !== '' ? $this->settings->endpointProfile($profile) : [];
        $endpointEnabled = $profile !== '' && !empty($profileSettings['enabled']);
        $bucket = $endpointEnabled ? $profile : 'global';
        $configuration = $endpointEnabled ? $profileSettings : [
            'enabled' => $this->settings->requestRateEnabled(),
            'mode' => $this->settings->requestRateMode(),
            'limit' => $this->settings->requestLimit(),
            'window_seconds' => $this->settings->requestWindowSeconds(),
            'burst_limit' => $this->settings->burstLimit(),
            'burst_window_seconds' => $this->settings->burstWindowSeconds(),
        ];

        $result = $this->assessBucket($request, $bucket, $configuration);

        return $this->applyStorageFailurePolicy($result, $profile, $endpointEnabled);
    }

    /**
     * Fail closed only for enabled, high-confidence authentication and
     * enumeration profiles. All other traffic retains the existing
     * availability-safe degraded-storage behavior.
     *
     * @param array<string,mixed> $result
     * @return array<string,mixed>
     */
    private function applyStorageFailurePolicy(array $result, string $profile, bool $endpointEnabled): array
    {
        if (
            !$endpointEnabled
            || !in_array($profile, self::STORAGE_FAILURE_BLOCK_PROFILES, true)
            || !array_key_exists('storage_ready', $result)
            || $result['storage_ready'] !== false
        ) {
            return $result;
        }

        $result['limited'] = true;
        $result['action'] = 'block';
        $result['reason'] = 'storage_unavailable';
        $result['retry_after'] = 0;
        $result['log_event'] = true;

        return $result;
    }

    /**
     * @param array<string,mixed> $request
     * @param array<string,mixed> $configuration
     * @return array<string,mixed>
     */
    private function assessBucket(array $request, string $profile, array $configuration): array
    {
        $enabled = !empty($configuration['enabled']);
        $mode = (string) ($configuration['mode'] ?? 'log_only');
        $limit = max(2, (int) ($configuration['limit'] ?? 120));
        $windowSeconds = max(10, (int) ($configuration['window_seconds'] ?? 60));
        $burstLimit = max(2, (int) ($configuration['burst_limit'] ?? 40));
        $burstWindowSeconds = max(5, (int) ($configuration['burst_window_seconds'] ?? 10));
        $base = [
            'enabled' => $enabled,
            'mode' => $mode,
            'profile' => $profile,
            'limited' => false,
            'action' => 'allow',
            'reason' => '',
            'retry_after' => 0,
            'count' => 0,
            'limit' => $limit,
            'window_seconds' => $windowSeconds,
            'burst_count' => 0,
            'burst_limit' => $burstLimit,
            'burst_window_seconds' => $burstWindowSeconds,
            'skipped' => false,
        ];

        if (!$enabled) {
            return $base;
        }

        if (self::isSkippedRequest($request)) {
            $base['skipped'] = true;

            return $base;
        }

        $now = time();
        $ip = self::normalizeIp((string) ($request['ip'] ?? 'unknown'));
        $key = $profile === 'global'
            ? hash('sha256', $ip)
            : hash('sha256', $ip . '|' . $profile);

        return $this->withState(function (array &$state, bool $writable) use ($base, $ip, $key, $now, $mode, $limit, $windowSeconds, $burstLimit, $burstWindowSeconds, $profile): array {
            $this->maybePruneState($state, $now);
            $this->pruneRequestClient($state, $key, $now);

            $client = isset($state['requests'][$key]) && is_array($state['requests'][$key])
                ? $state['requests'][$key]
                : [];

            $currentEventState = $this->requestEventState($client, $now, false);
            $currentEvents = $currentEventState['events'];
            $currentWindowEvents = self::pruneTimestamps($currentEvents, $now - $windowSeconds);
            $currentBurstEvents = self::pruneTimestamps($currentEvents, $now - $burstWindowSeconds);
            $alreadyWindowLimited = count($currentWindowEvents) + (int) $currentEventState['request_overflow_count'] >= $limit;
            $alreadyBurstLimited = count($currentBurstEvents) + (int) $currentEventState['burst_overflow_count'] >= $burstLimit;
            $eventState = ($alreadyWindowLimited || $alreadyBurstLimited)
                ? $currentEventState
                : $this->requestEventState($client, $now, true);
            $events = $eventState['events'];

            $windowEvents = self::pruneTimestamps($events, $now - $windowSeconds);
            $burstEvents = self::pruneTimestamps($events, $now - $burstWindowSeconds);
            $count = count($windowEvents) + (int) $eventState['request_overflow_count'] + ($alreadyWindowLimited ? 1 : 0);
            $burstCount = count($burstEvents) + (int) $eventState['burst_overflow_count'] + ($alreadyBurstLimited ? 1 : 0);
            $limited = $alreadyWindowLimited || $alreadyBurstLimited || $count > $limit || $burstCount > $burstLimit;
            $reason = '';
            $retryAfter = 0;

            if ($burstCount > $burstLimit) {
                $reason = 'burst';
                $retryAfter = self::retryAfterWithOverflow(
                    $burstEvents,
                    (int) $eventState['burst_overflow_count'],
                    (int) $eventState['burst_overflow_until'],
                    $burstWindowSeconds,
                    $now
                );
            } elseif ($count > $limit) {
                $reason = 'sustained';
                $retryAfter = self::retryAfterWithOverflow(
                    $windowEvents,
                    (int) $eventState['request_overflow_count'],
                    (int) $eventState['request_overflow_until'],
                    $windowSeconds,
                    $now
                );
            }

            $lastLimitedAt = (int) ($client['last_limited_at'] ?? 0);
            $lastReason = (string) ($client['last_reason'] ?? '');
            $logEvent = $limited && ($lastLimitedAt <= $now - 60 || $lastReason !== $reason);

            $updatedClient = array_merge($client, $eventState, [
                'ip' => $ip,
                'profile' => $profile,
                'last_seen' => $now,
                'last_limited_at' => $limited ? $now : (int) ($client['last_limited_at'] ?? 0),
                'last_reason' => $limited ? $reason : (string) ($client['last_reason'] ?? ''),
            ]);

            if (($alreadyWindowLimited || $alreadyBurstLimited) && !$logEvent) {
                $updatedClient['last_seen'] = (int) ($client['last_seen'] ?? $now);
                $updatedClient['last_limited_at'] = (int) ($client['last_limited_at'] ?? 0);
                $updatedClient['last_reason'] = (string) ($client['last_reason'] ?? '');
            }

            if ($updatedClient !== $client) {
                $state['requests'][$key] = $updatedClient;
            }

            return array_merge($base, [
                'storage_ready' => $writable,
                'limited' => $limited,
                'action' => $limited && $mode === 'enforce' ? 'block' : ($limited ? 'log' : 'allow'),
                'reason' => $reason,
                'retry_after' => $retryAfter,
                'count' => $count,
                'burst_count' => $burstCount,
                'log_event' => $logEvent,
            ]);
        }, 'requests', $key);
    }

    /**
     * @return array<string,mixed>
     */
    public function loginLockoutStatus(string $ip, string $username): array
    {
        $base = [
            'enabled' => $this->settings->loginProtectionEnabled(),
            'locked' => false,
            'retry_after' => 0,
            'failures' => 0,
            'limit' => $this->settings->loginFailureLimit(),
            'window_seconds' => $this->settings->loginWindowSeconds(),
            'lockout_seconds' => $this->settings->loginLockoutSeconds(),
        ];

        if (!$this->settings->loginProtectionEnabled()) {
            return $base;
        }

        $now = time();
        $status = $this->loginStatusForIdentities($this->loginIdentities($ip, $username), $now);

        return array_merge($base, $status);
    }

    /**
     * @return array<string,mixed>
     */
    public function recordLoginFailure(string $ip, string $username): array
    {
        $base = [
            'enabled' => $this->settings->loginProtectionEnabled(),
            'locked' => false,
            'new_lockout' => false,
            'retry_after' => 0,
            'failures' => 0,
            'limit' => $this->settings->loginFailureLimit(),
            'window_seconds' => $this->settings->loginWindowSeconds(),
            'lockout_seconds' => $this->settings->loginLockoutSeconds(),
        ];

        if (!$this->settings->loginProtectionEnabled()) {
            return $base;
        }

        $now = time();
        $result = $this->recordLoginFailureForIdentities($this->loginIdentities($ip, $username), $now);

        return array_merge($base, $result);
    }

    public function clearLoginFailures(string $ip, string $username): void
    {
        if (!$this->settings->loginProtectionEnabled()) {
            return;
        }

        foreach ($this->loginIdentities($ip, $username) as $identity) {
            $this->withState(function (array &$state) use ($identity): array {
                unset($state['logins'][$identity['key']]);

                return [];
            }, 'logins', $identity['key']);
        }
    }

    /**
     * @return array{ip:string,request_state_removed:int,login_lockouts_removed:int}
     */
    public function clearIp(string $ip): array
    {
        $ip = self::normalizeIp($ip);

        if ($ip === 'unknown') {
            return [
                'ip' => '',
                'request_state_removed' => 0,
                'login_lockouts_removed' => 0,
            ];
        }

        $requestStateRemoved = 0;
        $requestKeys = [hash('sha256', $ip)];
        foreach (RateLimitSettings::ENDPOINT_PROFILES as $profile) {
            $requestKeys[] = hash('sha256', $ip . '|' . $profile);
        }

        foreach ($requestKeys as $key) {
            $requestStateRemoved += (int) $this->withState(function (array &$state) use ($key): int {
                $removed = isset($state['requests'][$key]) ? 1 : 0;
                unset($state['requests'][$key]);

                return $removed;
            }, 'requests', $key);
        }

        $loginLockoutsRemoved = 0;
        $ipIdentity = [
            'key' => hash('sha256', 'ip|' . $ip),
        ];
        $loginLockoutsRemoved += (int) $this->withState(function (array &$state) use ($ipIdentity): int {
            $removed = isset($state['logins'][$ipIdentity['key']]) ? 1 : 0;
            unset($state['logins'][$ipIdentity['key']]);

            return $removed;
        }, 'logins', $ipIdentity['key']);

        foreach ($this->stateFiles('logins') as $file) {
            $loginLockoutsRemoved += (int) $this->withStateFile($file, function (array &$state) use ($ip): int {
                $removed = 0;
                foreach ($state['logins'] as $identityKey => $identity) {
                    if (is_array($identity) && (string) ($identity['ip'] ?? '') === $ip) {
                        unset($state['logins'][$identityKey]);
                        $removed++;
                    }
                }

                return $removed;
            });
        }

        return [
            'ip' => $ip,
            'request_state_removed' => (int) $requestStateRemoved,
            'login_lockouts_removed' => $loginLockoutsRemoved,
        ];
    }

    /**
     * @return array<string,mixed>
     */
    public function summary(): array
    {
        $now = time();
        $apcuReady = $this->apcuAvailable();
        $fileReady = is_dir($this->stateDirectory()) && $this->directoryWritable($this->stateDirectory());
        $fallbackReady = function_exists('get_option') && function_exists('update_option') && function_exists('add_option') && function_exists('delete_option');
        $storageDriver = $apcuReady ? 'apcu' : ($fileReady ? 'file' : 'wordpress_options_fallback');
        $countersPrecise = !$apcuReady && $fileReady;
        $requestTrackedClients = 0;
        $recentLimitedClients = 0;
        $loginTrackedIdentities = 0;
        $lockedIdentities = 0;

        if ($countersPrecise) {
            foreach ($this->stateFiles('requests') as $file) {
                $state = $this->readStateFile($file);
                $requestTrackedClients += count($state['requests']);
                foreach ($state['requests'] as $client) {
                    if (is_array($client) && (int) ($client['last_limited_at'] ?? 0) >= $now - $this->settings->requestWindowSeconds()) {
                        $recentLimitedClients++;
                    }
                }
            }

            foreach ($this->stateFiles('logins') as $file) {
                $state = $this->readStateFile($file);
                foreach ($state['logins'] as $identity) {
                    if (!is_array($identity)) {
                        continue;
                    }
                    if ((int) ($identity['lockout_until'] ?? 0) > $now) {
                        $lockedIdentities++;
                    }
                    $loginTrackedIdentities++;
                }
            }
        }

        return [
            'state_directory' => $this->stateDirectory(),
            'storage_ready' => $apcuReady || $fileReady || $fallbackReady,
            'storage_driver' => $storageDriver,
            'counters_precise' => $countersPrecise,
            'settings' => $this->settings->all(),
            'request' => [
                'enabled' => $this->settings->requestRateEnabled(),
                'mode' => $this->settings->requestRateMode(),
                'limit' => $this->settings->requestLimit(),
                'window_seconds' => $this->settings->requestWindowSeconds(),
                'burst_limit' => $this->settings->burstLimit(),
                'burst_window_seconds' => $this->settings->burstWindowSeconds(),
                'tracked_clients' => $requestTrackedClients,
                'recent_limited_clients' => $recentLimitedClients,
            ],
            'login' => [
                'enabled' => $this->settings->loginProtectionEnabled(),
                'failure_limit' => $this->settings->loginFailureLimit(),
                'window_seconds' => $this->settings->loginWindowSeconds(),
                'lockout_seconds' => $this->settings->loginLockoutSeconds(),
                'tracked_identities' => $loginTrackedIdentities,
                'locked_identities' => $lockedIdentities,
            ],
        ];
    }

    /**
     * @param array<string,mixed> $request
     */
    public static function endpointProfileForRequest(array $request): string
    {
        $method = strtoupper((string) ($request['method'] ?? 'GET'));
        $uri = (string) ($request['uri'] ?? '');
        $path = strtolower(str_replace('\\', '/', RequestPath::decodeTwice(self::requestPath($request, $uri))));
        $query = isset($request['query_string']) && is_scalar($request['query_string'])
            ? (string) $request['query_string']
            : '';
        if ($query === '') {
            $query = self::queryString($uri);
        }
        $queryValues = [];
        parse_str($query, $queryValues);

        if ($method === 'POST' && preg_match('~/wp-login\.php$~', $path) === 1) {
            return 'login_post';
        }
        if ($method === 'POST' && preg_match('~/xmlrpc\.php$~', $path) === 1) {
            return 'xmlrpc_post';
        }
        $restRoute = isset($queryValues['rest_route']) && is_scalar($queryValues['rest_route'])
            ? strtolower((string) $queryValues['rest_route'])
            : '';
        if (preg_match('~/wp-json/wp/v2/users(?:/|$)~', $path) === 1 || preg_match('~^/?wp/v2/users(?:/|$)~', $restRoute) === 1) {
            return 'rest_user_enumeration';
        }
        if ($method === 'POST' && preg_match('~/wp-comments-post\.php$~', $path) === 1) {
            return 'comment_submission';
        }
        if ($method === 'GET' && isset($queryValues['s']) && is_scalar($queryValues['s']) && trim((string) $queryValues['s']) !== '') {
            return 'search';
        }

        return '';
    }

    /**
     * @param array<string,mixed> $request
     */
    public static function isSkippedRequest(array $request): bool
    {
        $method = strtoupper((string) ($request['method'] ?? ''));
        if (in_array($method, ['OPTIONS', 'HEAD'], true)) {
            return true;
        }

        $uri = (string) ($request['uri'] ?? '');
        $query = isset($request['query_string']) && is_scalar($request['query_string'])
            ? (string) $request['query_string']
            : '';
        if ($query === '') {
            $query = self::queryString($uri);
        }

        $path = self::requestPath($request, $uri);
        $path = $path !== '' ? strtolower(RequestPath::decodeTwice($path)) : strtolower(RequestPath::decodeTwice($uri));
        $path = str_replace('\\', '/', $path);

        if (in_array($path, [RequestPath::adminEndpointPath('admin-ajax.php'), RequestPath::adminEndpointPath('admin-post.php')], true)) {
            $action = self::requestActionName($request, $query);
            if ($action !== '' && strpos($action, 'shieldx_') === 0 && self::currentRequestCanManageShieldX()) {
                return true;
            }
        }

        $skipExact = [
            '/favicon.ico',
            '/robots.txt',
            '/wp-cron.php',
            '/wp-admin/load-styles.php',
            '/wp-admin/load-scripts.php',
        ];

        if (in_array($path, $skipExact, true)) {
            return true;
        }

        return preg_match('/\.(?:css|js|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|eot)$/i', $path) === 1;
    }

    private static function queryString(string $uri): string
    {
        $withoutFragment = explode('#', $uri, 2)[0];

        return explode('?', $withoutFragment, 2)[1] ?? '';
    }

    /**
     * @param array<string,mixed> $request
     */
    private static function requestPath(array $request, string $uri): string
    {
        if (isset($request['path']) && is_scalar($request['path'])) {
            $provided = (string) $request['path'];
            if ($provided !== '' && strpos($provided, '?') === false && strpos($provided, '#') === false) {
                return $provided;
            }
        }

        $path = explode('?', explode('#', $uri, 2)[0], 2)[0];
        if (preg_match('~^https?://[^/]+(?P<path>/.*)?$~i', $path, $matches)) {
            $path = isset($matches['path']) && $matches['path'] !== '' ? $matches['path'] : '/';
        }

        return $path;
    }

    /**
     * @param array<string,mixed> $request
     */
    private static function requestActionName(array $request, string $query): string
    {
        $parsed = [];
        parse_str($query, $parsed);
        $action = isset($parsed['action']) && is_scalar($parsed['action']) ? (string) $parsed['action'] : '';

        if ($action === '' && isset($request['body']) && is_scalar($request['body'])) {
            $body = (string) $request['body'];
            $posted = [];
            parse_str($body, $posted);
            $action = isset($posted['action']) && is_scalar($posted['action']) ? (string) $posted['action'] : '';
            if ($action === '') {
                $action = self::actionFromInspectionBody($body);
            }
        }

        return strtolower((string) preg_replace('/[^a-z0-9_:-]+/i', '', trim($action)));
    }

    private static function actionFromInspectionBody(string $body): string
    {
        foreach (preg_split('/\r\n|\n|\r|&/', $body) ?: [] as $line) {
            $line = trim((string) $line);
            if (stripos($line, 'action=') !== 0) {
                continue;
            }

            return substr($line, 7);
        }

        return '';
    }

    private static function currentRequestCanManageShieldX(): bool
    {
        $capability = defined('SHIELDX_MANAGE_CAPABILITY') ? (string) SHIELDX_MANAGE_CAPABILITY : 'manage_options';

        return function_exists('current_user_can') && current_user_can($capability);
    }

    public static function normalizeIp(string $ip): string
    {
        $ip = trim($ip);
        $packed = @inet_pton($ip);
        if ($packed === false) {
            return 'unknown';
        }

        if (strlen($packed) === 16) {
            $packed = self::bucketIpv6Address($packed, self::ipv6PrefixBits());
        }

        $normalized = @inet_ntop($packed);

        return is_string($normalized) && filter_var($normalized, FILTER_VALIDATE_IP) ? strtolower($normalized) : 'unknown';
    }

    private static function ipv6PrefixBits(): int
    {
        $bits = 64;
        if (function_exists('apply_filters')) {
            try {
                $filtered = apply_filters('shieldx_rate_limiter_ipv6_prefix_bits', $bits);
                $bits = is_numeric($filtered) ? (int) $filtered : $bits;
            } catch (\Throwable $error) {
                \ShieldX\Support\ErrorLog::write('SHIELD-X rate limiter IPv6 prefix filter failed: ' . $error->getMessage());
            }
        }

        return max(32, min(128, $bits));
    }

    private static function bucketIpv6Address(string $packed, int $prefixBits): string
    {
        $bytes = array_values(unpack('C*', $packed) ?: []);
        if (count($bytes) !== 16) {
            return $packed;
        }

        for ($index = 0; $index < 16; $index++) {
            $remaining = $prefixBits - ($index * 8);
            if ($remaining >= 8) {
                continue;
            }

            if ($remaining <= 0) {
                $bytes[$index] = 0;
                continue;
            }

            $mask = (0xFF << (8 - $remaining)) & 0xFF;
            $bytes[$index] = $bytes[$index] & $mask;
        }

        return pack('C*', ...$bytes);
    }

    /**
     * @param callable $callback
     * @return mixed
     */
    private function withState(callable $callback, string $scope = 'global', string $shardKey = 'global')
    {
        if ($this->apcuAvailable()) {
            return $this->withApcuState($scope, $shardKey, $callback);
        }

        if (!$this->ensureStateDirectory()) {
            return $this->withFallbackState($scope, $shardKey, $callback, 'private_storage_unavailable');
        }

        return $this->withStateFile($this->stateFileFor($scope, $shardKey), $callback, $scope, $shardKey);
    }

    /**
     * @param callable $callback
     * @return mixed
     */
    private function withApcuState(string $scope, string $shardKey, callable $callback)
    {
        $key = $this->apcuStateKey($scope, $shardKey);
        $lockKey = $key . '_lock';
        $locked = false;

        for ($attempt = 0; $attempt < self::APCU_MUTEX_RETRIES; $attempt++) {
            if (apcu_add($lockKey, 1, self::APCU_MUTEX_TTL_SECONDS)) {
                $locked = true;
                break;
            }

            usleep(self::APCU_MUTEX_WAIT_MICROSECONDS);
        }

        if (!$locked) {
            $this->logDegradedState($scope, 'apcu_mutex_unavailable');
            $state = self::emptyState();

            return $callback($state, false);
        }

        $success = false;
        try {
            $state = apcu_fetch($key, $success);
            $state = $success && is_array($state) ? $state : self::emptyState();
            $before = self::stateFingerprint($state);

            $result = $callback($state, true);
            $this->capState($state, $scope, $shardKey);
            if (self::stateFingerprint($state) !== $before && !apcu_store($key, $state, $this->apcuTtlSeconds()) && is_array($result)) {
                $result['storage_ready'] = false;
            }

            return $result;
        } finally {
            apcu_delete($lockKey);
        }
    }

    private function apcuAvailable(): bool
    {
        if (defined('PHP_SAPI') && PHP_SAPI === 'cli') {
            return false;
        }

        if (!function_exists('apcu_add') || !function_exists('apcu_delete') || !function_exists('apcu_fetch') || !function_exists('apcu_store')) {
            return false;
        }

        if (function_exists('apcu_enabled')) {
            return (bool) apcu_enabled();
        }

        $enabled = strtolower((string) ini_get('apc.enabled'));

        return !in_array($enabled, ['', '0', 'off', 'false', 'no'], true);
    }

    private function apcuTtlSeconds(): int
    {
        return max(
            self::APCU_TTL_SECONDS,
            $this->settings->requestWindowSeconds(),
            $this->settings->burstWindowSeconds(),
            $this->settings->loginWindowSeconds(),
            $this->settings->loginLockoutSeconds()
        ) + 60;
    }

    private function apcuStateKey(string $scope, string $shardKey): string
    {
        $scope = in_array($scope, ['requests', 'logins'], true) ? $scope : 'global';
        $hash = preg_match('/^[a-f0-9]{64}$/', $shardKey) === 1 ? $shardKey : hash('sha256', $shardKey);

        return 'shieldx_rate_limit_' . $scope . '_' . substr($hash, 0, 16);
    }

    /**
     * @param callable $callback
     * @return mixed
     */
    private function withStateFile(string $file, callable $callback, string $protectedScope = '', string $protectedKey = '')
    {
        $directory = dirname($file);
        $handle = $this->directoryWritable($directory) ? fopen($file, 'c+') : false;

        if (!$handle) {
            return $this->withFallbackState($protectedScope, $protectedKey, $callback, 'state_file_unavailable');
        }

        try {
            $locked = flock($handle, LOCK_EX);
            if (!$locked) {
                return $this->withFallbackState($protectedScope, $protectedKey, $callback, 'state_file_lock_unavailable');
            }

            rewind($handle);
            $contents = stream_get_contents($handle);
            $contents = is_string($contents) ? $contents : '';
            $state = strlen($contents) > self::MAX_STATE_BYTES ? self::emptyState() : self::stateFromJson($contents);
            $before = self::stateFingerprint($state);

            $result = $callback($state, $locked);
            $this->capState($state, $protectedScope, $protectedKey);
            if (self::stateFingerprint($state) === $before) {
                flock($handle, LOCK_UN);

                return $result;
            }

            rewind($handle);
            $truncated = ftruncate($handle, 0);
            $json = wp_json_encode($state);
            $payload = is_string($json) ? $json : '{}';
            $bytes = $truncated ? fwrite($handle, $payload) : false;
            if ($truncated && $bytes !== strlen($payload)) {
                rewind($handle);
                ftruncate($handle, 0);
                $bytes = fwrite($handle, $payload);
            }
            $written = $truncated && $bytes === strlen($payload);
            if ($written) {
                fflush($handle);
            } elseif (is_array($result)) {
                $result['storage_ready'] = false;
            }

            flock($handle, LOCK_UN);

            return $result;
        } finally {
            fclose($handle);
        }
    }

    /**
     * Preserve login and request state when the private filesystem is unavailable.
     * The option-backed lock avoids a transient read/write race across PHP workers.
     *
     * @param callable $callback
     * @return mixed
     */
    private function withFallbackState(string $scope, string $shardKey, callable $callback, string $reason)
    {
        $scope = in_array($scope, ['requests', 'logins'], true) ? $scope : 'global';
        $this->logDegradedState($scope, $reason);
        $shieldxStateKey = $this->fallbackStateKey($scope, $shardKey);
        $lockKey = $this->fallbackLockKey($scope, $shardKey);
        $lockToken = $this->acquireFallbackLock($lockKey);

        if ($lockToken === '') {
            $this->logDegradedState($scope, 'fallback_lock_unavailable');
            $state = $this->fallbackState($shieldxStateKey);

            return $callback($state, false);
        }

        try {
            $state = $this->fallbackState($shieldxStateKey);
            $before = self::stateFingerprint($state);
            $result = $callback($state, true);
            $this->capState($state, $scope, $shardKey);

            if (self::stateFingerprint($state) !== $before && (!function_exists('set_transient') || !set_transient($shieldxStateKey, $state, self::FALLBACK_STATE_TTL_SECONDS))) {
                if (is_array($result)) {
                    $result['storage_ready'] = false;
                }
            }

            return $result;
        } finally {
            $this->releaseFallbackLock($lockKey, $lockToken);
        }
    }

    private function logDegradedState(string $scope, string $reason): void
    {
        if ($this->logger instanceof AuditLogger) {
            $this->logger->warning('rate_limiter.storage_degraded', [
                'scope' => $scope,
                'reason' => $reason,
            ]);
        }
    }

    /**
     * @param array<string,mixed> $state
     */
    private static function stateFingerprint(array $state): string
    {
        return hash('sha256', serialize($state));
    }

    /**
     * @return array{version:int,requests:array<string,array<string,mixed>>,logins:array<string,array<string,mixed>>}
     */
    private function fallbackState(string $key): array
    {
        if (!function_exists('get_transient')) {
            return self::emptyState();
        }

        return self::stateFromValue(get_transient($key));
    }

    private function fallbackStateKey(string $scope, string $shardKey): string
    {
        unset($shardKey);

        return 'shieldx_rl_state_' . $scope;
    }

    private function fallbackLockKey(string $scope, string $shardKey): string
    {
        unset($shardKey);

        return 'shieldx_rl_lock_' . $scope;
    }

    private function acquireFallbackLock(string $key): string
    {
        if (!function_exists('add_option') || !function_exists('get_option') || !function_exists('delete_option')) {
            return '';
        }

        $token = hash('sha256', $key . '|' . microtime(true) . '|' . (string) wp_rand());

        for ($attempt = 0; $attempt < self::FALLBACK_LOCK_RETRIES; $attempt++) {
            $lock = [
                'token' => $token,
                'until' => time() + self::FALLBACK_LOCK_TTL_SECONDS,
            ];

            if (add_option($key, $lock, '', false)) {
                return $token;
            }

            $existing = get_option($key, []);
            $until = is_array($existing) && isset($existing['until']) && is_numeric($existing['until'])
                ? (int) $existing['until']
                : 0;
            if ($until > 0 && $until <= time()) {
                delete_option($key);
                continue;
            }

            usleep(self::FALLBACK_LOCK_WAIT_MICROSECONDS);
        }

        return '';
    }

    private function releaseFallbackLock(string $key, string $token): void
    {
        if ($token === '' || !function_exists('get_option') || !function_exists('delete_option')) {
            return;
        }

        $existing = get_option($key, []);
        if (is_array($existing) && isset($existing['token']) && is_string($existing['token']) && hash_equals($token, $existing['token'])) {
            delete_option($key);
        }
    }

    private function stateDirectory(): string
    {
        return $this->storage->baseDir() . '/' . self::STATE_DIRECTORY;
    }

    private function stateFileFor(string $scope, string $shardKey): string
    {
        $scope = in_array($scope, ['requests', 'logins'], true) ? $scope : 'global';
        $hash = preg_match('/^[a-f0-9]{64}$/', $shardKey) === 1 ? $shardKey : hash('sha256', $shardKey);

        return $this->stateDirectory() . '/' . $scope . '-' . substr($hash, 0, 2) . '.json';
    }

    private function ensureStateDirectory(): bool
    {
        $this->storage->ensure();
        $directory = $this->stateDirectory();
        if (!is_dir($directory) && !is_file($directory)) {
            if (function_exists('wp_mkdir_p')) {
                wp_mkdir_p($directory);
            }
        }

        return is_dir($directory) && $this->directoryWritable($directory);
    }

    /**
     * @return array<int,string>
     */
    private function stateFiles(?string $scope = null): array
    {
        $directory = $this->stateDirectory();
        if (!is_dir($directory)) {
            return [];
        }

        $scope = in_array($scope, ['requests', 'logins'], true) ? $scope : null;
        $pattern = $scope ? $directory . '/' . $scope . '-*.json' : $directory . '/*.json';
        $files = glob($pattern);
        if (!is_array($files)) {
            return [];
        }

        return array_values(array_filter($files, 'is_file'));
    }

    /**
     * @return array{version:int,requests:array<string,array<string,mixed>>,logins:array<string,array<string,mixed>>}
     */
    private function readStateFile(string $file): array
    {
        if (!is_file($file) || filesize($file) > self::MAX_STATE_BYTES) {
            return self::emptyState();
        }

        $contents = file_get_contents($file);

        return is_string($contents) ? self::stateFromJson($contents) : self::emptyState();
    }

    private function directoryWritable(string $directory): bool
    {
        if (!is_dir($directory)) {
            return false;
        }

        if (function_exists('wp_is_writable')) {
            return wp_is_writable($directory);
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Unit tests can run without WordPress; production uses wp_is_writable() above.
        return is_writable($directory);
    }

    /**
     * @return array{version:int,requests:array<string,array<string,mixed>>,logins:array<string,array<string,mixed>>}
     */
    private static function emptyState(): array
    {
        return [
            'version' => 1,
            'last_pruned_at' => 0,
            'requests' => [],
            'logins' => [],
        ];
    }

    /**
     * @return array{version:int,requests:array<string,array<string,mixed>>,logins:array<string,array<string,mixed>>}
     */
    private static function stateFromJson(string $contents): array
    {
        $decoded = \ShieldX\Support\Json::decodeArray($contents);

        return self::stateFromValue($decoded);
    }

    /**
     * @param mixed $state
     * @return array{version:int,requests:array<string,array<string,mixed>>,logins:array<string,array<string,mixed>>}
     */
    private static function stateFromValue($state): array
    {
        if (!is_array($state)) {
            return self::emptyState();
        }

        return [
            'version' => 1,
            'last_pruned_at' => isset($state['last_pruned_at']) && is_numeric($state['last_pruned_at']) ? (int) $state['last_pruned_at'] : 0,
            'requests' => isset($state['requests']) && is_array($state['requests']) ? $state['requests'] : [],
            'logins' => isset($state['logins']) && is_array($state['logins']) ? $state['logins'] : [],
        ];
    }

    /**
     * @param array{version:int,requests:array<string,array<string,mixed>>,logins:array<string,array<string,mixed>>} $state
     */
    private function pruneState(array &$state, int $now): void
    {
        $requestCutoff = $now - max($this->settings->requestWindowSeconds(), $this->settings->burstWindowSeconds());
        foreach ($state['requests'] as $key => $client) {
            if (!is_array($client)) {
                unset($state['requests'][$key]);
                continue;
            }

            $eventState = $this->requestEventState($client, $now, false);
            $events = $eventState['events'];
            $lastSeen = (int) ($client['last_seen'] ?? 0);
            $lastLimited = (int) ($client['last_limited_at'] ?? 0);

            if (
                empty($events)
                && (int) $eventState['request_overflow_count'] === 0
                && (int) $eventState['burst_overflow_count'] === 0
                && $lastSeen < $requestCutoff
                && $lastLimited < $requestCutoff
            ) {
                unset($state['requests'][$key]);
                continue;
            }

            $state['requests'][$key] = array_merge($client, $eventState);
        }

        $loginCutoff = $now - $this->settings->loginWindowSeconds();
        foreach ($state['logins'] as $key => $identity) {
            if (!is_array($identity)) {
                unset($state['logins'][$key]);
                continue;
            }

            $failures = self::pruneTimestamps(self::timestampsFrom($identity['failures'] ?? []), $loginCutoff);
            $lockoutUntil = (int) ($identity['lockout_until'] ?? 0);
            $lastSeen = (int) ($identity['last_seen'] ?? 0);

            if (empty($failures) && $lockoutUntil <= $now && $lastSeen < $loginCutoff) {
                unset($state['logins'][$key]);
                continue;
            }

            $state['logins'][$key]['failures'] = $failures;
            if ($lockoutUntil <= $now) {
                $state['logins'][$key]['lockout_until'] = 0;
            }
        }

        $this->capState($state, '', '', $now);
    }

    /**
     * Keep pruning off the hot path for most requests. Each shard prunes at most
     * once per interval, so traffic for one client no longer scans every client.
     *
     * @param array{version:int,requests:array<string,array<string,mixed>>,logins:array<string,array<string,mixed>>} $state
     */
    private function maybePruneState(array &$state, int $now): void
    {
        $lastPruned = (int) ($state['last_pruned_at'] ?? 0);
        if ($lastPruned > 0 && $lastPruned >= $now - self::PRUNE_INTERVAL_SECONDS) {
            return;
        }

        $this->pruneState($state, $now);
        $state['last_pruned_at'] = $now;
    }

    /**
     * @param array{version:int,requests:array<string,array<string,mixed>>,logins:array<string,array<string,mixed>>} $state
     */
    private function pruneRequestClient(array &$state, string $key, int $now): void
    {
        if (!isset($state['requests'][$key]) || !is_array($state['requests'][$key])) {
            return;
        }

        $state['requests'][$key] = array_merge(
            $state['requests'][$key],
            $this->requestEventState($state['requests'][$key], $now, false)
        );
    }

    /**
     * Keep raw request timestamps bounded without under-counting a configured
     * limit above the in-memory timestamp budget. Overflowed timestamps are
     * counted conservatively until the newest discarded timestamp has expired.
     *
     * @param array<string,mixed> $client
     * @return array{events:array<int,int>,request_overflow_count:int,request_overflow_until:int,burst_overflow_count:int,burst_overflow_until:int}
     */
    private function requestEventState(array $client, int $now, bool $append): array
    {
        $requestWindow = $this->settings->requestWindowSeconds();
        $burstWindow = $this->settings->burstWindowSeconds();
        $retentionWindow = max($requestWindow, $burstWindow);
        $events = self::pruneTimestamps(
            self::timestampsFrom($client['events'] ?? []),
            $now - $retentionWindow
        );
        $requestOverflow = self::requestOverflowState($client, 'request', $now);
        $burstOverflow = self::requestOverflowState($client, 'burst', $now);

        if ($append) {
            $events[] = $now;
            sort($events, SORT_NUMERIC);
        }

        if (count($events) > self::MAX_REQUEST_EVENTS_PER_CLIENT) {
            $overflowCount = count($events) - self::MAX_REQUEST_EVENTS_PER_CLIENT;
            $discarded = array_slice($events, 0, $overflowCount);
            $events = array_slice($events, -self::MAX_REQUEST_EVENTS_PER_CLIENT);

            foreach ($discarded as $timestamp) {
                $requestOverflow = self::addRequestOverflow($requestOverflow, $timestamp, $requestWindow, $now);
                $burstOverflow = self::addRequestOverflow($burstOverflow, $timestamp, $burstWindow, $now);
            }
        }

        return [
            'events' => $events,
            'request_overflow_count' => (int) $requestOverflow['count'],
            'request_overflow_until' => (int) $requestOverflow['until'],
            'burst_overflow_count' => (int) $burstOverflow['count'],
            'burst_overflow_until' => (int) $burstOverflow['until'],
        ];
    }

    /**
     * @param array<string,mixed> $client
     * @return array{count:int,until:int}
     */
    private static function requestOverflowState(array $client, string $prefix, int $now): array
    {
        $count = max(0, (int) ($client[$prefix . '_overflow_count'] ?? 0));
        $until = max(0, (int) ($client[$prefix . '_overflow_until'] ?? 0));

        if ($count === 0 || $until < $now) {
            return [
                'count' => 0,
                'until' => 0,
            ];
        }

        return [
            'count' => $count,
            'until' => $until,
        ];
    }

    /**
     * @param array{count:int,until:int} $overflow
     * @return array{count:int,until:int}
     */
    private static function addRequestOverflow(array $overflow, int $timestamp, int $windowSeconds, int $now): array
    {
        if ($timestamp < $now - $windowSeconds) {
            return $overflow;
        }

        $count = (int) $overflow['count'];
        if ($count < PHP_INT_MAX) {
            $count++;
        }

        $expires = $timestamp > PHP_INT_MAX - $windowSeconds
            ? PHP_INT_MAX
            : $timestamp + $windowSeconds;

        return [
            'count' => $count,
            'until' => max((int) $overflow['until'], $expires),
        ];
    }

    /**
     * @param array{version:int,requests:array<string,array<string,mixed>>,logins:array<string,array<string,mixed>>} $state
     */
    private function pruneLoginIdentity(array &$state, string $key, int $now): void
    {
        if (!isset($state['logins'][$key]) || !is_array($state['logins'][$key])) {
            return;
        }

        $state['logins'][$key]['failures'] = self::pruneTimestamps(
            self::timestampsFrom($state['logins'][$key]['failures'] ?? []),
            $now - $this->settings->loginWindowSeconds()
        );

        if ((int) ($state['logins'][$key]['lockout_until'] ?? 0) <= $now) {
            $state['logins'][$key]['lockout_until'] = 0;
        }
    }

    /**
     * @param array<int,array{key:string,ip:string,username:string,bucket:string}> $identities
     * @return array{storage_ready:bool,locked:bool,retry_after:int,failures:int}
     */
    private function loginStatusForIdentities(array $identities, int $now): array
    {
        $result = [
            'storage_ready' => true,
            'locked' => false,
            'retry_after' => 0,
            'failures' => 0,
        ];

        foreach ($identities as $identity) {
            $status = $this->withState(function (array &$state, bool $writable) use ($identity, $now): array {
                $this->maybePruneState($state, $now);
                $this->pruneLoginIdentity($state, $identity['key'], $now);

                $entry = isset($state['logins'][$identity['key']]) && is_array($state['logins'][$identity['key']])
                    ? $state['logins'][$identity['key']]
                    : [];
                $failures = self::timestampsFrom($entry['failures'] ?? []);
                $lockoutUntil = (int) ($entry['lockout_until'] ?? 0);
                $locked = $lockoutUntil > $now;

                return [
                    'storage_ready' => $writable,
                    'locked' => $locked,
                    'retry_after' => $locked ? max(1, $lockoutUntil - $now) : 0,
                    'failures' => count($failures),
                ];
            }, 'logins', $identity['key']);

            $isHardLockBucket = ($identity['bucket'] ?? '') !== 'username';
            $result['storage_ready'] = $result['storage_ready'] && !empty($status['storage_ready']);
            $result['locked'] = $result['locked'] || ($isHardLockBucket && !empty($status['locked']));
            $result['retry_after'] = $isHardLockBucket ? max($result['retry_after'], (int) ($status['retry_after'] ?? 0)) : $result['retry_after'];
            $result['failures'] = $isHardLockBucket ? max($result['failures'], (int) ($status['failures'] ?? 0)) : $result['failures'];
        }

        return $result;
    }

    /**
     * @param array<int,array{key:string,ip:string,username:string,bucket:string}> $identities
     * @return array{storage_ready:bool,locked:bool,new_lockout:bool,retry_after:int,failures:int}
     */
    private function recordLoginFailureForIdentities(array $identities, int $now): array
    {
        $result = [
            'storage_ready' => true,
            'locked' => false,
            'new_lockout' => false,
            'retry_after' => 0,
            'failures' => 0,
        ];

        foreach ($identities as $identity) {
            $record = $this->withState(function (array &$state, bool $writable) use ($identity, $now): array {
                $this->maybePruneState($state, $now);
                $this->pruneLoginIdentity($state, $identity['key'], $now);

                $entry = isset($state['logins'][$identity['key']]) && is_array($state['logins'][$identity['key']])
                    ? $state['logins'][$identity['key']]
                    : [
                        'ip' => $identity['ip'],
                        'username' => $identity['username'],
                        'bucket' => $identity['bucket'],
                        'failures' => [],
                        'lockout_until' => 0,
                        'last_seen' => 0,
                    ];

                $failures = self::timestampsFrom($entry['failures'] ?? []);
                $failures[] = $now;
                $failures = self::pruneTimestamps($failures, $now - $this->settings->loginWindowSeconds());
                $wasLocked = (int) ($entry['lockout_until'] ?? 0) > $now;
                $lockoutUntil = $wasLocked ? (int) $entry['lockout_until'] : 0;
                $newLockout = false;

                $reportedFailures = count($failures);
                if (($identity['bucket'] ?? '') !== 'username' && !$wasLocked && count($failures) >= $this->settings->loginFailureLimit()) {
                    $lockoutUntil = $now + $this->settings->loginLockoutSeconds();
                    $newLockout = true;
                    $failures = [];
                }

                $state['logins'][$identity['key']] = [
                    'ip' => $identity['ip'],
                    'username' => $identity['username'],
                    'bucket' => $identity['bucket'],
                    'failures' => $failures,
                    'lockout_until' => $lockoutUntil,
                    'last_seen' => $now,
                ];

                return [
                    'storage_ready' => $writable,
                    'locked' => $lockoutUntil > $now,
                    'new_lockout' => $newLockout,
                    'retry_after' => $lockoutUntil > $now ? max(1, $lockoutUntil - $now) : 0,
                    'failures' => $reportedFailures,
                ];
            }, 'logins', $identity['key']);

            $isHardLockBucket = ($identity['bucket'] ?? '') !== 'username';
            $result['storage_ready'] = $result['storage_ready'] && !empty($record['storage_ready']);
            $result['locked'] = $result['locked'] || ($isHardLockBucket && !empty($record['locked']));
            $result['new_lockout'] = $result['new_lockout'] || ($isHardLockBucket && !empty($record['new_lockout']));
            $result['retry_after'] = $isHardLockBucket ? max($result['retry_after'], (int) ($record['retry_after'] ?? 0)) : $result['retry_after'];
            $result['failures'] = max($result['failures'], (int) ($record['failures'] ?? 0));
        }

        return $result;
    }

    /**
     * @param array{version:int,requests:array<string,array<string,mixed>>,logins:array<string,array<string,mixed>>} $state
     */
    private function capState(array &$state, string $protectedScope = '', string $protectedKey = '', int $now = 0): void
    {
        $now = $now > 0 ? $now : time();

        if (count($state['requests']) > self::MAX_TRACKED_REQUEST_CLIENTS_PER_SHARD) {
            $state['requests'] = $this->capEntries(
                $state['requests'],
                self::MAX_TRACKED_REQUEST_CLIENTS_PER_SHARD,
                $protectedScope === 'requests' ? $protectedKey : '',
                false,
                $now
            );
        }

        if (count($state['logins']) > self::MAX_TRACKED_LOGIN_IDENTITIES_PER_SHARD) {
            $state['logins'] = $this->capEntries(
                $state['logins'],
                self::MAX_TRACKED_LOGIN_IDENTITIES_PER_SHARD,
                $protectedScope === 'logins' ? $protectedKey : '',
                true,
                $now
            );
        }
    }

    /**
     * @param array<string,array<string,mixed>> $entries
     * @return array<string,array<string,mixed>>
     */
    private function capEntries(array $entries, int $limit, string $protectedKey, bool $preserveLockout, int $now): array
    {
        uksort($entries, function (string $leftKey, string $rightKey) use ($entries, $protectedKey, $preserveLockout, $now): int {
            $priority = $this->compareEntryPriority(
                $entries[$leftKey],
                $entries[$rightKey],
                $preserveLockout,
                $now
            );
            if ($priority !== 0) {
                return $priority;
            }

            $leftProtected = $protectedKey !== '' && $leftKey === $protectedKey;
            $rightProtected = $protectedKey !== '' && $rightKey === $protectedKey;
            if ($leftProtected !== $rightProtected) {
                return $leftProtected ? -1 : 1;
            }

            return strcmp($leftKey, $rightKey);
        });

        return array_slice($entries, 0, max(0, $limit), true);
    }

    /**
     * @param array<string,mixed> $left
     * @param array<string,mixed> $right
     */
    private function compareEntryPriority(array $left, array $right, bool $preserveLockout, int $now): int
    {
        $leftPriority = $preserveLockout
            ? $this->loginEntryPriority($left, $now)
            : $this->requestEntryPriority($left, $now);
        $rightPriority = $preserveLockout
            ? $this->loginEntryPriority($right, $now)
            : $this->requestEntryPriority($right, $now);

        foreach ($leftPriority as $index => $value) {
            if ($value !== $rightPriority[$index]) {
                return $rightPriority[$index] <=> $value;
            }
        }

        return 0;
    }

    /**
     * @param array<string,mixed> $entry
     * @return array<int,int>
     */
    private function requestEntryPriority(array $entry, int $now): array
    {
        $window = max($this->settings->requestWindowSeconds(), $this->settings->burstWindowSeconds());
        $events = self::pruneTimestamps(self::timestampsFrom($entry['events'] ?? []), $now - $window);
        $requestOverflow = self::requestOverflowState($entry, 'request', $now);
        $burstOverflow = self::requestOverflowState($entry, 'burst', $now);
        $activeEvents = count($events) + max(
            (int) $requestOverflow['count'],
            (int) $burstOverflow['count']
        );
        $lastLimited = (int) ($entry['last_limited_at'] ?? 0);
        $limited = $lastLimited >= $now - $window;

        return [
            $limited ? 1 : 0,
            $activeEvents > 0 ? 1 : 0,
            $activeEvents,
            $limited ? $lastLimited : 0,
            (int) ($entry['last_seen'] ?? 0),
        ];
    }

    /**
     * @param array<string,mixed> $entry
     * @return array<int,int>
     */
    private function loginEntryPriority(array $entry, int $now): array
    {
        $failures = self::pruneTimestamps(
            self::timestampsFrom($entry['failures'] ?? []),
            $now - $this->settings->loginWindowSeconds()
        );
        $lockoutUntil = (int) ($entry['lockout_until'] ?? 0);
        $locked = $lockoutUntil > $now;

        return [
            $locked ? 1 : 0,
            count($failures) > 0 ? 1 : 0,
            count($failures),
            $locked ? $lockoutUntil : 0,
            (int) ($entry['last_seen'] ?? 0),
        ];
    }

    /**
     * @param mixed $values
     * @return array<int,int>
     */
    private static function timestampsFrom($values): array
    {
        if (!is_array($values)) {
            return [];
        }

        $timestamps = [];
        foreach ($values as $value) {
            if (is_numeric($value)) {
                $timestamps[] = (int) $value;
            }
        }

        sort($timestamps);

        return $timestamps;
    }

    /**
     * @param array<int,int> $timestamps
     * @return array<int,int>
     */
    private static function pruneTimestamps(array $timestamps, int $cutoff): array
    {
        return array_values(array_filter($timestamps, static function (int $timestamp) use ($cutoff): bool {
            return $timestamp >= $cutoff;
        }));
    }

    /**
     * @param array<int,int> $events
     */
    private static function retryAfter(array $events, int $windowSeconds, int $now): int
    {
        if (empty($events)) {
            return 1;
        }

        $oldest = min($events);

        return max(1, ($oldest + $windowSeconds) - $now);
    }

    /**
     * @param array<int,int> $events
     */
    private static function retryAfterWithOverflow(array $events, int $overflowCount, int $overflowUntil, int $windowSeconds, int $now): int
    {
        $retryAfter = self::retryAfter($events, $windowSeconds, $now);

        if ($overflowCount > 0 && $overflowUntil >= $now) {
            $retryAfter = max($retryAfter, max(1, $overflowUntil - $now));
        }

        return $retryAfter;
    }

    /**
     * Track three buckets so an attacker cannot avoid lockout simply by
     * spraying many usernames from one IP or one username from many IPs.
     *
     * @return array<int,array{key:string,ip:string,username:string,bucket:string}>
     */
    private function loginIdentities(string $ip, string $username): array
    {
        $normalizedIp = self::normalizeIp($ip);
        $normalizedUser = self::normalizeUsername($username);

        $identities = [
            [
                'key' => hash('sha256', 'combined|' . $normalizedIp . '|' . $normalizedUser),
                'ip' => $normalizedIp,
                'username' => $normalizedUser,
                'bucket' => 'combined',
            ],
        ];

        if ($normalizedIp !== 'unknown') {
            $identities[] = [
                'key' => hash('sha256', 'ip|' . $normalizedIp),
                'ip' => $normalizedIp,
                'username' => '*',
                'bucket' => 'ip',
            ];
        }

        $identities[] = [
            'key' => hash('sha256', 'username|' . $normalizedUser),
            'ip' => '*',
            'username' => $normalizedUser,
            'bucket' => 'username',
        ];

        return $identities;
    }

    private static function normalizeUsername(string $username): string
    {
        $username = strtolower(trim($username));

        if (function_exists('sanitize_user')) {
            $username = sanitize_user($username, true);
        } else {
            $username = (string) preg_replace('/[^a-z0-9_.@-]/', '', $username);
        }

        return $username !== '' ? $username : 'unknown';
    }
}
