<?php

declare(strict_types=1);

/**
 * APO-197 Card 8 — Per-integration rate limiter.
 *
 * Sliding-window: counts ai_audit_log rows for the integration created in the
 * last 60 seconds. If `count + 1 > requestsPerMinute + burstCapacity`, the
 * request is rejected with `rate_limited` outcome + HTTP 429 + Retry-After.
 *
 * Defaults: 60 req/min, 20 burst. Per-integration overrides via the
 * `rateLimitConfig` JSON column on `ai_integrations`. Exempt scopes (default
 * empty) bypass the check; admin tooling can mark `administration.read` exempt
 * for monitoring use.
 *
 * The audit log IS the counter — no separate counter table.
 */
final class MyronRateLimitDecision
{
    public function __construct(
        public readonly bool $allowed,
        public readonly int $retryAfterSeconds,
        public readonly string $reason,
    ) {
    }
}

final class MyronRateLimiter
{
    public const DEFAULT_RPM    = 60;
    public const DEFAULT_BURST  = 20;
    public const WINDOW_SECONDS = 60;

    public function __construct(
        private readonly MyronAuditLogRepository $auditLog,
        private readonly MyronAiIntegrationRepository $integrations,
    ) {
    }

    /**
     * Decide whether to allow a request from this integration for this scope.
     *
     * @param string $integrationId  AI integration ID (caller_id)
     * @param string $requiredScope  the action's required scope (used for exempt check)
     */
    public function decide(string $integrationId, string $requiredScope): MyronRateLimitDecision
    {
        $integrationId = trim($integrationId);
        if ($integrationId === '' || $integrationId === 'dev-token' || $integrationId === 'anonymous' || $integrationId === 'system') {
            // Non-AI callers (anonymous, dev-token, system) bypass the limiter.
            return new MyronRateLimitDecision(true, 0, 'non-ai-caller');
        }
        $integration = $this->integrations->findById($integrationId);
        if ($integration === null) {
            return new MyronRateLimitDecision(true, 0, 'integration-not-found');
        }
        $config = $integration->rateLimitConfig ?? [];
        $rpm   = (int) ($config['requestsPerMinute'] ?? self::DEFAULT_RPM);
        $burst = (int) ($config['burstCapacity'] ?? self::DEFAULT_BURST);
        $exemptScopes = is_array($config['exemptScopes'] ?? null) ? array_map('strval', $config['exemptScopes']) : [];

        if ($requiredScope !== '' && in_array($requiredScope, $exemptScopes, true)) {
            return new MyronRateLimitDecision(true, 0, 'scope-exempt');
        }

        $sinceTimestamp = self::nowIsoMinusSeconds(self::WINDOW_SECONDS);
        $count = $this->auditLog->countSince($integrationId, $sinceTimestamp);
        $allowed = ($count + 1) <= ($rpm + $burst);

        return new MyronRateLimitDecision(
            allowed: $allowed,
            retryAfterSeconds: $allowed ? 0 : self::WINDOW_SECONDS,
            reason: $allowed
                ? 'within-budget(' . $count . '/' . ($rpm + $burst) . ')'
                : 'over-budget(' . $count . '/' . ($rpm + $burst) . ')',
        );
    }

    private static function nowIsoMinusSeconds(int $seconds): string
    {
        // Use the same format as myronNow() ('Y-m-d H:i:s' local) so string
        // comparison in the SQL query works correctly. The audit log
        // created_at column stores values in this format.
        return date('Y-m-d H:i:s', time() - $seconds);
    }
}
