<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\EventSubscriber;

use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * Rate-limits requests to FFmpeg Media API and AJAX endpoints.
 *
 * Uses a sliding-window counter backed by cache. Each client IP is allowed
 * a configurable number of requests per time window. The sliding window
 * interpolates between the previous and current fixed window to eliminate
 * the 2x burst problem at window boundaries.
 *
 * Protected routes include progress/health/metrics API endpoints and
 * all AJAX/SSE endpoints from both the core module and UI submodule.
 */
final class ApiRateLimitSubscriber implements EventSubscriberInterface {

  /**
   * Route names subject to rate limiting.
   *
   * Covers core API routes, UI AJAX routes, and UI SSE routes.
   *
   * @var array<int, string>
   */
  private const array RATE_LIMITED_ROUTES = [
    // Core API routes.
    'ffmpeg_media.progress',
    'ffmpeg_media.health',
    'ffmpeg_media.health.status',
    // NOTE: liveness and readiness probes are intentionally excluded.
    // Orchestration tools (Kubernetes, load balancers) must reach these
    // endpoints reliably without being blocked by API burst traffic.
    'ffmpeg_media.metrics',
    'ffmpeg_media.metrics.summary',
    // UI AJAX routes.
    'ffmpeg_media_ui.dashboard.ajax',
    'ffmpeg_media_ui.ajax.progress',
    'ffmpeg_media_ui.ajax.thumbnails',
    'ffmpeg_media_ui.ajax.batch',
    'ffmpeg_media_ui.ajax.queue.list',
    'ffmpeg_media_ui.ajax.queue.reorder',
    'ffmpeg_media_ui.ajax.queue.action',
    // UI SSE routes.
    'ffmpeg_media_ui.sse.media_progress',
    'ffmpeg_media_ui.sse.queue_progress',
    'ffmpeg_media_ui.sse.dashboard_stats',
  ];

  /**
   * Constructs an ApiRateLimitSubscriber.
   *
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   The config factory.
   * @param \Drupal\Core\Cache\CacheBackendInterface $cache
   *   Cache backend for rate limit counters.
   */
  public function __construct(
    protected readonly ConfigFactoryInterface $configFactory,
    protected readonly CacheBackendInterface $cache,
  ) {}

  /**
   * {@inheritdoc}
   *
   * @phpstan-return array<string, array{string, int}>
   */
  #[\Override]
  public static function getSubscribedEvents(): array {
    // Run before controllers but after authentication.
    return [KernelEvents::REQUEST => ['onRequest', 100]];
  }

  /**
   * Checks rate limit on API requests.
   */
  public function onRequest(RequestEvent $event): void {
    if (!$event->isMainRequest()) {
      return;
    }

    $request = $event->getRequest();
    $routeName = $request->attributes->get('_route');

    if (!is_string($routeName) || !in_array($routeName, self::RATE_LIMITED_ROUTES, TRUE)) {
      return;
    }

    $config = $this->configFactory->get('ffmpeg_media.settings');
    $maxRequests = (int) ($config->get('rate_limit_requests') ?? 60);
    $windowSeconds = (int) ($config->get('rate_limit_window') ?? 60);

    if ($maxRequests <= 0) {
      // Rate limiting disabled.
      return;
    }

    $clientIp = $request->getClientIp() ?? 'unknown';
    $cacheKey = 'ffmpeg_media:rate_limit:' . hash('xxh3', $clientIp . ':' . $routeName);
    $now = time();

    // Sliding-window counter: interpolate between previous and current
    // fixed windows to smooth burst allowance at boundaries.
    $currentWindowStart = $now - ($now % $windowSeconds);
    $previousWindowStart = $currentWindowStart - $windowSeconds;
    $elapsedInWindow = $now - $currentWindowStart;
    $previousWeight = 1.0 - ($elapsedInWindow / $windowSeconds);

    $cached = $this->cache->get($cacheKey);
    $previousCount = 0;
    $currentCount = 0;

    if ($cached !== FALSE && is_array($cached->data)) {
      if (($cached->data['window'] ?? 0) === $currentWindowStart) {
        $currentCount = (int) ($cached->data['count'] ?? 0);
        $previousCount = (int) ($cached->data['prev_count'] ?? 0);
      }
      elseif (($cached->data['window'] ?? 0) === $previousWindowStart) {
        // Previous window data becomes the "prev" for the new window.
        $previousCount = (int) ($cached->data['count'] ?? 0);
      }
    }

    $currentCount++;

    $this->cache->set($cacheKey, [
      'window' => $currentWindowStart,
      'count' => $currentCount,
      'prev_count' => $previousCount,
    ], $currentWindowStart + (2 * $windowSeconds));

    // Weighted request count: current window + proportional previous window.
    $effectiveCount = $currentCount + (int) ($previousCount * $previousWeight);

    if ($effectiveCount > $maxRequests) {
      $retryAfter = $currentWindowStart + $windowSeconds - $now;
      $remaining = max(0, $maxRequests - $effectiveCount);
      $response = new JsonResponse([
        'error' => 'Too many requests. Please try again later.',
      ], 429, [
        'Retry-After' => (string) max(1, $retryAfter),
        'X-RateLimit-Limit' => (string) $maxRequests,
        'X-RateLimit-Remaining' => (string) $remaining,
      ]);
      $event->setResponse($response);
    }
  }

}
