<?php

namespace Mtc\Core;

use Illuminate\Database\Eloquent\Model;
use Ramsey\Uuid\Uuid;

/**
 * Rate Limit Model
 *
 * Handles rate limiting functionality by tracking requests per IP address and key.
 * Can either throw exceptions or exit with 429 status when limits are exceeded.
 *
 * @author Craig McCreath <craig.mccreath@mtc.co.uk>
 * @author Jack Jefferson <jack.jefferson@mtc.co.uk>
 * @since 26/04/2025
 */
class RateLimit extends Model
{
    /**
     * The table associated with the model.
     *
     * @var string
     */
    protected $table = 'rate_limits';

    /**
     * The primary key type.
     *
     * @var string
     */
    protected $keyType = 'string';

    /**
     * Indicates if the IDs are auto-incrementing.
     *
     * @var bool
     */
    public $incrementing = false;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = ['id', 'key', 'ip_address'];

    /**
     * Log a rate limit attempt and check if limit is exceeded
     *
     * @param string $key Unique identifier for the rate limit check
     * @param string|null $message Custom message to show when limit exceeded
     * @param int $limit Maximum number of attempts allowed
     * @param int $period Time period in seconds to check attempts within
     * @param bool $throw Whether to throw exception (true) or exit with 429 (false) when exceeded
     * @throws \Exception When limit exceeded and $throw is true
     * @return array|null
     */
    public static function log($key, $message = null, $limit = 5, $period = 60, $throw = false)
    {
        $ipAddress = self::getIpAddress();

        // Check for escalated rate limit lockout first (if this isn't already the lockout check)
        if ($key !== 'rate_limit_exceeded') {
            $escalatedCount = self::where('key', 'rate_limit_exceeded')
                ->where('ip_address', $ipAddress)
                ->where('created_at', '>=', now()->subSeconds(300)) // 5 minute lockout
                ->count();

            // If user is in escalated lockout (more than 2 violations in 5 minutes)
            if ($escalatedCount > 2) {
                if ($throw) {
                    throw new \Exception('Too many rate limit violations. Please wait 5 minutes before trying again.');
                }

                return [
                    'limited' => true,
                    'message' => 'Too many rate limit violations. Please wait 5 minutes before trying again.',
                    'retry_after' => 300,
                ];
            }
        }

        // Count existing attempts within the time period
        $count = self::where('key', $key)
            ->where('ip_address', $ipAddress)
            ->where('created_at', '>=', now()->subSeconds($period))
            ->count();

        // Create rate limit record
        $item = new self;
        $item->id = Uuid::uuid4()->toString();
        $item->key = $key;
        $item->ip_address = $ipAddress;
        $item->save();

        // Increment count to include the current attempt
        $count++;

        // Handle exceeded limits
        if ($count >= $limit) {
            // Log escalated rate limit violation (but don't recurse)
            if ($key !== 'rate_limit_exceeded') {
                $escalationItem = new self;
                $escalationItem->id = Uuid::uuid4()->toString();
                $escalationItem->key = 'rate_limit_exceeded';
                $escalationItem->ip_address = $ipAddress;
                $escalationItem->save();
            }

            if ($throw) {
                throw new \Exception($message ?? 'Rate limit exceeded');
            }

            return [
                'limited' => true,
                'message' => $message ?? 'Rate limit exceeded',
                'retry_after' => $period,
            ];
        }

        return null;
    }

    /**
     * Clean up old rate limit records
     *
     * Deletes rate limit records older than 14 days to prevent database bloat
     *
     * @return void
     */
    public static function cleanup()
    {
        self::where('created_at', '<', now()->subDays(14))->delete();
    }

    /**
     * Get the IP address of the current request
     *
     * Checks common IP address headers, falling back to 0.0.0.0 if none found.
     * Validates IP addresses to prevent header spoofing.
     *
     * @return string IP address
     */
    private static function getIpAddress()
    {
        $keys = [
            'HTTP_CF_CONNECTING_IP', // Cloudflare IP (most trusted when using Cloudflare)
            'HTTP_X_FORWARDED_FOR', // Proxy/Load balancer (may contain multiple IPs)
            'HTTP_X_REAL_IP', // Nginx proxy
            'REMOTE_ADDR', // Direct client IP
        ];

        foreach ($keys as $key) {
            if (! (isset($_SERVER[$key]) && ! empty($_SERVER[$key]))) {
                continue;
            }

            $ip = $_SERVER[$key];

            // X-Forwarded-For may contain multiple IPs (client, proxy1, proxy2)
            // Take the first one (original client)
            if ($key === 'HTTP_X_FORWARDED_FOR' && str_contains($ip, ',')) {
                $ip = trim(explode(',', $ip)[0]);
            }

            // Validate IP address format
            if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
                return $ip;
            }

            // Allow private/reserved IPs for local development
            if (filter_var($ip, FILTER_VALIDATE_IP)) {
                return $ip;
            }
        }

        return '0.0.0.0';
    }

    /**
     * Get time remaining until rate limit expires
     *
     * @param string $key Rate limit key
     * @param int $period Time period in seconds
     * @return int Seconds remaining until oldest record expires
     */
    public static function getTimeRemaining($key, $period = 60)
    {
        $ipAddress = self::getIpAddress();

        $oldestRecord = self::where('key', $key)
            ->where('ip_address', $ipAddress)
            ->where('created_at', '>=', now()->subSeconds($period))
            ->orderBy('created_at', 'asc')
            ->first();

        if (! $oldestRecord) {
            return 0;
        }

        $expiresAt = $oldestRecord->created_at->addSeconds($period);
        $remaining = $expiresAt->diffInSeconds(now(), false);

        return max(0, (int) $remaining);
    }
}
