<?php

namespace Crwlr\Crawler\Loader\Http\Politeness;

use Crwlr\Crawler\Loader\Http\Politeness\TimingUnits\MultipleOf;
use Crwlr\Url\Url;
use Crwlr\Utils\Microseconds;
use Exception;
use InvalidArgumentException;
use Psr\Http\Message\UriInterface;

class Throttler
{
    /**
     * @var array<string, Microseconds>
     */
    protected array $latestRequestTimes = [];

    /**
     * @var array<string, Microseconds>
     */
    protected array $latestResponseTimes = [];

    /**
     * @var array<string, Microseconds>
     */
    protected array $latestDurations = [];

    protected Microseconds|MultipleOf $from;

    protected Microseconds|MultipleOf $to;

    protected Microseconds $min;

    /**
     * @var string[]
     */
    private array $_currentRequestUrls = [];

    /**
     * @throws InvalidArgumentException
     */
    public function __construct(
        Microseconds|MultipleOf|null $from = null,
        Microseconds|MultipleOf|null $to = null,
        Microseconds|null $min = null,
        protected Microseconds|null $max = null,
    ) {
        $this->from = $from ?? new MultipleOf(1.0);

        $this->to = $to ?? new MultipleOf(2.0);

        $this->validateFromAndTo();

        $this->min = $min ?? Microseconds::fromSeconds(0.25);
    }

    /**
     * @throws InvalidArgumentException
     */
    public function waitBetween(Microseconds|MultipleOf $from, Microseconds|MultipleOf $to): static
    {
        $this->from = $from;

        $this->to = $to;

        $this->validateFromAndTo();

        return $this;
    }

    public function waitAtLeast(Microseconds $seconds): static
    {
        $this->min = $seconds;

        return $this;
    }

    public function waitAtMax(Microseconds $seconds): static
    {
        $this->max = $seconds;

        return $this;
    }

    /**
     * @throws Exception
     */
    public function trackRequestStartFor(UriInterface $url): void
    {
        $domain = $this->getDomain($url);

        $this->latestRequestTimes[$domain] = $this->time();

        $this->_internalTrackStartFor($url);
    }

    /**
     * @throws Exception
     */
    public function trackRequestEndFor(UriInterface $url): void
    {
        if (!$this->_requestToUrlWasStarted($url)) {
            return;
        }

        $domain = $this->getDomain($url);

        if (!isset($this->latestRequestTimes[$domain])) {
            return;
        }

        $this->latestResponseTimes[$domain] = $responseTime = $this->time();

        $this->latestDurations[$domain] = $responseTime->subtract($this->latestRequestTimes[$domain]);

        unset($this->latestRequestTimes[$domain]);

        $this->_internalTrackEndFor($url);
    }

    /**
     * @throws Exception
     */
    public function waitForGo(UriInterface $url): void
    {
        $domain = $this->getDomain($url);

        if (!isset($this->latestDurations[$domain])) {
            return;
        }

        $waitUntil = $this->calcWaitUntil($this->latestDurations[$domain], $this->latestResponseTimes[$domain]);

        $now = $this->time();

        if ($now->isGreaterThanOrEqual($waitUntil)) {
            return;
        }

        $wait = $waitUntil->subtract($now);

        usleep($wait->value);
    }

    protected function time(): Microseconds
    {
        return Microseconds::fromSeconds(microtime(true));
    }

    /**
     * @throws Exception
     */
    protected function getDomain(UriInterface $url): string
    {
        $domain = Url::parse($url)->domain();

        if (!$domain) {
            $domain = $url->getHost();
        }

        if (!is_string($domain)) {
            $domain = '*';
        }

        return $domain;
    }

    protected function calcWaitUntil(
        Microseconds $latestResponseDuration,
        Microseconds $latestResponseTime,
    ): Microseconds {
        $from = $this->from instanceof MultipleOf ? $this->from->calc($latestResponseDuration) : $this->from;

        $to = $this->to instanceof MultipleOf ? $this->to->calc($latestResponseDuration) : $this->to;

        $waitValue = $this->getRandBetween($from, $to);

        if ($this->min->isGreaterThan($waitValue)) {
            $waitValue = $this->min;
        }

        if ($this->max && $this->max->isLessThan($waitValue)) {
            $waitValue = $this->max;
        }

        return $latestResponseTime->add($waitValue);
    }

    protected function getRandBetween(Microseconds $from, Microseconds $to): Microseconds
    {
        if ($from->equals($to)) {
            return $from;
        }

        return new Microseconds(rand($from->value, $to->value));
    }

    /**
     * @internal
     */
    protected function _internalTrackStartFor(UriInterface $url): void
    {
        $urlString = (string) $url;

        $this->_currentRequestUrls[$urlString] = $urlString;
    }

    /**
     * @internal
     */
    protected function _internalTrackEndFor(UriInterface $url): void
    {
        unset($this->_currentRequestUrls[(string) $url]);
    }

    protected function _requestToUrlWasStarted(UriInterface $url): bool
    {
        $urlString = (string) $url;

        if (array_key_exists($urlString, $this->_currentRequestUrls)) {
            return true;
        }

        return false;
    }

    protected function validateFromAndTo(): void
    {
        if (!$this->fromAndToAreOfSameType()) {
            throw new InvalidArgumentException('From and to values must be of the same type (Seconds or MultipleOf).');
        }

        if ($this->fromIsGreaterThanTo()) {
            throw new InvalidArgumentException('From value can\'t be greater than to value.');
        }
    }

    protected function fromAndToAreOfSameType(): bool
    {
        return ($this->from instanceof Microseconds && $this->to instanceof Microseconds) ||
            ($this->from instanceof MultipleOf && $this->to instanceof MultipleOf);
    }

    protected function fromIsGreaterThanTo(): bool
    {
        if ($this->from instanceof Microseconds && $this->to instanceof Microseconds) {
            return $this->from->isGreaterThan($this->to);
        }

        if ($this->from instanceof MultipleOf && $this->to instanceof MultipleOf) {
            return $this->from->factorIsGreaterThan($this->to);
        }

        return false;
    }
}
