<?php

declare(strict_types=1);

namespace Instinctor\Core\Security;


if (!defined('ABSPATH')) exit;
use Instinctor\Core\DB;

/**
 * RateLimit — sliding-window rate limiter.
 *
 * Storage: APCu (in-process, fast) with DB fallback (instinctor_rate_limits).
 * APCu is used when available — DB is the source of truth for multi-process
 * environments (multiple PHP-FPM workers or standalone multi-server).
 *
 * Usage:
 *   if (!RateLimit::check('login:' . $ip, 5, 60)) {
 *       // reject — more than 5 hits in 60 seconds
 *   }
 *
 * Predefined limits (enforced by Security facade):
 *   login        5  / 60s   per IP
 *   register     5  / 60s   per IP
 *   checkout     10 / 60s   per IP
 *   contact_form 3  / 60s   per IP
 *   rest_api     60 / 60s   per IP
 *
 * Brute-force lock (tracked separately in instinctor_login_attempts):
 *   5 failed logins within 15 minutes → 15-minute IP lockout
 */
class RateLimit
{
    private const TABLE     = 'instinctor_rate_limits';
    private const PRUNE_KEY = 'instinctor_rl_last_prune';
    private const PRUNE_TTL = 3600; // prune old rows at most once per hour

    /**
     * Check and increment a rate limit bucket.
     *
     * @param string $key    Bucket identifier, e.g. "login:127.0.0.1"
     * @param int    $limit  Maximum allowed hits within the window
     * @param int    $window Window size in seconds
     * @return bool          true = allowed, false = limit exceeded
     */
    public static function check(string $key, int $limit, int $window): bool
    {
        // APCu fast path (single-process environments or small sites)
        if (function_exists('apcu_fetch')) {
            return self::checkApcu($key, $limit, $window);
        }

        return self::checkDb($key, $limit, $window);
    }

    /**
     * Manually clear a bucket (e.g. after successful login).
     */
    public static function clear(string $key): void
    {
        if (function_exists('apcu_delete')) {
            apcu_delete('rl:' . $key);
        }
        DB::query(DB::prepare(
            "DELETE FROM " . DB::table(self::TABLE) . " WHERE `key` = %s",
            $key
        ));
    }

    /**
     * Get current hit count for a bucket (for display/debugging).
     */
    public static function hits(string $key): int
    {
        if (function_exists('apcu_fetch')) {
            $data = apcu_fetch('rl:' . $key);
            if (is_array($data)) {
                return (int) $data['hits'];
            }
        }

        $row = DB::get_row(DB::prepare(
            "SELECT hits, window_start FROM " . DB::table(self::TABLE) . " WHERE `key` = %s",
            $key
        ));

        return $row ? (int) $row->hits : 0;
    }

    // -------------------------------------------------------------------------
    // Brute-force tracking (login-specific)
    // -------------------------------------------------------------------------

    private const ATTEMPTS_TABLE   = 'instinctor_login_attempts';
    private const BRUTE_LIMIT      = 5;   // failed attempts before lockout
    private const BRUTE_WINDOW     = 900; // 15 minutes
    private const LOCKOUT_DURATION = 900; // 15-minute lockout

    /**
     * Record a login attempt.
     *
     * @param string $identifier  Email or username
     * @param string $ip          Client IP
     * @param bool   $success     Whether the login succeeded
     */
    public static function recordLoginAttempt(string $identifier, string $ip, bool $success): void
    {
        DB::insert(DB::table(self::ATTEMPTS_TABLE), [
            'identifier'   => strtolower(trim($identifier)),
            'ip'           => $ip,
            'attempted_at' => gmdate('Y-m-d H:i:s'),
            'success'      => $success ? 1 : 0,
        ]);

        // Clear failed attempts on success
        if ($success) {
            self::clearLoginAttempts($identifier, $ip);
        }
    }

