<?php

namespace MintyPHP\Service\Security;

use DateTimeImmutable;
use DateTimeZone;
use MintyPHP\Repository\Security\RateLimitRepositoryInterface;

class RateLimiterService
{
    private const HASH_ALGO = 'sha256';

    public function __construct(private readonly RateLimitRepositoryInterface $rateLimitRepository)
    {
    }

    public function hit(string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds): array
    {
        return $this->apply($scope, $subject, $maxHits, $windowSeconds, $blockSeconds, true);
    }

    public function registerFailure(string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds): array
    {
        return $this->apply($scope, $subject, $maxHits, $windowSeconds, $blockSeconds, true);
    }

    public function isBlocked(string $scope, string $subject): array
    {
        $normalized = $this->normalize($scope, $subject);
        if ($normalized === null) {
            return ['allowed' => true, 'retry_after' => 0];
        }

        try {
            $row = $this->rateLimitRepository->findByScopeAndHash($normalized['scope'], $normalized['subject_hash']);
            if (!is_array($row)) {
                return ['allowed' => true, 'retry_after' => 0];
            }

            $blockedUntilTs = $this->parseTimestamp((string) ($row['blocked_until'] ?? ''));
            if ($blockedUntilTs === null) {
                return ['allowed' => true, 'retry_after' => 0];
            }

            $nowTs = time();
            if ($blockedUntilTs <= $nowTs) {
                $this->rateLimitRepository->updateStateById(
                    (int) ($row['id'] ?? 0),
                    0,
                    gmdate('Y-m-d H:i:s', $nowTs),
                    null
                );
                return ['allowed' => true, 'retry_after' => 0];
            }

            return [
                'allowed' => false,
                'retry_after' => max(1, $blockedUntilTs - $nowTs),
            ];
        } catch (\Throwable $exception) {
            // Fail-open: never lock users out because of rate limit storage issues.
            return ['allowed' => true, 'retry_after' => 0];
        }
    }

    public function reset(string $scope, string $subject): void
    {
        $normalized = $this->normalize($scope, $subject);
        if ($normalized === null) {
            return;
        }

        try {
            $this->rateLimitRepository->deleteByScopeAndHash($normalized['scope'], $normalized['subject_hash']);
        } catch (\Throwable $exception) {
            // Ignore reset failures.
        }
    }

    private function apply(
        string $scope,
        string $subject,
        int $maxHits,
        int $windowSeconds,
        int $blockSeconds,
        bool $increment
    ): array {
        $normalized = $this->normalize($scope, $subject);
        if ($normalized === null) {
            return ['allowed' => true, 'retry_after' => 0];
        }

        $maxHits = max(1, $maxHits);
        $windowSeconds = max(1, $windowSeconds);
        $blockSeconds = max(1, $blockSeconds);

        try {
            $nowTs = time();
            $nowSql = gmdate('Y-m-d H:i:s', $nowTs);

            // Two-attempt loop handles a creation race: if two requests arrive simultaneously for a
            // new subject, both may find no row and try to INSERT. The second INSERT fails (unique key),
            // so we retry once and the second attempt reads the now-existing row.
            for ($attempt = 0; $attempt < 2; $attempt++) {
                $row = $this->rateLimitRepository->findByScopeAndHash($normalized['scope'], $normalized['subject_hash']);

                if (!is_array($row)) {
                    if (!$increment) {
                        return ['allowed' => true, 'retry_after' => 0];
                    }

                    $hits = 1;
                    $created = $this->rateLimitRepository->create(
                        $normalized['scope'],
                        $normalized['subject_hash'],
                        $hits,
                        $nowSql,
                        null
                    );

                    if ($created) {
                        return ['allowed' => true, 'retry_after' => 0];
                    }

                    continue;
                }

                $id = (int) ($row['id'] ?? 0);
                if ($id <= 0) {
                    return ['allowed' => true, 'retry_after' => 0];
                }

                $blockedUntilTs = $this->parseTimestamp((string) ($row['blocked_until'] ?? ''));
                if ($blockedUntilTs !== null && $blockedUntilTs > $nowTs) {
                    return [
                        'allowed' => false,
                        'retry_after' => max(1, $blockedUntilTs - $nowTs),
                    ];
                }

                $windowStartedTs = $this->parseTimestamp((string) ($row['window_started_at'] ?? ''));
                // Sliding window expired — reset the window start and hit count.
                if ($windowStartedTs === null || ($windowStartedTs + $windowSeconds) <= $nowTs) {
                    $windowStartedTs = $nowTs;
                    $hits = 0;
                } else {
                    $hits = max(0, (int) ($row['hits'] ?? 0));
                }

                if ($increment) {
                    $hits++;
                }

                $newBlockedUntilTs = null;
                if ($hits > $maxHits) {
                    $newBlockedUntilTs = $nowTs + $blockSeconds;
                }

                $this->rateLimitRepository->updateStateById(
                    $id,
                    $hits,
                    gmdate('Y-m-d H:i:s', $windowStartedTs),
                    $newBlockedUntilTs !== null ? gmdate('Y-m-d H:i:s', $newBlockedUntilTs) : null
                );

                if ($newBlockedUntilTs !== null) {
                    return [
                        'allowed' => false,
                        'retry_after' => max(1, $newBlockedUntilTs - $nowTs),
                    ];
                }

                return ['allowed' => true, 'retry_after' => 0];
            }

            return ['allowed' => true, 'retry_after' => 0];
        } catch (\Throwable $exception) {
            // Fail-open on storage failures.
            return ['allowed' => true, 'retry_after' => 0];
        }
    }

    private function normalize(string $scope, string $subject): ?array
    {
        $scope = strtolower(trim($scope));
        $subject = trim($subject);

        if ($scope === '' || $subject === '') {
            return null;
        }

        if (strlen($scope) > 64) {
            $scope = substr($scope, 0, 64);
        }

        // Hash the subject (e.g. email or IP) so PII is never stored in the rate limit table.
        return [
            'scope' => $scope,
            'subject_hash' => hash(self::HASH_ALGO, $subject),
        ];
    }

    private function parseTimestamp(string $value): ?int
    {
        $value = trim($value);
        if ($value === '') {
            return null;
        }

        $date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, new DateTimeZone('UTC'));
        if ($date instanceof DateTimeImmutable) {
            return $date->getTimestamp();
        }

        $timestamp = strtotime($value . ' UTC');
        if ($timestamp === false || $timestamp <= 0) {
            return null;
        }

        return $timestamp;
    }
}
