<?php
/**
 * 404 probing tracker — flags scans like /.env, /wp-config.bak, /xmlrpc.php hits.
 *
 * @package Brainwerk Security Suite
 */

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

class Shieldforge_Probing {

    /** Patterns we consider scanning, not legit traffic. */
    private static $patterns = array(
        '/\.env(\.|$)/i',
        '/wp-config(\.bak|\.old|\.txt|\.swp|~)?$/i',
        '/wp-admin\.php$/i',
        '/xmlrpc\.php$/i',
        '/phpinfo\.php$/i',
        '/wp-login\.php\.bak$/i',
        '/\.git\//i',
        '/\.aws\//i',
        '/admin\.php\?action=/i',
        '/\.sql(\.|$)/i',
        '/sftp-config\.json$/i',
        '/\/server-status$/i',
        '/wp-content\/plugins\/.+\/(readme|changelog)\.txt$/i',
    );

    public static function register( Shieldforge_Loader $loader ) {
        if ( ! Shieldforge_Settings::get( 'probing_track_enabled', true ) ) {
            return;
        }
        // Hook on `wp` (priority 1) instead of `template_redirect`. The `wp`
        // action fires right after the main query is parsed (so is_404() is
        // already valid), but BEFORE any template_redirect-chain plugin can
        // exit() / wp_safe_redirect() and starve us. Tested on the Schule
        // staging where Seoforge_Redirects + Yoast comment_link_fixer were
        // running on template_redirect prio 1 ahead of us.
        $loader->add_action( 'wp', __CLASS__, 'maybe_track_404', 1 );
    }

    public static function maybe_track_404() {
        if ( ! is_404() ) {
            return;
        }
        $uri = Shieldforge_Utils::safe_request_uri();
        if ( '' === $uri ) {
            return;
        }
        $matched = false;
        foreach ( self::$patterns as $pattern ) {
            if ( preg_match( $pattern, $uri ) ) {
                $matched = $pattern;
                break;
            }
        }
        if ( ! $matched ) {
            return;
        }
        Shieldforge_Logger::log( Shieldforge_Logger::EVT_PROBING_404, array(
            'severity' => Shieldforge_Logger::SEV_WARN,
            'context'  => array( 'pattern' => $matched ),
        ) );

        // Threshold check: alert when one IP probes a lot in a short window.
        $threshold = (int) Shieldforge_Settings::get( 'probing_alert_threshold', 20 );
        if ( $threshold < 1 ) {
            return;
        }
        $ip = Shieldforge_Utils::get_real_ip();
        // The log table stores the IP in its privacy-normalized form (anonymized
        // when privacy_anonymize_ip is on, which is the default). Querying with
        // the RAW IP never matched the stored "1.2.3.0" rows, so this threshold
        // silently never fired. Count against the same stored form. (The event
        // below intentionally still carries the raw IP for cross-site signal.)
        $ip_stored = Shieldforge_Utils::store_ip( $ip );
        $since = gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS );
        $count = Shieldforge_Logger::count_since( $since, array(
            'event_type' => Shieldforge_Logger::EVT_PROBING_404,
            'ip'         => $ip_stored,
        ) );
        if ( $count >= $threshold ) {
            do_action( 'shieldforge_probing_threshold', $ip, $count );
            // Threat-network sensor: this IP is actively scanning — high-value
            // cross-site signal. Emit a typed event with the raw IP.
            if ( class_exists( 'Shieldforge_Event' ) ) {
                Shieldforge_Event::emit( array(
                    'event_type' => 'probe.threshold',
                    'severity'   => 'warn',
                    'actor'      => array(
                        'ip'         => $ip,
                        'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
                    ),
                    'context'    => array(
                        'count'          => (int) $count,
                        'window_seconds' => HOUR_IN_SECONDS,
                    ),
                ) );
            }
        }
    }

    public static function patterns() {
        return self::$patterns;
    }
}