    /**
     * Check whether an IP or identifier is currently locked out.
     *
     * @return bool true = locked out (reject), false = allowed
     */
    public static function isLockedOut(string $identifier, string $ip): bool
    {
        $since = gmdate('Y-m-d H:i:s', time() - self::BRUTE_WINDOW);

        // Check by IP
        $failsByIp = (int) DB::get_var(DB::prepare(
            "SELECT COUNT(*) FROM " . DB::table(self::ATTEMPTS_TABLE) . "
             WHERE ip = %s AND success = 0 AND attempted_at >= %s",
            $ip,
            $since
        ));

        if ($failsByIp >= self::BRUTE_LIMIT) {
            return true;
        }

        // Check by identifier
        $failsByIdentifier = (int) DB::get_var(DB::prepare(
            "SELECT COUNT(*) FROM " . DB::table(self::ATTEMPTS_TABLE) . "
             WHERE identifier = %s AND success = 0 AND attempted_at >= %s",
            strtolower(trim($identifier)),
            $since
        ));

        return $failsByIdentifier >= self::BRUTE_LIMIT;
    }

    /**
     * Clear failed login attempts for an identifier/IP (after successful login).
     */
    public static function clearLoginAttempts(string $identifier, string $ip): void
    {
        DB::query(DB::prepare(
            "DELETE FROM " . DB::table(self::ATTEMPTS_TABLE) . "
             WHERE identifier = %s OR ip = %s",
            strtolower(trim($identifier)),
            $ip
        ));
    }

    /**
     * Prune old login attempt records (called daily by CronService).
     */
    public static function pruneLoginAttempts(int $days = 30): int
    {
        $before = gmdate('Y-m-d H:i:s', time() - ($days * 86400));
        return (int) DB::query(DB::prepare(
            "DELETE FROM " . DB::table(self::ATTEMPTS_TABLE) . "
             WHERE attempted_at < %s",
            $before
        ));
    }

    // -------------------------------------------------------------------------
    // Storage backends
    // -------------------------------------------------------------------------

    private static function checkApcu(string $key, int $limit, int $window): bool
    {
        $cacheKey = 'rl:' . $key;
        $now      = time();

        $data = apcu_fetch($cacheKey);

        if (!is_array($data) || ($now - $data['window_start']) >= $window) {
            // New window
            apcu_store($cacheKey, ['hits' => 1, 'window_start' => $now], $window + 10);
            return true;
        }

        if ($data['hits'] >= $limit) {
            return false;
        }

        apcu_inc($cacheKey . ':hits');

        // Update the stored hits count
        $data['hits']++;
        apcu_store($cacheKey, $data, $window - ($now - $data['window_start']) + 10);

        return true;
    }

    private static function checkDb(string $key, int $limit, int $window): bool
    {
        $now       = time();
        $expiresAt = gmdate('Y-m-d H:i:s', $now + $window);

        // Try to update an existing window row
        $row = DB::get_row(DB::prepare(
            "SELECT hits, window_start FROM " . DB::table(self::TABLE) . " WHERE `key` = %s",
            $key
        ));

        if (!$row || ($now - (int) $row->window_start) >= $window) {
            // New window — upsert with hits = 1
            DB::query(DB::prepare(
                "INSERT INTO " . DB::table(self::TABLE) . "
                 (`key`, hits, window_start, expires_at)
                 VALUES (%s, 1, %d, %s)
                 ON DUPLICATE KEY UPDATE
                     hits = 1,
                     window_start = VALUES(window_start),
                     expires_at = VALUES(expires_at)",
                $key,
                $now,
                $expiresAt
            ));

            self::maybePrune();
            return true;
        }

        if ((int) $row->hits >= $limit) {
            return false;
        }

        // Increment
        DB::query(DB::prepare(
            "UPDATE " . DB::table(self::TABLE) . " SET hits = hits + 1 WHERE `key` = %s",
            $key
        ));

        return true;
    }

    /**
     * Prune expired rows — runs at most once per hour to avoid overhead.
     */
    private static function maybePrune(): void
    {
        if (function_exists('apcu_fetch')) {
            if (apcu_fetch(self::PRUNE_KEY)) {
                return;
            }
            apcu_store(self::PRUNE_KEY, 1, self::PRUNE_TTL);
        }

        DB::query(
            "DELETE FROM " . DB::table(self::TABLE) . " WHERE expires_at < NOW()"
        );
    }
}
