<?php
/**
 * Crawler & Traffic Rate Limiting — throttles aggressive clients and
 * unmasks fake search-engine crawlers.
 *
 * Funktionsweise:
 *  - Zählt anonyme Frontend-Requests pro IP in festen 60-Sekunden-Fenstern
 *    (Site-Transients, kein eigener Tabellen-Overhead).
 *  - Getrennte Schwellen für Menschen und Crawler (UA-Klassifikation).
 *  - Echte Suchmaschinen-Bots (Google/Bing/Apple/Yandex/Baidu) werden per
 *    Reverse-DNS + Forward-Confirm verifiziert (Ergebnis 1 Woche gecacht)
 *    und NIE gedrosselt — SEO-sicher.
 *  - Clients, die einen Suchmaschinen-UA vortäuschen, aber die DNS-Prüfung
 *    nicht bestehen, können sofort geblockt werden (Fake-Googlebot-Schutz).
 *  - Wer sein Limit wiederholt reißt (3 Strikes), wird für N Minuten
 *    gebannt (429 + Eintrag in der Block-Tabelle für die Firewall-Ansicht).
 *
 * Privacy: Zähler werden unter md5(IP) abgelegt (kein Klartext) und leben
 * nur Minuten. Logs/Blocks folgen wie überall der store_ip()-Konvention.
 *
 * Default OFF (`ratelimit_enabled`). Läuft auf 'init' prio 2 — direkt nach
 * der WAF (prio 1), damit von ihr geblockte Requests nicht doppelt zählen.
 *
 * @package Brainwerk Security Suite
 */

if ( ! defined( 'ABSPATH' ) ) exit;

class Shieldforge_Rate_Limiter {

    const WINDOW         = 60;     // Sekunden pro Zähl-Fenster
    const STRIKES_TO_BAN = 3;      // Fenster-Überschreitungen bis zum Bann
    const STRIKE_MEMORY  = 900;    // Strikes verfallen nach 15 min Ruhe
    const BOT_CACHE_TTL  = WEEK_IN_SECONDS;

    public static function register( Shieldforge_Loader $loader ) {
        if ( ! Shieldforge_Settings::get( 'ratelimit_enabled', false ) ) return;
        $loader->add_action( 'init', __CLASS__, 'enforce', 2 );
    }

    public static function enforce() {
        if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) return;
        if ( defined( 'WP_CLI' ) && WP_CLI ) return;
        if ( is_user_logged_in() ) return;

        $ip = Shieldforge_Utils::get_real_ip();
        if ( ! $ip ) return;
        $whitelist = (array) Shieldforge_Settings::get( 'bruteforce_whitelist', array() );
        if ( Shieldforge_Utils::ip_in_list( $ip, $whitelist ) ) return;

        $ua      = Shieldforge_Utils::safe_user_agent();
        $claimed = self::claimed_search_bot( $ua );

        if ( $claimed && Shieldforge_Settings::get( 'ratelimit_verify_bots', true ) ) {
            if ( self::verify_search_bot( $ip, $claimed ) ) {
                return; // verifizierte Suchmaschine — niemals drosseln
            }
            if ( Shieldforge_Settings::get( 'ratelimit_block_fake_bots', true ) ) {
                self::punish_fake_bot( $ip, $ua, $claimed ); // exits
            }
        }

        $is_crawler = $claimed || self::looks_like_bot( $ua );
        $limit      = $is_crawler
            ? max( 1, (int) Shieldforge_Settings::get( 'ratelimit_crawler_rpm', 60 ) )
            : max( 1, (int) Shieldforge_Settings::get( 'ratelimit_rpm', 240 ) );

