<?php
declare(strict_types=1);

namespace Autogedal\BlackFriday\Model;

use Magento\Framework\App\CacheInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\HTTP\PhpEnvironment\Request as HttpRequest;
use Magento\Framework\Lock\LockManagerInterface;
use Magento\Framework\Serialize\Serializer\Json;

/**
 * Simple fixed-window limits for the public endpoints of the page (subscribe, favourites), kept in the Magento cache.
 * Not a replacement for reCAPTCHA: it caps automated subscriptions per visitor and per email (email bombing).
 */
class RateLimiter
{
    private const PREFIX = 'autogedal_bf_rl_';
    private const LOCK_PREFIX = 'bfrl_';   // + sha1: within the 64 characters of a MySQL lock name
    private const LOCK_TIMEOUT = 2;

    /**
     * @param string[] $trustedProxies CIDR ranges whose CF-Connecting-IP header is trusted (Cloudflare, di.xml)
     */
    public function __construct(
        private readonly CacheInterface $cache,
        private readonly LockManagerInterface $lockManager,
        private readonly Json $json,
        private readonly array $trustedProxies = []
    ) {
    }

    /**
     * Counts one attempt in the bucket; false when the limit of the window is already reached (nothing counted).
     * Read + increment + write run under a lock, so concurrent requests cannot pass on the same count. When the lock
     * cannot be taken the attempt is refused (fail closed).
     */
    public function hit(string $bucket, int $limit, int $windowSeconds): bool
    {
        $key = self::PREFIX . sha1($bucket);
        $lockName = self::LOCK_PREFIX . sha1($bucket);
        if (!$this->lockManager->lock($lockName, self::LOCK_TIMEOUT)) {
            return false;
        }

        try {
            $now = time();
            $entry = $this->read($key);
            if ($entry === null || $entry['until'] <= $now) {
                $entry = ['count' => 0, 'until' => $now + $windowSeconds];
            }
            if ($entry['count'] >= $limit) {
                return false;
            }
            $entry['count']++;
            $this->cache->save((string)$this->json->serialize($entry), $key, [], max(1, $entry['until'] - $now));
            return true;
        } finally {
            $this->lockManager->unlock($lockName);
        }
    }

    /**
     * IP of the visitor: the Cloudflare header only when the request really comes from a Cloudflare proxy
     * (REMOTE_ADDR in the trusted ranges); otherwise REMOTE_ADDR, so a client reaching the origin directly cannot
     * pick its IP through the header.
     */
    public function clientIp(RequestInterface $request): string
    {
        if (!$request instanceof HttpRequest) {
            return '';
        }
        $remote = (string)$request->getServer('REMOTE_ADDR');
        $cloudflare = (string)$request->getServer('HTTP_CF_CONNECTING_IP');
        if ($cloudflare !== '' && filter_var($cloudflare, FILTER_VALIDATE_IP) && $this->isTrustedProxy($remote)) {
            return $cloudflare;
        }
        return $remote;
    }

    /** @return array{count: int, until: int}|null */
    private function read(string $key): ?array
    {
        $raw = $this->cache->load($key);
        if (!$raw) {
            return null;
        }
        try {
            $entry = $this->json->unserialize((string)$raw);
        } catch (\InvalidArgumentException) {
            return null;
        }
        return is_array($entry) ? ['count' => (int)($entry['count'] ?? 0), 'until' => (int)($entry['until'] ?? 0)] : null;
    }

    private function isTrustedProxy(string $ip): bool
    {
        foreach ($this->trustedProxies as $range) {
            if ($this->inRange($ip, (string)$range)) {
                return true;
            }
        }
        return false;
    }

    /** IPv4 / IPv6 address inside a CIDR range. */
    private function inRange(string $ip, string $cidr): bool
    {
        [$subnet, $bits] = array_pad(explode('/', $cidr, 2), 2, null);
        $ipBinary = @inet_pton($ip);
        $subnetBinary = @inet_pton((string)$subnet);
        if ($ipBinary === false || $subnetBinary === false || strlen($ipBinary) !== strlen($subnetBinary)) {
            return false;
        }
        $bits = $bits === null ? strlen($ipBinary) * 8 : (int)$bits;
        $bytes = intdiv($bits, 8);
        if (strncmp($ipBinary, $subnetBinary, $bytes) !== 0) {
            return false;
        }
        $rest = $bits % 8;
        if ($rest === 0) {
            return true;
        }
        $mask = chr((0xFF << (8 - $rest)) & 0xFF);
        return ($ipBinary[$bytes] & $mask) === ($subnetBinary[$bytes] & $mask);
    }
}
