<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\Component\RateLimiter\Policy;

use Symfony\Component\RateLimiter\Exception\InvalidIntervalException;
use Symfony\Component\RateLimiter\LimiterStateInterface;

/**
 * @author Tobias Nyholm <tobias.nyholm@gmail.com>
 *
 * @internal
 */
final class SlidingWindow implements LimiterStateInterface
{
    private string $id;
    private int $hitCount = 0;
    private int $hitCountForLastWindow = 0;
    private int $intervalInSeconds;
    private float $windowEndAt;

    public function __construct(string $id, int $intervalInSeconds)
    {
        if ($intervalInSeconds < 1) {
            throw new InvalidIntervalException(sprintf('The interval must be positive integer, "%d" given.', $intervalInSeconds));
        }
        $this->id = $id;
        $this->intervalInSeconds = $intervalInSeconds;
        $this->windowEndAt = microtime(true) + $intervalInSeconds;
    }

    public static function createFromPreviousWindow(self $window, int $intervalInSeconds): self
    {
        $new = new self($window->id, $intervalInSeconds);
        $windowEndAt = $window->windowEndAt + $intervalInSeconds;

        if (microtime(true) < $windowEndAt) {
            $new->hitCountForLastWindow = $window->hitCount;
            $new->windowEndAt = $windowEndAt;
        }

        return $new;
    }

    public function getId(): string
    {
        return $this->id;
    }

    /**
     * Returns the remaining of this timeframe and the next one.
     */
    public function getExpirationTime(): int
    {
        return (int) ($this->windowEndAt + $this->intervalInSeconds - microtime(true));
    }

    public function isExpired(): bool
    {
        return microtime(true) > $this->windowEndAt;
    }

    public function add(int $hits = 1): void
    {
        $this->hitCount += $hits;
    }

    /**
     * Calculates the sliding window number of request.
     */
    public function getHitCount(): int
    {
        $startOfWindow = $this->windowEndAt - $this->intervalInSeconds;
        $percentOfCurrentTimeFrame = min((microtime(true) - $startOfWindow) / $this->intervalInSeconds, 1);

        return (int) floor($this->hitCountForLastWindow * (1 - $percentOfCurrentTimeFrame) + $this->hitCount);
    }

    /**
     * @deprecated since Symfony 6.4, use {@see self::calculateTimeForTokens} instead
     */
    public function getRetryAfter(): \DateTimeImmutable
    {
        trigger_deprecation('symfony/ratelimiter', '6.4', 'The "%s()" method is deprecated, use "%s::calculateTimeForTokens" instead.', __METHOD__, self::class);

        return \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true) + $this->calculateTimeForTokens(max(1, $this->getHitCount()), 1)));
    }

    public function calculateTimeForTokens(int $maxSize, int $tokens): float
    {
        $remaining = $maxSize - $this->getHitCount();
        if ($remaining >= $tokens) {
            return 0;
        }

        $time = microtime(true);
        $startOfWindow = $this->windowEndAt - $this->intervalInSeconds;
        $timePassed = $time - $startOfWindow;
        $windowPassed = min($timePassed / $this->intervalInSeconds, 1);
        $releasable = max(1, $maxSize - floor($this->hitCountForLastWindow * (1 - $windowPassed)));
        $remainingWindow = $this->intervalInSeconds - $timePassed;
        $needed = $tokens - $remaining;

        if ($releasable >= $needed) {
            return $needed * ($remainingWindow / max(1, $releasable));
        }

        return ($this->windowEndAt - $time) + ($needed - $releasable) * ($this->intervalInSeconds / $maxSize);
    }

    public function __serialize(): array
    {
        return [
            pack('NNN', $this->hitCount, $this->hitCountForLastWindow, $this->intervalInSeconds).$this->id => $this->windowEndAt,
        ];
    }

    public function __unserialize(array $data): void
    {
        // BC layer for old objects serialized via __sleep
        if (5 === \count($data)) {
            $data = array_values($data);
            $this->id = $data[0];
            $this->hitCount = $data[1];
            $this->intervalInSeconds = $data[2];
            $this->hitCountForLastWindow = $data[3];
            $this->windowEndAt = $data[4];

            return;
        }

        $pack = key($data);
        $this->windowEndAt = $data[$pack];
        ['a' => $this->hitCount, 'b' => $this->hitCountForLastWindow, 'c' => $this->intervalInSeconds] = unpack('Na/Nb/Nc', $pack);
        $this->id = substr($pack, 12);
    }
}