        self::count_and_maybe_throttle( $ip, $limit, $is_crawler ? 'crawler' : 'human' );
    }

    /* ---------------------------------------------------------------------
     * Suchmaschinen-Verifikation
     * ------------------------------------------------------------------- */

    /**
     * Erkennt, ob der UA einen der großen Suchmaschinen-Crawler BEHAUPTET.
     * Liefert dann die Bot-Definition (id + erlaubte rDNS-Suffixe), sonst null.
     */
    private static function claimed_search_bot( $ua ) {
        if ( '' === $ua ) return null;
        $bots = array(
            array( 'id' => 'googlebot', 'ua' => '/googlebot|adsbot-google|apis-google|mediapartners-google/i', 'rdns' => array( '.googlebot.com', '.google.com' ) ),
            array( 'id' => 'bingbot',   'ua' => '/bingbot|bingpreview|msnbot/i',                               'rdns' => array( '.search.msn.com' ) ),
            array( 'id' => 'applebot',  'ua' => '/applebot/i',                                                 'rdns' => array( '.applebot.apple.com' ) ),
            array( 'id' => 'yandexbot', 'ua' => '/yandex(bot|images|metrika)/i',                               'rdns' => array( '.yandex.ru', '.yandex.net', '.yandex.com' ) ),
            array( 'id' => 'baiduspider', 'ua' => '/baiduspider/i',                                            'rdns' => array( '.crawl.baidu.com', '.crawl.baidu.jp' ) ),
        );
        foreach ( $bots as $bot ) {
            if ( preg_match( $bot['ua'], $ua ) ) return $bot;
        }
        return null;
    }

    /**
     * Reverse-DNS + Forward-Confirm (das von Google/Bing offiziell
     * dokumentierte Verfahren). Ergebnis wird pro IP+Bot 1 Woche gecacht,
     * damit der (langsame) DNS-Roundtrip nur einmal anfällt.
     */
    private static function verify_search_bot( $ip, $bot ) {
        $key    = 'shieldforge_rl_bot_' . md5( $ip . '|' . $bot['id'] );
        $cached = get_site_transient( $key );
        if ( false !== $cached ) return (bool) $cached;

        $verified = false;
        $host     = @gethostbyaddr( $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- reverse-DNS lookup on an untrusted IP may warn/fail; failure yields false, handled by the truthy check below.
        if ( $host && $host !== $ip ) {
            foreach ( $bot['rdns'] as $suffix ) {
                if ( strlen( $host ) > strlen( $suffix )
                    && substr( strtolower( $host ), -strlen( $suffix ) ) === strtolower( $suffix ) ) {
                    $verified = self::forward_confirm( $host, $ip );
                    break;
                }
            }
        }
        set_site_transient( $key, $verified ? 1 : 0, self::BOT_CACHE_TTL );
        return $verified;
    }

    /** Forward-Lookup des rDNS-Hostnamens muss wieder auf die IP zeigen. */
    private static function forward_confirm( $host, $ip ) {
        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
            $records = @dns_get_record( $host, DNS_A ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- forward DNS lookup may warn/fail on an untrusted hostname; failure yields false, handled by the is_array() check below.
            $field   = 'ip';
        } else {
            $records = @dns_get_record( $host, DNS_AAAA ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- forward DNS lookup may warn/fail on an untrusted hostname; failure yields false, handled by the is_array() check below.
            $field   = 'ipv6';
        }
        if ( ! is_array( $records ) ) return false;
        $needle = @inet_pton( $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- malformed IP triggers a PHP warning; failure yields false, handled by the === comparison below.
        foreach ( $records as $rec ) {
            if ( ! empty( $rec[ $field ] ) && @inet_pton( $rec[ $field ] ) === $needle ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- malformed DNS-record IP triggers a PHP warning; failure yields false, handled by the === comparison.
                return true;
            }
        }
        return false;
    }

    /**
     * Generische Bot-Heuristik (kein Anspruch auf Suchmaschinen-Status).
     * `bot\b` statt `\bbot\b`, damit Compound-Namen (AhrefsBot, MJ12bot,
     * DotBot …) matchen — Preis: ein "Cubot"-Handy-UA fällt mit ins
     * Crawler-Budget. Das ist nur das niedrigere Limit, kein Block.
     */
    private static function looks_like_bot( $ua ) {
        if ( '' === $ua ) return true; // leerer UA = sicher kein Browser
        return (bool) preg_match( '/bot\b|\b(crawler|crawl|spider|slurp|scrape|scraper|fetcher|httpclient|curl|wget|python-requests|libwww|go-http-client|okhttp|node-fetch|axios|headless)\b/i', $ua );
    }

    /* ---------------------------------------------------------------------
     * Durchsetzung
     * ------------------------------------------------------------------- */

    private static function punish_fake_bot( $ip, $ua, $bot ) {
        $ban_minutes = max( 1, (int) Shieldforge_Settings::get( 'ratelimit_ban_minutes', 30 ) );
        if ( class_exists( 'Shieldforge_Logger' ) ) {
            Shieldforge_Logger::log( 'fake_crawler', array(
                'severity' => Shieldforge_Logger::SEV_HIGH,
                'context'  => array( 'claimed_bot' => $bot['id'] ),
            ) );
        }
        if ( class_exists( 'Shieldforge_Bruteforce' ) && method_exists( 'Shieldforge_Bruteforce', 'block_ip' ) ) {
            Shieldforge_Bruteforce::block_ip( $ip, $ban_minutes, 'fake-crawler:' . $bot['id'] );
        }
        if ( class_exists( 'Shieldforge_Event' ) ) {
            Shieldforge_Event::emit( array(
                'event_type' => 'traffic.fake_crawler',
                'severity'   => 'high',
                'actor'      => array( 'ip' => $ip, 'user_agent' => $ua ),
                'target'     => array( 'kind' => 'endpoint', 'endpoint' => Shieldforge_Utils::safe_request_uri() ),
                'context'    => array( 'claimed_bot' => $bot['id'] ),
            ) );
        }
        status_header( 403 );
        nocache_headers();
        header( 'Content-Type: text/plain; charset=UTF-8' );
        echo "403 Forbidden\n\nCrawler identity could not be verified.\n";
        exit;
    }

    private static function count_and_maybe_throttle( $ip, $limit, $class ) {
        $key    = 'shieldforge_rl_' . md5( $ip );
        $now    = time();
        $bucket = (int) floor( $now / self::WINDOW );

        $state = get_site_transient( $key );
        if ( ! is_array( $state ) ) {
            $state = array( 'b' => $bucket, 'c' => 0, 's' => 0, 'sv' => 0, 'ban' => 0, 'warned' => 0 );
        }

        // Aktiver Bann → sofort 429, kein weiteres Zählen
        if ( ! empty( $state['ban'] ) && $state['ban'] > $now ) {
            self::respond_429( $state['ban'] - $now );
        }

        if ( $state['b'] !== $bucket ) {
            $state['b']      = $bucket;
            $state['c']      = 0;
            $state['warned'] = 0;
        }
        if ( ! empty( $state['s'] ) && ( $now - (int) $state['sv'] ) > self::STRIKE_MEMORY ) {
            $state['s'] = 0;
        }

        $state['c']++;

        if ( $state['c'] <= $limit ) {
            set_site_transient( $key, $state, self::STRIKE_MEMORY );
            return;
        }

        $retry = self::WINDOW - ( $now % self::WINDOW );
        if ( empty( $state['warned'] ) ) {
            // Nur ein Strike + Log-Eintrag pro Fenster, sonst flutet ein
            // einziger aggressiver Client das eigene Log.
            $state['warned'] = 1;
            $state['s']++;
            $state['sv'] = $now;
            if ( class_exists( 'Shieldforge_Logger' ) ) {
                Shieldforge_Logger::log( 'rate_limited', array(
                    'severity' => Shieldforge_Logger::SEV_WARN,
                    'context'  => array( 'client_class' => $class, 'limit_rpm' => $limit ),
                ) );
            }
            if ( class_exists( 'Shieldforge_Event' ) ) {
                Shieldforge_Event::emit( array(
                    'event_type' => 'traffic.rate_limited',
                    'severity'   => 'medium',
                    'actor'      => array( 'ip' => $ip, 'user_agent' => Shieldforge_Utils::safe_user_agent() ),
                    'target'     => array( 'kind' => 'endpoint', 'endpoint' => Shieldforge_Utils::safe_request_uri() ),
                    'context'    => array( 'client_class' => $class, 'limit_rpm' => $limit, 'strikes' => (int) $state['s'] ),
                ) );
            }
        }

        if ( $state['s'] >= self::STRIKES_TO_BAN ) {
            $ban_minutes  = max( 1, (int) Shieldforge_Settings::get( 'ratelimit_ban_minutes', 30 ) );
            $state['ban'] = $now + $ban_minutes * MINUTE_IN_SECONDS;
            $retry        = $ban_minutes * MINUTE_IN_SECONDS;
            // Auch in die Block-Tabelle — macht den Bann im Firewall-Tab
            // sichtbar und deckt den Login-Pfad gleich mit ab.
            if ( class_exists( 'Shieldforge_Bruteforce' ) && method_exists( 'Shieldforge_Bruteforce', 'block_ip' ) ) {
                Shieldforge_Bruteforce::block_ip( $ip, $ban_minutes, 'rate-limit:' . $class );
            }
            if ( class_exists( 'Shieldforge_Logger' ) ) {
                Shieldforge_Logger::log( 'rate_limit_ban', array(
                    'severity' => Shieldforge_Logger::SEV_HIGH,
                    'context'  => array( 'client_class' => $class, 'ban_minutes' => $ban_minutes ),
                ) );
            }
        }

        $ttl = max( self::STRIKE_MEMORY, ( ! empty( $state['ban'] ) ? ( $state['ban'] - $now ) + MINUTE_IN_SECONDS : 0 ) );
        set_site_transient( $key, $state, $ttl );
        self::respond_429( $retry );
    }

    private static function respond_429( $retry_after ) {
        status_header( 429 );
        nocache_headers();
        header( 'Content-Type: text/plain; charset=UTF-8' );
        header( 'Retry-After: ' . max( 1, (int) $retry_after ) );
        echo "429 Too Many Requests\n\nSlow down and retry later.\n";
        exit;
    }
}
